diff --git a/README.rst b/README.rst index f8a614c5..27bbe3bf 100644 --- a/README.rst +++ b/README.rst @@ -23,14 +23,12 @@ SSAPy - Space Situational Awareness for Python orbital modeling and analysis tool for orbits spanning from low-Earth orbit into the cislunar regime. -SSAPy retains the core coordinate, observer-geometry, and propagation routines. -For higher-level utilities, convenience workflows, plotting tools, workflow -wrappers around coordinate conversions, Lambertian magnitude / brightness -calculations, and related extensions, see the companion project -`SSAPy-Toolkit `__. +SSAPy provides physics-based orbital modeling and analysis for space-domain +applications. -Optional packaged datasets are available with ``llnl-ssapy[data]``. Install -``llnl-ssapy[all-data]`` to install every split ``ssatk-data-*`` distribution. +Ephemerides, gravity models, and textures install automatically from the +``ssa-data-*`` packages; ``pip install "llnl-ssapy[all-data]"`` adds the +propulsion and benchmark data sets. SSAPy includes: @@ -65,26 +63,6 @@ SSAPy includes: including GCRF, IERS, GCRS Cartesian, TEME Cartesian, RA/Dec, NTW, zenith/azimuth, apparent positions, and orthogonal tangent plane coordinates -SSAPy-Toolkit -------------- - -SSAPy provides the core propagation and modeling engine. Many -higher-level, analysis-ready capabilities built on top of it live in the -companion project -`SSAPy-Toolkit `__ (sometimes abbreviated -*SSATK*), including: - -- Higher-level utilities and convenience workflows that wrap common SSAPy tasks -- Plotting tools for orbit and analysis visualization -- Workflow-level wrappers around SSAPy's coordinate and observer-geometry - routines -- Lambertian magnitude / brightness calculations -- Additional related extensions - -If your work centers on plotting, dashboards, convenience utilities, or -higher-level workflows, SSAPy-Toolkit is often the best place to start — and the -natural home for contributions of that kind. - Installation ------------ @@ -92,10 +70,6 @@ For installation details, see the `Installing SSAPy `_ section of the documentation. -If you are looking for higher-level utilities or plotting-oriented workflows, -you may also want to install or explore -`SSAPy-Toolkit `__. - Strict dependencies ------------------- @@ -113,9 +87,9 @@ The following Python packages are installed automatically when you install SSAPy - `ipython_genutils `_ - `jplephem `_ - `tqdm `_ - -Earth/Moon mesh helpers and other visualization tools are maintained in -`SSAPy-Toolkit `__. +- `ssa-data-core `_, + ``ssa-data-gravity``, ``ssa-data-lunar``, and ``ssa-data-lunar-gravity`` + (packaged data) Documentation ------------- @@ -143,17 +117,16 @@ Contributing to SSAPy is straightforward. Please open a targeting the ``main`` branch of the `SSAPy repository `_. -For work that primarily concerns plotting, dashboards, convenience utilities, -or higher-level workflows, please also consider whether the contribution belongs -in the companion repository -`SSAPy-Toolkit `__. - Your PR must pass SSAPy's required CI checks. For local testing guidance, documentation builds, and Git workflow tips, see the `Contribution Guide `_. SSAPy's ``main`` branch contains the latest development work. +For six-degree-of-freedom (6-DOF) propagation, visualization, and mission-level +workflows, see the +`Space Situational Awareness Toolkit (SSATK) `__. + Releases -------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 62fc49eb..9a3ba330 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -43,7 +43,9 @@ def linkcode_resolve(domain, info): return "https://github.com/LLNL/SSAPy/tree/main/%s.py" % filename autosummary_generate = True -autosummary_imported_members = True +# Document objects implemented by SSAPy rather than imported implementation +# helpers (for example ``functools.lru_cache``). +autosummary_imported_members = False numpydoc_show_class_members = False sphinx_tabs_valid_builders = ['linkcheck'] source_suffix = ['.rst', '.md'] diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 023f3d15..0347a3c1 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -1,9 +1,7 @@ SSAPy by Example ================ -The examples below use base SSAPy APIs that are part of the current package. -Higher-level plotting workflows and apparent-magnitude calculations are -maintained in `SSAPy-Toolkit `_. +The examples below demonstrate the core SSAPy APIs. Define an epoch and a simple geosynchronous orbit: diff --git a/docs/source/installation.rst b/docs/source/installation.rst index ee6755af..604bb3d9 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -45,8 +45,13 @@ As the package has been published on `PyPI `` installs the import package +``ssa_data_`` with its files below ``ssa_data_/data``. This module +is the single list SSAPy searches. +""" + +#: Required dependencies of SSAPy (ephemerides, gravity models, textures). +REQUIRED = ( + "ssa_data_core", + "ssa_data_gravity", + "ssa_data_lunar", + "ssa_data_lunar_gravity", +) + +#: Added by the ``all-data`` extra. +OPTIONAL = ( + "ssa_data_propulsion", + "ssa_data_benchmarks", +) + +ALL = REQUIRED + OPTIONAL + +#: Command that installs every data package. +INSTALL_HINT = "pip install 'llnl-ssapy[all-data]'" diff --git a/ssapy/ephemeris.py b/ssapy/ephemeris.py index cf4a50ce..c92c99ed 100644 --- a/ssapy/ephemeris.py +++ b/ssapy/ephemeris.py @@ -2,7 +2,7 @@ Planetary ephemeris selection and long-span kernels. SSAPy evaluates Sun, Moon and planet positions from JPL development -ephemerides (DE). The split SSATK data packages ship the supported kernels: +ephemerides (DE). The ``ssa-data-*`` packages ship the supported kernels: ========== ======================== ========================= Ephemeris Shipped kernel Coverage @@ -18,7 +18,7 @@ For epochs outside the shipped kernel, SSAPy uses a longer kernel of the same ephemeris (``de440.bsp`` or ``de430.bsp``, 1550-2650) and, for DE440 beyond that, DE441 (-13200 to +17191). These are 120 MB to 1.65 GB, so they are not -shipped: SSAPy looks for them in the working directory and split data packages +shipped: SSAPy looks for them in the working directory, the ssa-data-* packages, and the cache directory, and otherwise downloads them from NAIF, checks their SHA-256, and caches them, warning with :class:`EphemerisDownloadWarning` before each download. Set ``SSAPY_EPHEMERIS_DOWNLOAD=0`` to forbid downloads @@ -42,6 +42,8 @@ import numpy as np +from ._data_packages import INSTALL_HINT as _DATA_INSTALL_HINT + __all__ = [ "EphemerisDownloadWarning", "EphemerisRangeWarning", @@ -72,7 +74,7 @@ class Kernel: name: str start_jd: float # TDB Julian dates covered by every segment SSAPy uses end_jd: float - sha256: Optional[str] = None # None for kernels shipped in split data packages + sha256: Optional[str] = None # None for kernels shipped in ssa-data-* packages nbytes: Optional[int] = None solution: str = "" @@ -194,7 +196,8 @@ def _path(kernel, reason): return path if kernel.sha256 is None: raise EphemerisUnavailableError( - f"{kernel.name} is missing; install the appropriate ssatk-data-* package." + f"{kernel.name} is missing; install the ssa-data-* packages with: " + f"{_DATA_INSTALL_HINT}" ) destination = os.path.join(cache_dir(), kernel.name) manual = (f"Download {kernel.url} ({kernel.nbytes / 1e6:.0f} MB) into {cache_dir()} " diff --git a/ssapy/utils.py b/ssapy/utils.py index 6ed23952..d32a62b4 100644 --- a/ssapy/utils.py +++ b/ssapy/utils.py @@ -6,12 +6,13 @@ import re import numpy as np import warnings -from functools import lru_cache +from functools import lru_cache as _lru_cache from astropy.time import Time as _Time import astropy.units as u from typing import Union, Tuple from . import datadir +from ._data_packages import ALL as _DATA_PACKAGES, INSTALL_HINT as _DATA_INSTALL_HINT from .constants import RGEO, EARTH_RADIUS, MOON_RADIUS, WGS84_EARTH_OMEGA @@ -46,12 +47,12 @@ def _is_lfs_pointer(path): return False -@lru_cache(maxsize=1) +@_lru_cache(maxsize=1) def _ssapy_data_files(): - """Return files shipped by installed split SSATK data packages.""" + """Return files shipped by the installed ``ssa-data-*`` packages.""" from importlib.resources import files result = [] - for package in ("ssapy_data_core", "ssapy_data_gravity", "ssapy_data_lunar", "ssapy_data_lunar_gravity", "ssapy_data_propulsion", "ssapy_data_benchmarks"): + for package in _DATA_PACKAGES: try: stack = [files(package) / "data"] while stack: @@ -66,13 +67,13 @@ def _ssapy_data_files(): def find_file(filename, ext=None): - """Find a file in the current directory or installed split data packages. + """Find a file in the current directory or the installed ``ssa-data-*`` packages. If ext is not None, also try appending ext to the filename. Git LFS pointer files (left over from SSAPy versions that stored data - with Git LFS) are skipped, and installed split data packages are searched - by file name. + with Git LFS) are skipped, and the installed ``ssa-data-*`` packages are + searched by file name. """ names = [filename] if ext is None else [filename, filename + ext] candidates = [] @@ -89,10 +90,11 @@ def find_file(filename, ext=None): if os.path.isfile(candidate): raise FileNotFoundError( f"{candidate} is a git LFS pointer, not the data file. SSAPy's data now " - "comes from the split ssatk-data-* packages." + f"comes from the ssa-data-* packages; install them with: {_DATA_INSTALL_HINT}" ) raise FileNotFoundError( - f"{filename} was not found in the working directory or installed ssatk-data-* packages ({datadir})." + f"{filename} was not found in the working directory or installed ssa-data-* packages ({datadir}). " + f"Install them with: {_DATA_INSTALL_HINT}" ) diff --git a/tests/test_accel.py b/tests/test_accel.py index 746c18c3..a5e7b31b 100644 --- a/tests/test_accel.py +++ b/tests/test_accel.py @@ -19,7 +19,7 @@ def _has_data(name): - # SSAPy's data comes from split SSATK data packages; skip only if missing. + # SSAPy's data comes from the ssa-data-* packages; skip only if missing. try: ssapy.utils.find_file(name) return True @@ -151,7 +151,7 @@ def _make_body_dependent_objects(): earth = moon = sun = earth_MG = moon_MG = sun_MG = None -@pytest.mark.skipif(not HAS_EGM84, reason="EGM84 gravity file unavailable (split data package not installed)") +@pytest.mark.skipif(not HAS_EGM84, reason="EGM84 gravity file unavailable (ssa-data-* package not installed)") @timer def test_MG_3_1(): """Exercise 3.1 from Montenbruck and Gill diff --git a/tests/test_data_file_lookup.py b/tests/test_data_file_lookup.py index 211c1320..8cba8678 100644 --- a/tests/test_data_file_lookup.py +++ b/tests/test_data_file_lookup.py @@ -19,13 +19,13 @@ def test_lfs_pointer_is_not_returned_as_data(tmp_path, monkeypatch): (tmp_path / "de440s.bsp").write_text(POINTER) monkeypatch.setattr(utils, "datadir", str(tmp_path)) monkeypatch.setattr(utils, "_ssapy_data_files", lambda: []) - with pytest.raises(FileNotFoundError, match="git LFS pointer.*split"): + with pytest.raises(FileNotFoundError, match="git LFS pointer.*ssa-data"): utils.find_file("de440s.bsp") def test_ssapy_data_is_searched_when_the_package_data_is_missing(tmp_path, monkeypatch): # With the package copy only a pointer, a real file of the same name - # shipped by a split SSATK data package is returned instead. + # shipped by an ssa-data-* package is returned instead. local = tmp_path / "local" shipped = tmp_path / "shipped" / "ephemerides" local.mkdir() @@ -39,15 +39,35 @@ def test_ssapy_data_is_searched_when_the_package_data_is_missing(tmp_path, monke def test_split_data_packages_are_searchable(): - # Data is distributed across independently installable SSATK packages. + # The four required ssa-data-* packages install with SSAPy. import ssapy - import ssapy_data_core, ssapy_data_gravity, ssapy_data_lunar, ssapy_data_lunar_gravity + import ssa_data_core, ssa_data_gravity, ssa_data_lunar, ssa_data_lunar_gravity assert ssapy.datadir - assert all(pkg.__file__ for pkg in (ssapy_data_core, ssapy_data_gravity, ssapy_data_lunar, ssapy_data_lunar_gravity)) + assert all(pkg.__file__ for pkg in (ssa_data_core, ssa_data_gravity, ssa_data_lunar, ssa_data_lunar_gravity)) def test_missing_file_names_the_data_package(monkeypatch, tmp_path): monkeypatch.setattr(utils, "datadir", str(tmp_path)) monkeypatch.setattr(utils, "_ssapy_data_files", lambda: []) - with pytest.raises(FileNotFoundError, match="ssatk-data"): + with pytest.raises(FileNotFoundError, match="ssa-data"): + utils.find_file("no_such_file.bsp") + + +def test_datadir_falls_back_to_a_path_without_data_packages(monkeypatch): + # With no ssa-data-* package installed, datadir must stay a path so + # find_file raises FileNotFoundError with install instructions rather + # than TypeError from os.path.join(None, ...). + import importlib.resources + import ssapy + + def _missing(package): + raise ModuleNotFoundError(package) + + monkeypatch.setattr(importlib.resources, "files", _missing) + fallback = ssapy._datadir() + assert isinstance(fallback, str) + + monkeypatch.setattr(utils, "datadir", fallback) + monkeypatch.setattr(utils, "_ssapy_data_files", lambda: []) + with pytest.raises(FileNotFoundError, match=r"llnl-ssapy\[all-data\]"): utils.find_file("no_such_file.bsp") diff --git a/tests/test_hour_angle_regressions.py b/tests/test_hour_angle_regressions.py new file mode 100644 index 00000000..88ccc424 --- /dev/null +++ b/tests/test_hour_angle_regressions.py @@ -0,0 +1,33 @@ +import numpy as np +import pytest + +from ssapy import utils + + +def test_rightascension_to_hourangle_matches_documented_examples(): + for right_ascension, local_time in [ + ("10:30:00", "12:45:00"), + (157.5, 191.25), + ]: + hour_angle = utils.rightascension_to_hourangle(right_ascension, local_time) + assert utils.hms_to_dd(hour_angle) == pytest.approx(33.75) + + +def test_rightascension_to_hourangle_wraps_through_midnight(): + hour_angle = utils.rightascension_to_hourangle("02:00:00", "01:00:00") + assert utils.hms_to_dd(hour_angle) == pytest.approx(345.0) + + +def test_equatorial_to_horizontal_accepts_numeric_ra_and_local_time(): + from_ra = utils.equatorial_to_horizontal( + observer_latitude=35.0, + declination=20.0, + right_ascension=157.5, + local_time=191.25, + ) + from_hour_angle = utils.equatorial_to_horizontal( + observer_latitude=35.0, + declination=20.0, + hour_angle=33.75, + ) + np.testing.assert_allclose(from_ra, from_hour_angle, atol=1e-12) diff --git a/tests/test_orbit.py b/tests/test_orbit.py index 3c913525..9ae7531a 100755 --- a/tests/test_orbit.py +++ b/tests/test_orbit.py @@ -20,7 +20,7 @@ def _has_ephemeris(): - # SSAPy's ephemeris comes from split SSATK data packages; skip only if missing. + # SSAPy's ephemeris comes from the ssa-data-* packages; skip only if missing. try: ssapy.body._planetary_ephemeris_path() return True @@ -1570,7 +1570,7 @@ def test_light_time_correction(): np.testing.assert_allclose(dircos, dc_linear, rtol=0, atol=1e-7) np.testing.assert_allclose(dircos, dc_exact, rtol=0, atol=1e-14) -@pytest.mark.skipif(not _has_ephemeris(), reason="planetary ephemeris unavailable (split data package not installed)") +@pytest.mark.skipif(not _has_ephemeris(), reason="planetary ephemeris unavailable (ssa-data-* package not installed)") @pytest.mark.timeout(90) @timer def test_find_passes(): diff --git a/tests/test_tle_drag.py b/tests/test_tle_drag.py index 6f6f396b..bbb424dc 100644 --- a/tests/test_tle_drag.py +++ b/tests/test_tle_drag.py @@ -16,9 +16,9 @@ # The numerical drag propagator needs the gravity and ephemeris data from -# split SSATK data packages; skip those tests if unavailable. +# the ssa-data-* packages; skip those tests if unavailable. def _has_data(name): - # SSAPy's data comes from split SSATK data packages; skip only if missing. + # SSAPy's data comes from the ssa-data-* packages; skip only if missing. try: ssapy.utils.find_file(name) return True @@ -39,7 +39,7 @@ def _has_ephemeris(): HAS_MOON_PA = _has_data("moon_pa_de440_200625.bpc") HAS_BODY_DATA = HAS_EGM84 and HAS_EPHEMERIS and HAS_MOON_PA needs_body_data = pytest.mark.skipif( - not HAS_BODY_DATA, reason="body data unavailable (split data package not installed)") + not HAS_BODY_DATA, reason="body data unavailable (ssa-data-* package not installed)") ISS = ("1 25544U 98067A 24015.54791435 .00016717 00000-0 30074-3 0 9993", "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.49514637123456")