Source code for ADCS.orbits.ephemeris

__all__ = ["Ephemeris"]

import os
import sys
from pathlib import Path
from skyfield.api import load, Loader
from typing import Optional

[docs] class Ephemeris: r""" High-precision planetary ephemeris interface based on Skyfield. This class provides a unified interface for loading and accessing high-precision planetary ephemerides using the `Skyfield <https://rhodesmill.org/skyfield/>`_ library. It manages the retrieval, storage, and initialization of the JPL DE421 ephemeris file and exposes commonly used celestial bodies such as the Earth and the Sun. The ephemeris data are represented in the International Celestial Reference Frame (ICRF), enabling precise position and velocity computations required for orbital dynamics, attitude determination, and navigation applications. Mathematical Background ----------------------- A planetary ephemeris provides the barycentric position and velocity of solar system bodies as continuous functions of time: .. math:: \mathbf{r}_i(t), \; \mathbf{v}_i(t) \quad \forall i \in \{\text{Sun, Earth, Moon, planets}\} where :math:`\mathbf{r}_i` and :math:`\mathbf{v}_i` are expressed in the ICRF frame. These quantities are derived from numerical integration of the N-body equations of motion: .. math:: \ddot{\mathbf{r}}_i = \sum_{j \neq i} G m_j \frac{\mathbf{r}_j - \mathbf{r}_i} {\lVert \mathbf{r}_j - \mathbf{r}_i \rVert^3}, with relativistic and empirical corrections applied in the JPL models. The DE421 ephemeris is valid over the time span: .. math:: 1900 \le t \le 2050, and provides sufficient accuracy for most Earth-orbiting spacecraft analyses. :param filepath: Optional path to a pre-downloaded JPL DE421 ephemeris file. If ``None``, a default location under ``ADCS/environment/de421.bsp`` is used. :type filepath: pathlib.Path | None :raises RuntimeError: If the ephemeris file cannot be downloaded or loaded. .. note:: This class internally initializes a shared Skyfield :class:`~ADCS.etc.Ephemeris` timescale object, which should be reused across all time-dependent orbital computations to ensure consistency. """ def __init__(self, filepath: Optional[Path] = None) -> None: r""" Initialize the ephemeris loader and planetary body objects. This constructor loads the JPL DE421 ephemeris file either from a user-specified path or from the default project directory. If the file does not exist locally, it is automatically downloaded using Skyfield’s loader utilities. Upon successful loading, commonly used celestial bodies and a timescale object are extracted and stored as attributes. :param filepath: Optional filesystem path to a DE421 ephemeris file. If ``None``, a default project-relative path is used. :type filepath: pathlib.Path | None :return: ``None`` :rtype: None """ if filepath is not None: # User-provided file path self.planets = load(str(filepath)) else: existing = self._find_existing_ephemeris() if existing is not None: self.planets = load(str(existing)) print(f"✅ Loaded local ephemeris from {existing}") else: self.planets = self._download_ephemeris( self._get_default_ephemeris_path() ) # Extract common bodies self.sun = self.planets['sun'] self.earth = self.planets['earth'] # Create a timescale object (shared across all orbital computations) self.ts = load.timescale() # ---------------------------------------------------------------------- @staticmethod def _packaged_ephemeris_path() -> Path: r"""Location of an ephemeris shipped alongside the package, if any. Read only. ``de421.bsp`` is 16 MB and is deliberately not shipped in the wheel, but a source checkout has one here, and so does an installation where a user or sysadmin placed one deliberately. :return: Path to ``ADCS/environment/de421.bsp``. :rtype: pathlib.Path """ return Path(__file__).resolve().parents[1] / "environment" / "de421.bsp" @staticmethod def _cache_ephemeris_path() -> Path: r"""Per-user cache location for a downloaded ephemeris. Honours ``ADCS_EPHEMERIS_PATH`` (a full path to a .bsp file) first, then ``XDG_CACHE_HOME``, then the platform default. Deliberately never inside ``site-packages``: writing there fails outright on read-only or system-managed installs, and any file written survives ``pip uninstall`` as an orphan because it is absent from the wheel's RECORD. :return: Path the ephemeris would be cached at. :rtype: pathlib.Path """ override = os.environ.get("ADCS_EPHEMERIS_PATH") if override: return Path(override).expanduser() xdg = os.environ.get("XDG_CACHE_HOME") if xdg: base = Path(xdg) elif sys.platform == "darwin": base = Path.home() / "Library" / "Caches" elif sys.platform == "win32": base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) else: base = Path.home() / ".cache" return base / "generalized_adcs" / "de421.bsp" @classmethod def _find_existing_ephemeris(cls) -> Path | None: r"""First existing ephemeris across the search order, else ``None``. Order: explicit ``ADCS_EPHEMERIS_PATH`` / user cache, then the copy shipped beside the package. :return: An existing ephemeris path, or ``None``. :rtype: pathlib.Path | None """ for candidate in (cls._cache_ephemeris_path(), cls._packaged_ephemeris_path()): if candidate.exists(): return candidate return None def _get_default_ephemeris_path(self) -> Path: r"""Where a newly downloaded ephemeris should be written. Always the per-user cache, never ``site-packages``. :return: Absolute path to the cache location for ``de421.bsp``. :rtype: pathlib.Path """ path = self._cache_ephemeris_path() path.parent.mkdir(parents=True, exist_ok=True) return path # ---------------------------------------------------------------------- def _download_ephemeris(self, save_path: Path): r""" Download and load the JPL DE421 ephemeris file. This method uses Skyfield’s :class:`~ADCS.etc.Ephemeris` loader mechanism to retrieve the DE421 ephemeris from official JPL mirrors and store it at the specified location. If the file already exists, it is reused. :param save_path: Destination path where the ephemeris file will be stored. :type save_path: pathlib.Path :return: Loaded Skyfield planetary ephemeris kernel. :rtype: skyfield.jpllib.SpiceKernel :raises RuntimeError: If the ephemeris file cannot be downloaded or initialized. """ try: loader = Loader(str(save_path.parent)) planets = loader("de421.bsp") # downloads if missing print(f"✅ Ephemeris downloaded to: {planets.path}") return planets except Exception as e: raise RuntimeError(f"❌ Failed to download DE421 ephemeris: {e}") from e