Source code for empyrean.ephemeris.result
"""Ephemeris quivr table, configuration, and result container.
Mirrors the shape of :class:`empyrean.propagation.result.PropagationResult`:
the observable table plus a per-``(orbit, observer)`` sensitivity container.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any
import quivr as qv
from empyrean.coordinates.coordinates import (
CartesianCoordinates,
SphericalCoordinates,
)
from empyrean.ephemeris.sensitivity import ObservationSensitivities
from empyrean.propagation.config import PropagationConfig
# ── Ephemeris quivr table ────────────────────────────────────
[docs]
class Ephemeris(qv.Table):
"""Predicted astrometric ephemeris for observed objects.
Each row is one (orbit, observer, epoch) combination with topocentric
spherical coordinates (with covariance), aberrated Cartesian state,
and ancillary data. All angles are in degrees.
"""
# Identity
orbit_id = qv.LargeStringColumn()
object_id = qv.LargeStringColumn(nullable=True)
obs_code = qv.LargeStringColumn()
# Topocentric astrometry (covariance lives inside coordinates)
coordinates = SphericalCoordinates.as_column()
# Aberrated state at light-time corrected epoch
aberrated_state = CartesianCoordinates.as_column(nullable=True)
# Light time & geometry
light_time = qv.Float64Column(nullable=True) # one-way (days)
phase_angle = qv.Float64Column(nullable=True) # Sun-Object-Observer (deg)
elongation = qv.Float64Column(nullable=True) # Sun-Observer-Object (deg)
heliocentric_distance = qv.Float64Column(nullable=True) # AU
# Photometry
mag = qv.Float64Column(nullable=True)
mag_sigma = qv.Float64Column(nullable=True)
# Local horizon
zenith_angle = qv.Float64Column(nullable=True)
azimuth = qv.Float64Column(nullable=True)
hour_angle = qv.Float64Column(nullable=True)
# Lunar geometry
lunar_elongation = qv.Float64Column(nullable=True)
# Sky motion
position_angle = qv.Float64Column(nullable=True)
sky_rate = qv.Float64Column(nullable=True)
# ── Configuration ────────────────────────────────────────────
[docs]
@dataclass
class EphemerisConfig:
"""Configuration for :func:`empyrean.generate_ephemeris`.
Embeds a :class:`PropagationConfig` — every propagation-side knob
(force model, uncertainty method, integrator tolerance, thread
count, non-grav, etc.) is set there. Ephemeris-specific fields
(light-time iteration limits, diagnostic computation) live on this
struct directly.
Parameters
----------
propagation : PropagationConfig
Inner propagation configuration. Default:
:class:`PropagationConfig()` (Standard, FirstOrder, etc.).
max_light_time_iterations : int
Light-time convergence loop cap. Default 3.
light_time_tolerance_days : float
Light-time convergence tolerance in days. Default 1e-10.
compute_diagnostics : bool
Compute phase angle, elongation, heliocentric distance, and
apparent magnitude. Skip during DC iterations for speed.
Default True.
"""
propagation: PropagationConfig = field(default_factory=PropagationConfig)
max_light_time_iterations: int = 3
light_time_tolerance_days: float = 1e-10
compute_diagnostics: bool = True
def _to_wire_dict(self) -> dict[str, Any]:
"""Serialize to the nested dict shape the binding consumes.
Internal — called by :func:`empyrean.generate_ephemeris` to
marshal the config across the FFI boundary. For user-facing
serialization, use :func:`dataclasses.asdict`.
"""
return {
"propagation": self.propagation._to_wire_dict(),
"max_light_time_iterations": self.max_light_time_iterations,
"light_time_tolerance_days": self.light_time_tolerance_days,
"compute_diagnostics": self.compute_diagnostics,
}
# ── Result container ─────────────────────────────────────────
[docs]
@dataclass
class EphemerisResult:
"""Result of :func:`empyrean.generate_ephemeris`.
Attributes
----------
ephemeris : Ephemeris
Predicted astrometry table (one row per orbit × observer ×
epoch) with topocentric coordinates and observation covariance.
sensitivity : ObservationSensitivities, optional
Flat per-``(orbit_id, obs_code, epoch)`` sensitivity table —
observation Jacobians + optional Hessians. ``None`` when no
input covariance was supplied.
"""
ephemeris: Ephemeris
sensitivity: ObservationSensitivities | None = None
[docs]
def to_dir(self, path: str) -> None:
"""Persist to ``<path>/ephemeris.parquet`` + ``<path>/sensitivity.parquet``."""
os.makedirs(path, exist_ok=True)
self.ephemeris.to_parquet(os.path.join(path, "ephemeris.parquet"))
if self.sensitivity is not None and len(self.sensitivity) > 0:
self.sensitivity.to_parquet(os.path.join(path, "sensitivity.parquet"))
[docs]
@classmethod
def from_dir(cls, path: str) -> EphemerisResult:
ephemeris = Ephemeris.from_parquet(os.path.join(path, "ephemeris.parquet"))
sens_path = os.path.join(path, "sensitivity.parquet")
sensitivity = (
ObservationSensitivities.from_parquet(sens_path) if os.path.exists(sens_path) else None
)
return cls(ephemeris=ephemeris, sensitivity=sensitivity)