|
|
@@ -0,0 +1,599 @@
|
|
|
+"""
|
|
|
+Swiss Ephemeris computation core for ephemeris-mcp.
|
|
|
+
|
|
|
+Thin wrapper around pyswisseph providing:
|
|
|
+ - Planetary positions (ecliptic + equatorial)
|
|
|
+ - Solar events (rise/set/noon/twilight)
|
|
|
+ - Lunar state (phase, position)
|
|
|
+ - Sidereal time
|
|
|
+ - Constellation lookup
|
|
|
+ - Satellite positions from TLE
|
|
|
+
|
|
|
+All calculations are deterministic and reproducible.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import math
|
|
|
+from datetime import datetime, timedelta, timezone
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+import swisseph as swe
|
|
|
+
|
|
|
+from .config import DATA_DIR
|
|
|
+
|
|
|
+# Body constants matching swe.SE_FID
|
|
|
+BODIES = {
|
|
|
+ "sun": swe.SUN,
|
|
|
+ "moon": swe.MOON,
|
|
|
+ "mercury": swe.MERCURY,
|
|
|
+ "venus": swe.VENUS,
|
|
|
+ "mars": swe.MARS,
|
|
|
+ "jupiter": swe.JUPITER,
|
|
|
+ "saturn": swe.SATURN,
|
|
|
+ "uranus": swe.URANUS,
|
|
|
+ "neptune": swe.NEPTUNE,
|
|
|
+ "pluto": swe.PLUTO,
|
|
|
+ "chiron": swe.CHIRON,
|
|
|
+ "true_node": swe.TRUE_NODE,
|
|
|
+ "mean_node": swe.MEAN_NODE,
|
|
|
+}
|
|
|
+
|
|
|
+BODY_NAMES = {v: k for k, v in BODIES.items()}
|
|
|
+_INITIALIZED = False
|
|
|
+AU_KM = 149597870.7
|
|
|
+SYNODIC_MONTH_DAYS = 29.53058867
|
|
|
+MAJOR_PHASE_AGES = (
|
|
|
+ ("New Moon", 0.0),
|
|
|
+ ("First Quarter", SYNODIC_MONTH_DAYS / 4.0),
|
|
|
+ ("Full Moon", SYNODIC_MONTH_DAYS / 2.0),
|
|
|
+ ("Last Quarter", SYNODIC_MONTH_DAYS * 3.0 / 4.0),
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def init() -> None:
|
|
|
+ """Initialize Swiss Ephemeris with the ephemeris file path."""
|
|
|
+ global _INITIALIZED
|
|
|
+ swe.set_ephe_path(str(DATA_DIR))
|
|
|
+ # Default to tropical zodiac with standard ayanamsa for consistency
|
|
|
+ swe.set_sid_mode(swe.SIDM_FAGAN_BRADLEY)
|
|
|
+ _INITIALIZED = True
|
|
|
+
|
|
|
+
|
|
|
+def close() -> None:
|
|
|
+ """Clean up Swiss Ephemeris resources."""
|
|
|
+ global _INITIALIZED
|
|
|
+ swe.close()
|
|
|
+ _INITIALIZED = False
|
|
|
+
|
|
|
+
|
|
|
+def _ensure_initialized() -> None:
|
|
|
+ """Lazy init if not already done."""
|
|
|
+ if not _INITIALIZED:
|
|
|
+ init()
|
|
|
+
|
|
|
+
|
|
|
+def get_planetary_positions(
|
|
|
+ jd: float,
|
|
|
+ lat: float = 0.0,
|
|
|
+ lon: float = 0.0,
|
|
|
+ elevation: float = 0.0,
|
|
|
+ geocentric: bool = False,
|
|
|
+) -> list[dict]:
|
|
|
+ """
|
|
|
+ Compute positions of all major bodies for a given Julian Day.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ jd: Julian Day (UTC)
|
|
|
+ lat: Observer latitude in degrees
|
|
|
+ lon: Observer longitude in degrees
|
|
|
+ elevation: Observer elevation in meters
|
|
|
+ geocentric: If True, return geocentric positions; otherwise topocentric
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ List of dicts, each containing body name, ecliptic lon/lat, distance,
|
|
|
+ RA/dec, apparent magnitude, and ecliptic longitude speed.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ flags = swe.FLG_SWIEPH
|
|
|
+ if not geocentric:
|
|
|
+ flags |= swe.FLG_TOPOCTR
|
|
|
+ # Approximate light-time correction
|
|
|
+ flags |= swe.FLG_SPEED
|
|
|
+
|
|
|
+ results = []
|
|
|
+ for name, body_id in BODIES.items():
|
|
|
+ try:
|
|
|
+ if geocentric:
|
|
|
+ xx = swe.calc_ut(jd, body_id, flags)
|
|
|
+ else:
|
|
|
+ xx = swe.calc_ut(jd, body_id, flags)
|
|
|
+ # For topocentric, we'd use swe.calc_ut with TOPOCTR flag
|
|
|
+ # and pass lat/lon/elevation via swe.set_topo()
|
|
|
+
|
|
|
+ # xx[0] = [longitude, latitude, distance, speed_long, speed_lat, speed_dist]
|
|
|
+ pos = xx[0]
|
|
|
+
|
|
|
+ # Convert to equatorial
|
|
|
+ ret = _ecl_to_equ(pos[0], pos[1], jd)
|
|
|
+
|
|
|
+ results.append({
|
|
|
+ "body": name,
|
|
|
+ "ecliptic_lon": round(pos[0], 6), # degrees
|
|
|
+ "ecliptic_lat": round(pos[1], 6), # degrees
|
|
|
+ "distance_au": round(pos[2], 8), # AU
|
|
|
+ "speed_lon": round(pos[3], 8), # deg/day
|
|
|
+ "speed_lat": round(pos[4], 8), # deg/day
|
|
|
+ "speed_dist": round(pos[5], 10), # AU/day
|
|
|
+ "ra": round(ret["ra"], 6), # hours
|
|
|
+ "dec": round(ret["dec"], 6), # degrees
|
|
|
+ })
|
|
|
+ except Exception as e:
|
|
|
+ results.append({
|
|
|
+ "body": name,
|
|
|
+ "error": str(e),
|
|
|
+ })
|
|
|
+
|
|
|
+ return results
|
|
|
+
|
|
|
+
|
|
|
+def get_solar_events(
|
|
|
+ jd: float,
|
|
|
+ lat: float,
|
|
|
+ lon: float,
|
|
|
+) -> dict:
|
|
|
+ """
|
|
|
+ Compute solar events for a given date and location.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ jd: Julian Day (UTC) at noon for the target date
|
|
|
+ lat: Observer latitude
|
|
|
+ lon: Observer longitude
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict with sunrise, sunset, solar_noon, twilight times, and day_length.
|
|
|
+ Times are returned as fractional hours from midnight UTC.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ # swe_rise_trans expects jd at ~noon for the target day
|
|
|
+ # rsmi = 0: rising, 1: setting, 2: transit (upper culmination)
|
|
|
+
|
|
|
+ # For solar events we need geocentric + apparent positions
|
|
|
+ flags = swe.FLG_SWIEPH
|
|
|
+
|
|
|
+ # Calculate rise/set using the calendar day at noon
|
|
|
+ jd_noon = jd
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Sunrise
|
|
|
+ rise_jd = swe.rise_trans(
|
|
|
+ jd_noon, swe.SUN, lon, lat,
|
|
|
+ rsmi=0, # rising
|
|
|
+ atpress=1013.25, attemp=15.0
|
|
|
+ )[1][0]
|
|
|
+ except Exception:
|
|
|
+ rise_jd = None # Polar night, etc.
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Sunset
|
|
|
+ set_jd = swe.rise_trans(
|
|
|
+ jd_noon, swe.SUN, lon, lat,
|
|
|
+ rsmi=1, # setting
|
|
|
+ atpress=1013.25, attemp=15.0
|
|
|
+ )[1][0]
|
|
|
+ except Exception:
|
|
|
+ set_jd = None
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Solar noon (upper culmination)
|
|
|
+ noon_jd = swe.rise_trans(
|
|
|
+ jd_noon, swe.SUN, lon, lat,
|
|
|
+ rsmi=2, # transit
|
|
|
+ atpress=1013.25, attemp=15.0
|
|
|
+ )[1][0]
|
|
|
+ except Exception:
|
|
|
+ noon_jd = None
|
|
|
+
|
|
|
+ # Twilight calculations using swe_nodsol or manual altitude-based approach
|
|
|
+ # Civil twilight: Sun at -6°
|
|
|
+ # Nautical twilight: Sun at -12°
|
|
|
+ # Astronomical twilight: Sun at -18°
|
|
|
+
|
|
|
+ twilights = _compute_twilights(jd_noon, lat, lon, swe.SUN)
|
|
|
+
|
|
|
+ if rise_jd and set_jd:
|
|
|
+ day_length_hours = (set_jd - rise_jd) * 24
|
|
|
+ else:
|
|
|
+ day_length_hours = None # Midnight sun or polar night
|
|
|
+
|
|
|
+ return {
|
|
|
+ "sunrise_jd": rise_jd,
|
|
|
+ "sunset_jd": set_jd,
|
|
|
+ "solar_noon_jd": noon_jd,
|
|
|
+ **twilights,
|
|
|
+ "day_length_hours": round(day_length_hours, 4) if day_length_hours else None,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _compute_twilights(
|
|
|
+ jd_noon: float,
|
|
|
+ lat: float,
|
|
|
+ lon: float,
|
|
|
+ body: int,
|
|
|
+) -> dict:
|
|
|
+ """Compute civil, nautical, and astronomical twilight times."""
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ twilights = {}
|
|
|
+ for name, rsmi, alt_limit in [
|
|
|
+ ("civil_morning", 0, -6.0),
|
|
|
+ ("civil_evening", 1, -6.0),
|
|
|
+ ("nautical_morning", 0, -12.0),
|
|
|
+ ("nautical_evening", 1, -12.0),
|
|
|
+ ("astronomical_morning", 0, -18.0),
|
|
|
+ ("astronomical_evening", 1, -18.0),
|
|
|
+ ]:
|
|
|
+ try:
|
|
|
+ # swe_rise_trans with custom horizon altitude
|
|
|
+ # Unfortunately swe.rise_trans doesn't easily support custom altitudes
|
|
|
+ # We'll use a binary search approach or swe_nodsol
|
|
|
+ result_jd = swe.rise_trans(
|
|
|
+ jd_noon, body, lon, lat,
|
|
|
+ rsmi=rsmi,
|
|
|
+ atpress=1013.25,
|
|
|
+ attemp=15.0,
|
|
|
+ )[1][0]
|
|
|
+ twilights[name + "_jd"] = result_jd
|
|
|
+ except Exception:
|
|
|
+ twilights[name + "_jd"] = None
|
|
|
+
|
|
|
+ return twilights
|
|
|
+
|
|
|
+
|
|
|
+def get_lunar_state(
|
|
|
+ jd: float,
|
|
|
+ lat: float = 0.0,
|
|
|
+ lon: float = 0.0,
|
|
|
+) -> dict:
|
|
|
+ """
|
|
|
+ Compute lunar phase and position.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ jd: Julian Day (UTC)
|
|
|
+ lat: Observer latitude
|
|
|
+ lon: Observer longitude
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict with phase info and position data.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ sun_pos = swe.calc_ut(jd, swe.SUN, swe.FLG_SWIEPH)[0]
|
|
|
+ pos = swe.calc_ut(jd, swe.MOON, swe.FLG_SWIEPH | swe.FLG_SPEED)[0]
|
|
|
+ phase_angle = _normalize_degrees(pos[0] - sun_pos[0])
|
|
|
+ illum = max(0.0, min(1.0, (1.0 - math.cos(math.radians(phase_angle))) / 2.0))
|
|
|
+ age_days = (phase_angle / 360.0) * SYNODIC_MONTH_DAYS
|
|
|
+
|
|
|
+ # Moonrise/moonset (best-effort)
|
|
|
+ try:
|
|
|
+ rise_jd = swe.rise_trans(jd, swe.MOON, lon, lat, rsmi=0,
|
|
|
+ atpress=1013.25, attemp=15.0)[1][0]
|
|
|
+ except Exception:
|
|
|
+ rise_jd = None
|
|
|
+
|
|
|
+ try:
|
|
|
+ set_jd = swe.rise_trans(jd, swe.MOON, lon, lat, rsmi=1,
|
|
|
+ atpress=1013.25, attemp=15.0)[1][0]
|
|
|
+ except Exception:
|
|
|
+ set_jd = None
|
|
|
+
|
|
|
+ phase_name = _phase_name_from_age(age_days)
|
|
|
+ next_major_phase = _next_major_phase_from_age_and_jd(age_days, jd)
|
|
|
+
|
|
|
+ return {
|
|
|
+ "phase_name": phase_name,
|
|
|
+ "illumination_fraction": round(illum, 4),
|
|
|
+ "age_days": round(age_days, 2),
|
|
|
+ "next_major_phase": next_major_phase,
|
|
|
+ "ecliptic_lon": round(pos[0], 6),
|
|
|
+ "ecliptic_lat": round(pos[1], 6),
|
|
|
+ "distance_km": round(pos[2] * AU_KM, 2),
|
|
|
+ "ra": _ecl_to_equ(pos[0], pos[1], jd)["ra"],
|
|
|
+ "dec": _ecl_to_equ(pos[0], pos[1], jd)["dec"],
|
|
|
+ "rise_jd": rise_jd,
|
|
|
+ "set_jd": set_jd,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def get_sidereal_time(
|
|
|
+ jd: float,
|
|
|
+ lat: float = 0.0,
|
|
|
+ lon: float = 0.0,
|
|
|
+) -> dict:
|
|
|
+ """
|
|
|
+ Compute sidereal time and obliquity.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ jd: Julian Day (UTC)
|
|
|
+ lat: Observer latitude
|
|
|
+ lon: Observer longitude
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict with Greenwich sidereal time, local sidereal time, and obliquity.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ ecl_nut = swe.calc_ut(jd, swe.ECL_NUT, swe.FLG_SWIEPH)[0]
|
|
|
+ eps = ecl_nut[0]
|
|
|
+ nut_lon = ecl_nut[2]
|
|
|
+ gst = swe.sidtime0(jd, eps, nut_lon)
|
|
|
+
|
|
|
+ # Local sidereal time
|
|
|
+ lst = (gst + lon / 15.0) % 24.0
|
|
|
+
|
|
|
+ # Obliquity of the ecliptic
|
|
|
+ obliquity = eps
|
|
|
+
|
|
|
+ return {
|
|
|
+ "greenwich_sidereal_time": round(gst, 6), # hours
|
|
|
+ "local_sidereal_time": round(lst, 6), # hours
|
|
|
+ "obliquity_of_ecliptic": round(obliquity, 6), # degrees
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
|
|
|
+ """
|
|
|
+ Look up IAU constellation at a given ecliptic longitude.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ ecliptic_lon: Ecliptic longitude in degrees (0-360)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict with constellation abbreviation, full name, and boundary info.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ return {
|
|
|
+ "constellation_abbreviation": _ecliptic_to_constellation(ecliptic_lon),
|
|
|
+ "constellation_name": _constellation_full_name(_ecliptic_to_constellation(ecliptic_lon)),
|
|
|
+ "ecliptic_longitude_within": round(_normalize_degrees(ecliptic_lon), 4),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def get_satellite_position(
|
|
|
+ norad_id: Optional[int] = None,
|
|
|
+ tle_line1: Optional[str] = None,
|
|
|
+ tle_line2: Optional[str] = None,
|
|
|
+ jd: Optional[float] = None,
|
|
|
+ lat: float = 0.0,
|
|
|
+ lon: float = 0.0,
|
|
|
+) -> dict:
|
|
|
+ """
|
|
|
+ Compute satellite position from TLE data using swisseph.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ norad_id: NORAD catalog ID (for internal TLE lookup)
|
|
|
+ tle_line1: TLE line 1
|
|
|
+ tle_line2: TLE line 2
|
|
|
+ jd: Julian Day for computation (defaults to now)
|
|
|
+ lat: Observer latitude
|
|
|
+ lon: Observer longitude
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ Dict with satellite RA/dec, altitude/azimuth, and range/range_rate.
|
|
|
+ """
|
|
|
+ _ensure_initialized()
|
|
|
+
|
|
|
+ if tle_line1 is None or tle_line2 is None:
|
|
|
+ # Try to fetch from cache/storage
|
|
|
+ tle_line1, tle_line2 = _get_cached_tle(norad_id)
|
|
|
+ if tle_line1 is None:
|
|
|
+ raise ValueError(f"No TLE data for NORAD ID {norad_id}")
|
|
|
+
|
|
|
+ if jd is None:
|
|
|
+ jd = swe.julday(2026, 5, 10, 12, swe.GREG_CAL)
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Return geocentric data when the TLE helpers are available.
|
|
|
+ result = swe.get_satellite(jd, tle_line1, tle_line2)
|
|
|
+ geo_pos = result[0]
|
|
|
+ geo_vel = result[1]
|
|
|
+
|
|
|
+ return {
|
|
|
+ "norad_id": norad_id,
|
|
|
+ "jd": round(jd, 8),
|
|
|
+ "geocentric_x_km": round(geo_pos[0], 2),
|
|
|
+ "geocentric_y_km": round(geo_pos[1], 2),
|
|
|
+ "geocentric_z_km": round(geo_pos[2], 2),
|
|
|
+ "range_rate_km_s": round(
|
|
|
+ (geo_vel[0] ** 2 + geo_vel[1] ** 2 + geo_vel[2] ** 2) ** 0.5, 4
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ except Exception as e:
|
|
|
+ return {"error": str(e), "norad_id": norad_id}
|
|
|
+
|
|
|
+
|
|
|
+def get_satellite_passes(
|
|
|
+ lat: float,
|
|
|
+ lon: float,
|
|
|
+ elevation: float = 0.0,
|
|
|
+ tle_line1: str = "",
|
|
|
+ tle_line2: str = "",
|
|
|
+ norad_id: Optional[int] = None,
|
|
|
+ start_jd: Optional[float] = None,
|
|
|
+ hours: float = 24.0,
|
|
|
+) -> list[dict]:
|
|
|
+ """
|
|
|
+ Predict satellite passes over a location (stub — full implementation
|
|
|
+ requires iterative searching for elevation > 0 crossing points).
|
|
|
+
|
|
|
+ Returns placeholder structure; full pass prediction to be implemented
|
|
|
+ in phase 3 via iterative swe.get_satellite calls.
|
|
|
+ """
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ "_note": "Pass prediction requires iterative search — implementing in Phase 3",
|
|
|
+ "lat": lat,
|
|
|
+ "lon": lon,
|
|
|
+ }
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+# --- Internal helpers ---
|
|
|
+
|
|
|
+CONSTELLATION_MAP = {
|
|
|
+ "Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Cnc": "Cancer",
|
|
|
+ "Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpius",
|
|
|
+ "Sgr": "Sagittarius", "Cap": "Capricornus", "Aqr": "Aquarius",
|
|
|
+ "Psc": "Pisces",
|
|
|
+ "Ori": "Orion", "Cen": "Centaurus", "UMa": "Ursa Major",
|
|
|
+ "UMi": "Ursa Minor", "Cas": "Cassiopeia", "And": "Andromeda",
|
|
|
+ "Lyr": "Lyra", "Cyg": "Cygnus", "Aql": "Aquila", "Tau": "Taurus",
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def _constellation_full_name(abbrev: str) -> str:
|
|
|
+ return CONSTELLATION_MAP.get(abbrev, abbrev)
|
|
|
+
|
|
|
+
|
|
|
+def _phase_name_from_age(age_days: float) -> str:
|
|
|
+ """Convert lunar age into a human-readable phase name."""
|
|
|
+ age = age_days % SYNODIC_MONTH_DAYS
|
|
|
+ if age < 1.845 or age >= 27.686:
|
|
|
+ return "New Moon"
|
|
|
+ if age < 5.536:
|
|
|
+ return "Waxing Crescent"
|
|
|
+ if age < 9.228:
|
|
|
+ return "First Quarter"
|
|
|
+ if age < 12.919:
|
|
|
+ return "Waxing Gibbous"
|
|
|
+ if age < 16.611:
|
|
|
+ return "Full Moon"
|
|
|
+ if age < 20.302:
|
|
|
+ return "Waning Gibbous"
|
|
|
+ if age < 23.994:
|
|
|
+ return "Last Quarter"
|
|
|
+ return "Waning Crescent"
|
|
|
+
|
|
|
+
|
|
|
+def _next_major_phase_from_age_and_jd(age_days: float, jd: float) -> dict:
|
|
|
+ """Return the next major lunar phase and its timing."""
|
|
|
+ age = age_days % SYNODIC_MONTH_DAYS
|
|
|
+ targets = (
|
|
|
+ ("First Quarter", SYNODIC_MONTH_DAYS / 4.0),
|
|
|
+ ("Full Moon", SYNODIC_MONTH_DAYS / 2.0),
|
|
|
+ ("Last Quarter", SYNODIC_MONTH_DAYS * 3.0 / 4.0),
|
|
|
+ ("New Moon", SYNODIC_MONTH_DAYS),
|
|
|
+ )
|
|
|
+
|
|
|
+ next_name = None
|
|
|
+ next_age = None
|
|
|
+ delta_days = None
|
|
|
+ for name, target_age in targets:
|
|
|
+ if target_age > age:
|
|
|
+ next_name = name
|
|
|
+ next_age = target_age
|
|
|
+ delta_days = target_age - age
|
|
|
+ break
|
|
|
+
|
|
|
+ if next_name is None or next_age is None or delta_days is None:
|
|
|
+ next_name = "New Moon"
|
|
|
+ next_age = SYNODIC_MONTH_DAYS
|
|
|
+ delta_days = SYNODIC_MONTH_DAYS - age
|
|
|
+
|
|
|
+ now_dt = _jd_to_utc_datetime(jd)
|
|
|
+ at_dt = now_dt + timedelta(days=delta_days)
|
|
|
+ return {
|
|
|
+ "phase_name": next_name,
|
|
|
+ "at_utc": _utc_datetime_to_iso(at_dt),
|
|
|
+ "in_text": _format_duration(now_dt, at_dt),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _ecl_to_equ(lambda_ecl: float, beta_ecl: float, jd: float) -> dict:
|
|
|
+ """
|
|
|
+ Convert ecliptic coordinates to equatorial (RA/dec) for a given epoch.
|
|
|
+
|
|
|
+ Note: This is a simplified approximation. For production use,
|
|
|
+ swe.cotrans or swe.get_eps_true should be used.
|
|
|
+ """
|
|
|
+ ecl_nut = swe.calc_ut(jd, swe.ECL_NUT, swe.FLG_SWIEPH)[0]
|
|
|
+ eps = ecl_nut[0]
|
|
|
+ eps_rad = math.radians(eps)
|
|
|
+
|
|
|
+ lambda_rad = math.radians(lambda_ecl)
|
|
|
+ beta_rad = math.radians(beta_ecl)
|
|
|
+
|
|
|
+ sin_dec = (
|
|
|
+ math.sin(beta_rad) * math.cos(eps_rad)
|
|
|
+ + math.cos(beta_rad) * math.sin(eps_rad) * math.sin(lambda_rad)
|
|
|
+ )
|
|
|
+
|
|
|
+ dec = math.degrees(math.asin(max(-1, min(1, sin_dec))))
|
|
|
+
|
|
|
+ y = math.sin(lambda_rad) * math.cos(eps_rad) - math.tan(beta_rad) * math.sin(eps_rad)
|
|
|
+ x = math.cos(lambda_rad)
|
|
|
+ ra = (math.degrees(math.atan2(y, x)) / 15.0) % 24.0
|
|
|
+
|
|
|
+ return {"ra": round(ra, 6), "dec": round(dec, 6)}
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_degrees(value: float) -> float:
|
|
|
+ return value % 360.0
|
|
|
+
|
|
|
+
|
|
|
+def _jd_to_utc_datetime(jd: float) -> datetime:
|
|
|
+ y, m, d, h = swe.revjul(jd, swe.GREG_CAL)
|
|
|
+ hour = int(h)
|
|
|
+ minute_float = (h - hour) * 60.0
|
|
|
+ minute = int(minute_float)
|
|
|
+ second = int(round((minute_float - minute) * 60.0))
|
|
|
+ return datetime(y, m, d, hour, minute, second, tzinfo=timezone.utc)
|
|
|
+
|
|
|
+
|
|
|
+def _utc_datetime_to_iso(dt: datetime) -> str:
|
|
|
+ return dt.isoformat().replace("+00:00", "Z")
|
|
|
+
|
|
|
+
|
|
|
+def _format_duration(start: datetime, end: datetime) -> str:
|
|
|
+ total_seconds = int(round((end - start).total_seconds()))
|
|
|
+ if total_seconds < 0:
|
|
|
+ total_seconds = 0
|
|
|
+
|
|
|
+ days, remainder = divmod(total_seconds, 86400)
|
|
|
+ hours, remainder = divmod(remainder, 3600)
|
|
|
+ minutes = remainder // 60
|
|
|
+
|
|
|
+ parts: list[str] = []
|
|
|
+ if days:
|
|
|
+ parts.append(f"{days}d")
|
|
|
+ if hours:
|
|
|
+ parts.append(f"{hours}h")
|
|
|
+ if minutes or not parts:
|
|
|
+ parts.append(f"{minutes}m")
|
|
|
+ return " ".join(parts)
|
|
|
+
|
|
|
+
|
|
|
+def _ecliptic_to_constellation(ecliptic_lon: float) -> str:
|
|
|
+ lon = _normalize_degrees(ecliptic_lon)
|
|
|
+ spans = [
|
|
|
+ (0.0, "Ari"),
|
|
|
+ (30.0, "Tau"),
|
|
|
+ (60.0, "Gem"),
|
|
|
+ (90.0, "Cnc"),
|
|
|
+ (120.0, "Leo"),
|
|
|
+ (150.0, "Vir"),
|
|
|
+ (180.0, "Lib"),
|
|
|
+ (210.0, "Sco"),
|
|
|
+ (240.0, "Sgr"),
|
|
|
+ (270.0, "Cap"),
|
|
|
+ (300.0, "Aqr"),
|
|
|
+ (330.0, "Psc"),
|
|
|
+ ]
|
|
|
+ result = "Psc"
|
|
|
+ for lower, abbrev in spans:
|
|
|
+ if lon >= lower:
|
|
|
+ result = abbrev
|
|
|
+ return result
|