├── pysrc ├── __init__.py └── ssapy.cpp ├── src ├── __init__.py └── ssapy.cpp ├── devel ├── .gitignore ├── import_cache.py ├── export_cache.py ├── catalog_to_apparent.c └── angles_to_orbits.ipynb ├── ssapy ├── data │ ├── Earth_graphics │ │ ├── ne_50m_ocean.cpg │ │ ├── ne_50m_ocean.VERSION.txt │ │ ├── ne_50m_ocean.dbf │ │ ├── ne_50m_ocean.shx │ │ ├── ne_50m_ocean.prj │ │ └── ne_50m_ocean.shp │ ├── orekit-data.zip │ ├── egm2008.egm │ ├── egm84.egm │ ├── egm96.egm │ ├── wgs84.egm │ ├── de430.bsp │ ├── earth.png │ ├── egm84.egm.cof │ ├── egm96.egm.cof │ ├── moon.png │ ├── wgs84.egm.cof │ ├── egm2008.egm.cof │ ├── gggrx_1200a_sha.lbl │ ├── gggrx_1200a_sha.tab │ └── moon_pa_de440_200625.bpc ├── __init__.py ├── ellipsoid.py ├── constants.py └── body.py ├── tests ├── __init__.py ├── data │ ├── aeroTLE_1.txt │ ├── aeroTLE_2.txt │ ├── tles.txt │ ├── b3_test.obs │ └── aeroB3_1.obs ├── conftest.py ├── test_simple.py ├── test_ellipsoid.py ├── test_io.py ├── test_linker.py ├── test_collision_event.py ├── ssapy_test_helpers.py ├── test_particles.py ├── test_plotutils.py ├── test_tracks.py ├── test_plots.py ├── test_utils.py └── test_frame.py ├── .pypirc ├── .gitmodules ├── CHANGELOG.md ├── docs ├── source │ ├── _static │ │ └── images │ │ │ └── logo │ │ │ └── ssapy_logo.ico │ ├── references.rst │ ├── _templates │ │ └── automodapi_templ.rst │ ├── magnitude-plot.png │ ├── magnitude_plot.png │ ├── orbit_plot_1.png │ ├── orbit_plot_2.png │ ├── phase_angle.png │ ├── ground_track_plot.png │ ├── reflectance_plot.png │ ├── benchmarks.rst │ ├── tables │ │ └── system_prerequisites.csv │ ├── index.rst │ ├── api.rst │ ├── installation.rst │ ├── refs.bib │ ├── conf.py │ ├── examples.rst │ └── concepts.rst ├── requirements.txt ├── Makefile └── README.rst ├── JOSS ├── orbit_plot.png ├── ground_track.png └── paper.md ├── setup.cfg ├── CONTRIBUTING.md ├── requirements.txt ├── MANIFEST.in ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── user_question.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── python-publish.yml │ └── ci.yml ├── .flake8 ├── NOTICE ├── LICENSE ├── .codecov.yml ├── pyproject.toml ├── .gitignore ├── CMakeLists.txt ├── .docker └── Dockerfile ├── setup.py ├── CODE_OF_CONDUCT.md ├── CITATION.cff ├── include └── ssapy.h └── README.rst /pysrc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /devel/.gitignore: -------------------------------------------------------------------------------- 1 | cache.zip 2 | old_cache.zip 3 | -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.cpg: -------------------------------------------------------------------------------- 1 | UTF-8 -------------------------------------------------------------------------------- /ssapy/data/orekit-data.zip: -------------------------------------------------------------------------------- 1 | tests/orekit-data.zip -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ssapy_test_helpers 2 | -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.VERSION.txt: -------------------------------------------------------------------------------- 1 | 4.1.0 2 | -------------------------------------------------------------------------------- /.pypirc: -------------------------------------------------------------------------------- 1 | [distutils] 2 | index-servers = 3 | testpypi 4 | 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pybind11"] 2 | path = pybind11 3 | url = https://github.com/pybind/pybind11.git 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.7.0 (2023-10-20) 2 | # v1.1.0 (2025-7-10) 3 | # v1.1.1 (2025-8-8) 4 | # v1.1.2 (2025-9-9) 5 | # v1.1.3 (2025-10-23) 6 | -------------------------------------------------------------------------------- /docs/source/_static/images/logo/ssapy_logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/llnl/SSAPy/HEAD/docs/source/_static/images/logo/ssapy_logo.ico -------------------------------------------------------------------------------- /docs/source/references.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | References 3 | ========== 4 | 5 | .. bibliography:: refs.bib 6 | :style: plain 7 | :all: -------------------------------------------------------------------------------- /docs/source/_templates/automodapi_templ.rst: -------------------------------------------------------------------------------- 1 | {{ fullname }} 2 | {{ underline }} 3 | 4 | .. automodapi:: {{ fullname }} 5 | :no-heading: 6 | -------------------------------------------------------------------------------- /JOSS/orbit_plot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:60b56a82fff649d7f9b705909219b7a1ba9176a1ae9ac968f53a1b0b1184b190 3 | size 126636 4 | -------------------------------------------------------------------------------- /ssapy/data/egm2008.egm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:031ecae647986faadf3f3a0177ba07ffcb73a2504b084c2e1c82b97cd9e61c75 3 | size 790 4 | -------------------------------------------------------------------------------- /ssapy/data/egm84.egm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:549e21625b71a0f0daec8477e0f4928338885ed2269ff70e351b15c3e6e0d43e 3 | size 800 4 | -------------------------------------------------------------------------------- /ssapy/data/egm96.egm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cdf0896bb34c1f8ad7ea1c4126cf949fe5069ad9df17c2a66cf3d81da096970e 3 | size 753 4 | -------------------------------------------------------------------------------- /ssapy/data/wgs84.egm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:594ff1c9dc2363e50d663003deb9baca2e144d096d70fc9b7a6c1c86f5b781f0 3 | size 724 4 | -------------------------------------------------------------------------------- /JOSS/ground_track.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:7af1ebbca5db9702bb088200ef80afa4ada40f188cea7574fbfa37f897472a85 3 | size 1573431 4 | -------------------------------------------------------------------------------- /ssapy/data/de430.bsp: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6e1b277c5f07135a84950604b83e56b736be696a7f3560bcddb1d4aeb944fca1 3 | size 119741440 4 | -------------------------------------------------------------------------------- /ssapy/data/earth.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6e8a1296caef991dcdba963fdc72816e4095720d005e7af6688b060e5fb85aa4 3 | size 8825662 4 | -------------------------------------------------------------------------------- /ssapy/data/egm84.egm.cof: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:f95fa2967091b9a0630ae7f34934446f17a02f73e5b648ea919db4174b047633 3 | size 262112 4 | -------------------------------------------------------------------------------- /ssapy/data/egm96.egm.cof: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fd427a88a944e2df46d3a3e1aefdb17a68e892e3decdf1382f3ca14de9b6f2ae 3 | size 2085160 4 | -------------------------------------------------------------------------------- /ssapy/data/moon.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4d9e6eebd180262aefe7555a0231927e5142954144cfcf2c4a10876b7c218027 3 | size 13728894 4 | -------------------------------------------------------------------------------- /ssapy/data/wgs84.egm.cof: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:aeedf849678826d171b731eb7a783ab5d7e47552413da551418d21445fc3a2ec 3 | size 192 4 | -------------------------------------------------------------------------------- /docs/source/magnitude-plot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:df2fbdc808903a1d5ee8deadde2e4c3e328cb9ebf150217a0d1a3a268d04f913 3 | size 178575 4 | -------------------------------------------------------------------------------- /docs/source/magnitude_plot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:df2fbdc808903a1d5ee8deadde2e4c3e328cb9ebf150217a0d1a3a268d04f913 3 | size 178575 4 | -------------------------------------------------------------------------------- /docs/source/orbit_plot_1.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:27e455ae3257b1db02c7675ac7fff732b94f481d14b00a972cc330ae6119cc37 3 | size 72458 4 | -------------------------------------------------------------------------------- /docs/source/orbit_plot_2.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:4664c67811020b8ac86baa5f471891a31310f6c32d78b1f34bc0c37a974b0d74 3 | size 69198 4 | -------------------------------------------------------------------------------- /docs/source/phase_angle.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dfadcf7ef5a94df3abf715c6f7d6dc910c23ce24f135ff8ad8fd48f06bcd6558 3 | size 24641 4 | -------------------------------------------------------------------------------- /ssapy/data/egm2008.egm.cof: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:ebed5b09e3da7aeae37ac49e7b8c803fda0a525462bba374f762d6345624d132 3 | size 75755304 4 | -------------------------------------------------------------------------------- /ssapy/data/gggrx_1200a_sha.lbl: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8d46351c2e73db699d4669a98da1f47551086cbee321d8f9baa3fdb6b65d3d5f 3 | size 10637 4 | -------------------------------------------------------------------------------- /tests/data/aeroTLE_1.txt: -------------------------------------------------------------------------------- 1 | 1 83005U 00000AAA 10019.29270428 .00000000 00000 0 99999 0 0 07 2 | 2 83005 13.0026 59.1827 0008556 283.5478 197.1526 1.00302926 07 3 | -------------------------------------------------------------------------------- /tests/data/aeroTLE_2.txt: -------------------------------------------------------------------------------- 1 | 1 83140U 00000AAA 10016.08518229 .00000000 00000 0 99999 0 0 05 2 | 2 83140 16.1430 4.9121 0074783 215.3634 134.7556 0.96498905 04 3 | -------------------------------------------------------------------------------- /docs/source/ground_track_plot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0d52c7a2e06b3720e0b9a1164c05957bc732551f7cf01ed33ea9c1b0f98e5c02 3 | size 645920 4 | -------------------------------------------------------------------------------- /docs/source/reflectance_plot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dabd9ab6218b7ddfa3858e2b7640be98fe3d14fa3f4bead6e3f028e38b350ae7 3 | size 144450 4 | -------------------------------------------------------------------------------- /ssapy/data/gggrx_1200a_sha.tab: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fa04c3dce9376948ad243f3df74144e2602f12d183ea4d179604ed0a79da7ded 3 | size 88059844 4 | -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.dbf: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:162d515911b17e35d36e22c5757e75fd37bdd38db2bc0011b9c79888a28bb4f5 3 | size 176 4 | -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.shx: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:449353f638399c48c4cfe59e8f945e5c3025a4e412e9bcd0eeba64ba137ae7c1 3 | size 108 4 | -------------------------------------------------------------------------------- /ssapy/data/moon_pa_de440_200625.bpc: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:60cd55aa401ea2ea97360636f567554bfe4e37bb829f901b4460a455dfaf783f 3 | size 12863488 4 | -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.prj: -------------------------------------------------------------------------------- 1 | GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]] -------------------------------------------------------------------------------- /ssapy/data/Earth_graphics/ne_50m_ocean.shp: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6ff1190e8430e493cf8eed99393f021b7562b600b9a7a11aa23ce45b6035d8b8 3 | size 978340 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | [tool:pytest] 4 | testpaths = tests 5 | addopts = -n 8 --junitxml=pytest.xml 6 | filterwarnings = 7 | ignore::DeprecationWarning:importlib: 8 | ignore::DeprecationWarning:emcee: -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.1.2 2 | sphinx-copybutton 3 | myst-parser<4.0.0 4 | sphinx-tabs 5 | sphinx-automodapi 6 | sphinx_rtd_theme 7 | sphinxcontrib-bibtex 8 | sphinx-autobuild 9 | docutils<0.21 10 | nbsphinx -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SSAPy 2 | 3 | All contributions to SSAPy must be made under the MIT License. 4 | 5 | Before contributing to SSAPy, you should read the [Contribution Guide](), which is maintained as part of SSAPy's documentation. 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | graphviz 2 | numpy 3 | pandas 4 | h5py 5 | scipy 6 | jplephem 7 | astropy 8 | pyerfa 9 | emcee 10 | lmfit 11 | sgp4 12 | matplotlib 13 | pypdf2 14 | ipyvolume 15 | ipython_genutils 16 | ipython 17 | pytest 18 | imageio 19 | tqdm 20 | build 21 | cibuildwheel 22 | delocate -------------------------------------------------------------------------------- /docs/source/benchmarks.rst: -------------------------------------------------------------------------------- 1 | SSAPy Benchmarks 2 | ================ 3 | 4 | THIS PAGE IS UNDER DEVELOPMENT 5 | 6 | The following figures provide an overview of our timing and accuracy benchmarking tests against various community SSA/SDA packages, integrators, and propagators. 7 | 8 | THIS PAGE IS UNDER DEVELOPMENT -------------------------------------------------------------------------------- /docs/source/tables/system_prerequisites.csv: -------------------------------------------------------------------------------- 1 | Name, Supported Versions, Notes, Requirement Reason 2 | Python, 3.8--3.11, , Interpreter 3 | C/C++ Compilers, , , Building software 4 | make, , , Build software 5 | git, , , Manage software repositories 6 | git-lfs, , , Track large binary files 7 | graphviz, , , Local documentation building 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include pyproject.toml 4 | include CMakeLists.txt 5 | 6 | include include/*.h 7 | include pysrc/*.cpp 8 | include src/*.cpp 9 | 10 | recursive-include pybind11/include/ * 11 | include pybind11/CMakeLists.txt 12 | include pybind11/LICENSE 13 | recursive-include pybind11/tools * 14 | recursive-include ssapy *.so 15 | recursive-include ssapy/data * 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png filter=lfs diff=lfs merge=lfs -text 2 | *.jpg filter=lfs diff=lfs merge=lfs -text 3 | *.egm* filter=lfs diff=lfs merge=lfs -text 4 | gggrx_1200a_sha* filter=lfs diff=lfs merge=lfs -text 5 | *.bpc filter=lfs diff=lfs merge=lfs -text 6 | *.bsp filter=lfs diff=lfs merge=lfs -text 7 | *.dbf filter=lfs diff=lfs merge=lfs -text 8 | *.shp filter=lfs diff=lfs merge=lfs -text 9 | *.shx filter=lfs diff=lfs merge=lfs -text 10 | -------------------------------------------------------------------------------- /devel/import_cache.py: -------------------------------------------------------------------------------- 1 | import astropy.utils.data as aud 2 | 3 | print("Current cached urls:") 4 | print(aud.get_cached_urls()) 5 | print("Saving old cache to old_cache.zip") 6 | aud.export_download_cache("old_cache.zip") 7 | aud.clear_download_cache() 8 | print("Cleared cached urls:") 9 | print(aud.get_cached_urls()) 10 | print("Importing from cache.zip") 11 | aud.import_download_cache("cache.zip") 12 | print("New cached urls:") 13 | print(aud.get_cached_urls()) 14 | -------------------------------------------------------------------------------- /devel/export_cache.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import astropy.units as u 3 | from astropy.time import Time 4 | import astropy.utils.data as aud 5 | 6 | print("Current cached urls:") 7 | print(aud.get_cached_urls()) 8 | 9 | # aud.clear_download_cache() 10 | print(Time.now().delta_ut1_utc) # requires finals2000A.all 11 | Time("J2018") + np.linspace(-40, 0, 1000)*u.year 12 | 13 | print("New cached urls:") 14 | print(aud.get_cached_urls()) 15 | aud.export_download_cache("cache.zip") 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/user_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: User question 3 | about: Ask a user support question 4 | title: "How do I ..." 5 | labels: 'question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe what you want to do** 11 | A clear and concise description of what the goal is. 12 | 13 | **Explain what documentation is missing** 14 | If someone else has this same goal, what do you think could be added to https://LLNL.github.io/SSAPy/ to help them complete the task on their own? 15 | -------------------------------------------------------------------------------- /tests/data/tles.txt: -------------------------------------------------------------------------------- 1 | GALAXY-15 2 | 1 28884U 05041A 18196.72132799 .00000060 00000-0 00000+0 0 9990 3 | 2 28884 0.0176 308.6679 0001935 164.1717 307.3411 1.00272316 46618 4 | 5 | GALAXY-11 6 | 1 26038U 99071A 18196.60081050 +.00000118 +00000-0 +00000-0 0 9990 7 | 2 26038 000.0836 097.3267 0000104 131.0104 326.2407 01.00272460068074 8 | 9 | AFRISTAR 10 | 1 25515U 98063A 18196.48992696 -.00000061 +00000-0 +00000-0 0 9990 11 | 2 25515 004.0946 067.2519 0003308 010.1213 232.5094 00.99008465071908 -------------------------------------------------------------------------------- /tests/data/b3_test.obs: -------------------------------------------------------------------------------- 1 | U9237951104254055820350-50379 0203537 50 2 | U9741851010001000232500098195 0808046 0000000 +01459067-03031669-06160507 90 3 | U9157724115141223146450-35218 2348547 50 4 | U9739751010001001624750014914 1056474 0000000 -02394058+01701762-06374778 90 5 | U9028951010001003808224-65943 0955484 0000000 -03370674+05922829+01647269 90 6 | U9999851110010015734450J48369 0352053 50 7 | U9892351110010020447750K18633 0501549 50 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # -*- conf -*- 2 | # flake8 settings for SSAPy. 3 | # 4 | [flake8] 5 | #ignore = E129,,W503 6 | #extend-ignore = E731,E203 7 | max-line-length = 120 8 | 9 | # F4: Import 10 | # - F405: `name` may be undefined, or undefined from star imports: `module` 11 | # 12 | # F8: Name 13 | # - F821: undefined name `name` 14 | # 15 | #per-file-ignores = 16 | # *-ci-package.py:F403,F405,F821 17 | 18 | # exclude things we usually do not want linting for. 19 | # These still get linted when passed explicitly. 20 | exclude = 21 | .git 22 | build/ 23 | dist/ 24 | __pycache__ 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_addoption(parser): 5 | parser.addoption( 6 | "--runslow", action="store_true", default=False, help="run slow tests" 7 | ) 8 | 9 | 10 | def pytest_configure(config): 11 | config.addinivalue_line("markers", "slow: mark test as slow to run") 12 | 13 | 14 | def pytest_collection_modifyitems(config, items): 15 | if config.getoption("--runslow"): 16 | # --runslow given in cli: do not skip slow tests 17 | return 18 | skip_slow = pytest.mark.skip(reason="need --runslow option to run") 19 | for item in items: 20 | if "slow" in item.keywords: 21 | item.add_marker(skip_slow) 22 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. SSAPy documentation master file, created by 2 | sphinx-quickstart on Fri Aug 4 16:23:59 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | .. include:: ../../README.rst 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | :caption: Basics 10 | 11 | installation 12 | examples 13 | benchmarks 14 | concepts 15 | contribution_guide 16 | references 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | :caption: API DOCS 21 | 22 | API Reference Guide 23 | 24 | Indices and tables 25 | ================== 26 | 27 | * :ref:`genindex` 28 | * :ref:`modindex` 29 | * :ref:`search` -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import numpy as np 3 | from astropy.time import Time 4 | from unittest.mock import patch, MagicMock 5 | 6 | from ssapy.simple import ( 7 | keplerian_prop, 8 | threebody_prop, 9 | fourbody_prop, 10 | best_prop, 11 | ssapy_kwargs, 12 | ssapy_prop, 13 | ssapy_orbit, 14 | get_similar_orbits, 15 | ) 16 | 17 | 18 | def test_ssapy_kwargs(): 19 | kwargs = ssapy_kwargs(100, 0.01, 2.1, 1.2) 20 | assert kwargs == {'mass': 100, 'area': 0.01, 'CD': 2.1, 'CR': 1.2} 21 | 22 | 23 | def test_ssapy_orbit_errors(): 24 | with pytest.raises(ValueError): 25 | ssapy_orbit() 26 | 27 | with pytest.raises(ValueError): 28 | ssapy_orbit(a=None, r=None, v=None) 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: " Adding cool new feature to SSAPy.XYZ" 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = _build 10 | 11 | # Remove sphinx generated files 12 | clean: 13 | rm -rf ${BUILDDIR} source/modules source/api 14 | 15 | # Put it first so that "make" without argument is like "make help". 16 | help: 17 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 18 | 19 | .PHONY: help Makefile 20 | 21 | # Catch-all target: route all unknown targets to Sphinx using the new 22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 23 | %: Makefile 24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "A bug was found in SSAPy.XYZ" 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. write analysis operation '...' 16 | 2. if applicable: make plots like '...' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Output** 23 | If applicable, add output, screenshots, or plots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. linux, osx,...] 27 | - Version [e.g. 22] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /docs/README.rst: -------------------------------------------------------------------------------- 1 | SSAPy Documentation 2 | =================== 3 | 4 | Building the docs 5 | ----------------- 6 | 7 | First, build and _install_ `ssapy` following the `Installing SSAPy `_ section of the documentation. 8 | 9 | Then, to build the HTML documentation locally, from the `docs` directory, run 10 | 11 | .. code-block:: bash 12 | 13 | make html 14 | 15 | (run it twice to generate all files.) 16 | 17 | Then open `_build/html/index.html` to browse the docs locally. 18 | 19 | Alternatively, you can run `sphinx-autobuild source _build/html` to start a server that watches for changes in `/docs` 20 | and regenerates the HTML docs automatically while serving them at http://127.0.0.1:8000/. 21 | 22 | Note that if you updated docstrings, you'll need to re-build and re-install ssapy before re-building the docs. 23 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API Reference Guide 2 | ******************* 3 | SSAPy: Space Situational Awareness for Python 4 | 5 | When executing 6 | 7 | >>> import SSAPy 8 | 9 | a subset of the full SSAPy package is imported into the python environment. 10 | Some packages must be imported explicitly, so as to avoid importing unnecessary 11 | and/or heavy dependencies. Below lists the packages available in the ``ssapy`` namespace. 12 | 13 | .. autosummary:: 14 | :toctree: modules 15 | :template: automodapi_templ.rst 16 | 17 | ssapy.accel 18 | ssapy.body 19 | ssapy.compute 20 | ssapy.constants 21 | ssapy.correlate_tracks 22 | ssapy.ellipsoid 23 | ssapy.gravity 24 | ssapy.io 25 | ssapy.linker 26 | ssapy.orbit_solver 27 | ssapy.orbit 28 | ssapy.particles 29 | ssapy.plotUtils 30 | ssapy.propagator 31 | ssapy.rvsampler 32 | ssapy.simple 33 | ssapy.utils 34 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build-and-publish: 13 | runs-on: ubuntu-latest 14 | environment: 15 | name: pypi 16 | url: https://pypi.org/p/llnl-ssapy 17 | permissions: 18 | id-token: write 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: 'recursive' 24 | 25 | - name: Install dependencies 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install build-essential git git-lfs cmake 29 | git lfs install 30 | git lfs pull 31 | python -m pip install --upgrade pip build 32 | python -m pip install -r requirements.txt 33 | 34 | - name: Build source distribution 35 | run: python -m build --sdist 36 | 37 | - name: Publish to PyPI 38 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This work was produced under the auspices of the U.S. Department of Energy by Lawrence Livermore National Laboratory under Contract DE-AC52-07NA27344. 2 | 3 | This work was prepared as an account of work sponsored by an agency of the United States Government. Neither the United States Government nor Lawrence Livermore National Security, LLC, nor any of their employees makes any warranty, expressed or implied, or assumes any legal liability or responsibility for the accuracy, completeness, or usefulness of any information, apparatus, product, or process disclosed, or represents that its use would not infringe privately owned rights. 4 | 5 | Reference herein to any specific commercial product, process, or service by trade name, trademark, manufacturer, or otherwise does not necessarily constitute or imply its endorsement, recommendation, or favoring by the United States Government or Lawrence Livermore National Security, LLC. 6 | 7 | The views and opinions of authors expressed herein do not necessarily state or reflect those of the United States Government or Lawrence Livermore National Security, LLC, and shall not be used for advertising or product endorsement purposes. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Lawrence Livermore National Security, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Attempt to fix "Missing base commit" messages in the codecov UI. 2 | # Because we do not run full tests on package PRs, package PRs' merge 3 | # commits on `main` don't have coverage info. It appears that 4 | # codecov will give you an error if the pseudo-base's coverage data 5 | # doesn't all apply properly to the real PR base. 6 | # 7 | # See here for docs: 8 | # https://docs.codecov.com/docs/comparing-commits#pseudo-comparison 9 | # See here for another potential solution: 10 | # https://community.codecov.com/t/2480/15 11 | 12 | codecov: 13 | allow_coverage_offsets: true 14 | 15 | coverage: 16 | precision: 2 17 | round: nearest 18 | range: 60...90 19 | status: 20 | project: 21 | default: 22 | threshold: 0.2% 23 | 24 | #ignore: 25 | # - test/.* 26 | 27 | comment: 28 | layout: " diff, flags, files" 29 | behavior: default 30 | require_changes: false # learn more in the Requiring Changes section below 31 | require_base: false # [true :: must have a base report to post] 32 | require_head: true # [true :: must have a head report to post] 33 | hide_project_coverage: false # [true :: only show coverage on the git diff] 34 | 35 | # Inline codecov annotations make the code hard to read, and they add 36 | # annotations in files that seemingly have nothing to do with the PR. 37 | github_checks: 38 | annotations: false 39 | 40 | 41 | -------------------------------------------------------------------------------- /ssapy/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | datadir = os.path.join(os.path.dirname(__file__), "data") 3 | 4 | from . import _ssapy 5 | from .orbit import Orbit, EarthObserver, OrbitalObserver 6 | from .propagator import ( 7 | KeplerianPropagator, SeriesPropagator, RK4Propagator, SGP4Propagator, 8 | SciPyPropagator, RK78Propagator, RK8Propagator 9 | ) 10 | from .compute import rv, dircos, radec, altaz, quickAltAz, radecRate, groundTrack 11 | from .accel import Accel, AccelKepler, AccelSum, AccelEarthRad, AccelSolRad, AccelDrag, AccelConstNTW 12 | from .linker import ModelSelectorParams, BinarySelectorParams, Linker 13 | from .orbit_solver import TwoPosOrbitSolver, GaussTwoPosOrbitSolver 14 | from .orbit_solver import DanchickTwoPosOrbitSolver, SheferTwoPosOrbitSolver 15 | from .orbit_solver import ThreeAngleOrbitSolver 16 | from .particles import Particles 17 | from .rvsampler import GEOProjectionInitializer, DistanceProjectionInitializer 18 | from .rvsampler import circular_guess 19 | from .rvsampler import GaussianRVInitializer, DirectInitializer 20 | from .rvsampler import RVProbability, EmceeSampler, MVNormalProposal 21 | from .rvsampler import RVSigmaProposal, MHSampler, LMOptimizer 22 | from .ellipsoid import Ellipsoid 23 | from .body import ( 24 | EarthOrientation, MoonOrientation, MoonPosition, Body, get_body 25 | ) 26 | from .gravity import HarmonicCoefficients, AccelThirdBody, AccelHarmonic 27 | 28 | from . import constants 29 | from . import plotUtils 30 | from . import io 31 | from . import utils 32 | from . import simple 33 | 34 | from astropy.time import Time, TimeDelta 35 | import astropy.units as u 36 | from datetime import timedelta -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "llnl-ssapy" 3 | version = "1.1.3" 4 | authors = [ 5 | { name="LLNL SSAPy Software Team", email="yeagerastro@gmail.com" }, 6 | ] 7 | description = "A fast, flexible, high-fidelity orbital modeling and analysis tool for orbits spanning from low-Earth orbit into the cislunar regime." 8 | readme = "README.rst" 9 | requires-python = ">=3.8" 10 | classifiers = [ 11 | "Programming Language :: Python :: 3", 12 | "Operating System :: OS Independent", 13 | ] 14 | license = { file = "LICENSE" } 15 | dependencies = [ 16 | 'numpy<2.0', 17 | 'scipy', 18 | 'astropy', 19 | 'pyerfa', 20 | 'emcee', 21 | 'lmfit', 22 | 'sgp4', 23 | 'matplotlib', 24 | 'pandas', 25 | 'h5py', 26 | 'pypdf2', 27 | 'imageio', 28 | 'ipython', 29 | 'ipyvolume', 30 | 'ipython_genutils', 31 | 'jplephem', 32 | 'tqdm', 33 | 'myst-parser', 34 | 'graphviz', 35 | ] 36 | 37 | [build-system] 38 | requires = ["setuptools>=42", "wheel", "scikit-build", "cmake"] 39 | build-backend = "setuptools.build_meta" 40 | 41 | [tool.setuptools] 42 | packages = ["ssapy"] 43 | include-package-data = true 44 | 45 | [tool.setuptools.package-data] 46 | ssapy = ["*.cpp", "*.h", "data/*", "*.so"] 47 | 48 | [tool.poetry] 49 | packages = [ 50 | { include = "ssapy" }, 51 | ] 52 | 53 | [tool.poetry.include] 54 | files = ["ssapy/_ssapy.cpython-38-x86_64-linux-gnu.so"] 55 | 56 | [tool.pytest.ini_options] 57 | testpaths = ["tests"] 58 | timeout = 30 59 | timeout_method = "thread" 60 | 61 | [project.urls] 62 | Homepage = "https://github.com/LLNL/SSAPy" 63 | Issues = "https://github.com/LLNL/SSAPy/issues" 64 | Documentation = "https://software.llnl.gov/SSAPy/" 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ########################## 2 | # SSAPy-specific ignores # 3 | ########################## 4 | 5 | **/*.h5 6 | 7 | ########################### 8 | # Python-specific ignores # 9 | ########################### 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | wheels/ 26 | 27 | # Python egg metadata, regenerated from source files by setuptools. 28 | eggs/ 29 | .eggs/ 30 | **/*.egg-info 31 | **/*.egg 32 | 33 | # Unit test / coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .nox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | *.cover 43 | *.py,cover 44 | .hypothesis/ 45 | .pytest_cache/ 46 | pytest.xml 47 | pytest_session.txt 48 | cover/ 49 | 50 | # Ignore ssapy_test_plots folder in tests subfolder 51 | tests/ssapy_test_plots/ 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | docs/source/api 56 | docs/source/modules 57 | 58 | # Jupyter Notebook 59 | **/*.ipynb_checkpoints 60 | **/*-checkpoint.ipynb 61 | 62 | # Environments 63 | .env 64 | .venv 65 | env/ 66 | venv/ 67 | ENV/ 68 | env.bak 69 | venv.bak 70 | 71 | ################################## 72 | # Visual Studio-specific ignores # 73 | ################################## 74 | 75 | .vscode/* 76 | !.vscode/settings.json 77 | !.vscode/tasks.json 78 | !.vscode/launch.json 79 | !.vscode/extensions.json 80 | *.code-workspace 81 | 82 | # Local History for Visual Studio Code 83 | .history/ 84 | 85 | ########################## 86 | # macOS-specific ignores # 87 | ########################## 88 | 89 | # General 90 | .DS_Store 91 | 92 | ########################## 93 | # Linux-specific ignores # 94 | ########################## 95 | 96 | *~ 97 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | System Prerequisites 5 | -------------------- 6 | 7 | ``SSAPy`` has the following minimum system requirements, which are assumed to be present on the machine where ``SSAPy`` is run: 8 | 9 | .. csv-table:: System prerequisites for SSAPy 10 | :file: tables/system_prerequisites.csv 11 | :header-rows: 1 12 | 13 | These requirements can be easily installed on most modern macOS and Linux systems. 14 | 15 | .. tabs:: 16 | 17 | .. tab:: Debian/Ubuntu 18 | 19 | .. code-block:: console 20 | 21 | apt update 22 | apt install build-essential git git-lfs python3 python3-distutils python3-venv graphviz 23 | 24 | .. tab:: RHEL 25 | 26 | .. code-block:: console 27 | 28 | dnf install epel-release 29 | dnf group install "Development Tools" 30 | dnf install git git-lfs gcc-gfortran python3 python3-pip python3-setuptools graphviz 31 | 32 | .. tab:: macOS Brew 33 | 34 | .. code-block:: console 35 | 36 | brew update 37 | brew install gcc git git-lfs python3 graphviz 38 | 39 | Installation 40 | ------------ 41 | 42 | As the package has been published on `PyPI `_, it can be installed using pip. 43 | 44 | .. code-block:: console 45 | 46 | pip install llnl-ssapy 47 | 48 | Orekit dependency 49 | ^^^^^^^^^^^^^^^^^ 50 | 51 | `Orekit `_ is an optional dependency, including the ``Orekit`` Python wrapper that is hard to find. Clone the python wrappper from here: 52 | 53 | `https://gitlab.orekit.org/orekit-labs/python-wrapper `_ 54 | 55 | Alternatively, the ``Orekit`` python wrapper can be installed from `Anaconda `_. 56 | -------------------------------------------------------------------------------- /tests/test_ellipsoid.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from ssapy.ellipsoid import Ellipsoid 5 | 6 | @pytest.fixture 7 | def sample_ellipsoid(): 8 | """Fixture for a standard ellipsoid with WGS84 flattening.""" 9 | return Ellipsoid(Req=6378137.0, f=1 / 298.257223563) 10 | 11 | @pytest.mark.timeout(30) 12 | def test_sphere_to_cart_and_back_scalar(sample_ellipsoid): 13 | lon = np.deg2rad(30) 14 | lat = np.deg2rad(45) 15 | height = 1000.0 16 | 17 | x, y, z = sample_ellipsoid.sphereToCart(lon, lat, height) 18 | lon2, lat2, height2 = sample_ellipsoid.cartToSphere(x, y, z) 19 | 20 | assert np.isclose(lon, lon2, atol=1e-9) 21 | assert np.isclose(lat, lat2, atol=1e-9) 22 | assert np.isclose(height, height2, atol=1e-3) 23 | 24 | @pytest.mark.timeout(30) 25 | def test_sphere_to_cart_broadcasting(sample_ellipsoid): 26 | lon = np.deg2rad(30) 27 | lat = np.deg2rad([0, 45, 90]) 28 | height = 500.0 29 | 30 | x, y, z = sample_ellipsoid.sphereToCart(lon, lat, height) 31 | assert x.shape == lat.shape 32 | assert y.shape == lat.shape 33 | assert z.shape == lat.shape 34 | 35 | @pytest.mark.timeout(30) 36 | def test_cart_to_sphere_broadcasting_safe(sample_ellipsoid): 37 | # Slightly offset points near the principal axes to avoid edge cases 38 | x = np.array([6378137.0, 1.0, 100.0]) 39 | y = np.array([0.1, 6378137.0, 200.0]) 40 | z = np.array([0.1, 0.2, 6356752.314245]) 41 | 42 | lon, lat, height = sample_ellipsoid.cartToSphere(x, y, z) 43 | 44 | # Check output shapes 45 | assert lon.shape == x.shape 46 | assert lat.shape == x.shape 47 | assert height.shape == x.shape 48 | 49 | # Check values are finite 50 | assert np.all(np.isfinite(lon)) 51 | assert np.all(np.isfinite(lat)) 52 | assert np.all(np.isfinite(height)) 53 | 54 | # Optional: Check known ranges 55 | assert np.all((lat >= -np.pi / 2) & (lat <= np.pi / 2)) 56 | assert np.all((lon >= -np.pi) & (lon <= np.pi)) 57 | 58 | -------------------------------------------------------------------------------- /docs/source/refs.bib: -------------------------------------------------------------------------------- 1 | @inproceedings{ssapyprep, 2 | title = {{SSAPy} - Space Situational Awareness for Python}, 3 | author = {Meyers, Joshua E. and Schneider, Michael D. and Ebert, Julia T. and Schlafly, Edward F. and Yeager, Travis and Perloff, Alexx and Merl, Daniel and Lifset, Noah and Berstein, Jason and Dawson, William A. and Golovich, Nathan and Higgins, Denvir and McGill, Peter and Miller, Caleb and Pruett, Kerianne}, 4 | booktitle = {The Journal of Open Source Software}, 5 | publisher = {The Open Journal}, 6 | note = {Submitted to}, 7 | year = {2024} 8 | } 9 | 10 | @INPROCEEDINGS{2023amos.conf..208Y, 11 | author = {{Yeager}, T. and {Pruett}, K. and {Schneider}, M.}, 12 | title = "{Long-term N-body Stability in Cislunar Space}", 13 | keywords = {cislunar, n-body, dynamics, orbit, stability}, 14 | booktitle = {Proceedings of the Advanced Maui Optical and Space Surveillance (AMOS) Technologies Conference}, 15 | year = 2023, 16 | editor = {{Ryan}, S.}, 17 | month = sep, 18 | eid = {208}, 19 | pages = {208}, 20 | url = {https://amostech.com/TechnicalPapers/2023/Poster/Yeager.pdf}, 21 | adsurl = {https://ui.adsabs.harvard.edu/abs/2023amos.conf..208Y}, 22 | adsnote = {Provided by the SAO/NASA Astrophysics Data System} 23 | } 24 | 25 | @unpublished{2023amos.conf.poster..Yeager, 26 | author = {{Yeager}, Travis and {Pruett}, Kerianne and {Schneider}, Michael}, 27 | title = "{Long-term N-body Stability in Cislunar Space}", 28 | year = 2023, 29 | note = {Poster presented at the Advanced Maui Optical and Space Surveillance (AMOS) Technologies Conference}, 30 | } 31 | 32 | @unpublished{2022csc.conf.poster..Yeager, 33 | author = {{Yeager}, Travis and {Pruett}, Kerianne and {Schneider}, Michael}, 34 | title = "{Unaided Dynamical Orbit Stability in the Cislunar Regime}", 35 | year = 2022, 36 | note = {Poster presented at the Cislunar Security Conference}, 37 | } 38 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import os 2 | import numpy as np 3 | from io import StringIO 4 | from astropy.time import Time 5 | from ssapy import io 6 | 7 | def test_file_exists_extension_agnostic(tmp_path): 8 | f = tmp_path / "testfile.txt" 9 | f.write_text("data") 10 | assert io.file_exists_extension_agnostic(str(f).replace(".txt", "")) is True 11 | 12 | def test_exists(tmp_path): 13 | f = tmp_path / "example.txt" 14 | f.write_text("content") 15 | assert io.exists(str(f)) is True 16 | assert io.exists(str(tmp_path)) is True 17 | assert io.exists("/nonexistent/path") is False 18 | 19 | def test_read_tle_catalog(tmp_path): 20 | file = tmp_path / "tle.txt" 21 | file.write_text("1 25544U 98067A 21073.51465278 .00000282\n2 25544 51.6430 249.4256 0001791 160.3235 199.7986 15.48988277272524\n") 22 | result = io.read_tle_catalog(str(file)) 23 | assert len(result) == 1 24 | assert result[0][0].startswith("1 ") 25 | assert result[0][1].startswith("2 ") 26 | 27 | def test_read_tle(tmp_path): 28 | file = tmp_path / "tle.txt" 29 | file.write_text("ISS (ZARYA)\n1 25544U 98067A 21073.51465278 .00000282\n2 25544 51.6430 249.4256 0001791 160.3235 199.7986 15.48988277272524\n") 30 | line1, line2 = io.read_tle("ISS (ZARYA)", str(file)) 31 | assert line1.startswith("1 ") 32 | assert line2.startswith("2 ") 33 | 34 | def test_make_tle_and_parse_tle(): 35 | a = 6780000.0 # semi-major axis in meters 36 | e = 0.001 37 | i = np.radians(51.6) 38 | pa = np.radians(45.0) 39 | raan = np.radians(120.0) 40 | true_anomaly = np.radians(60.0) 41 | t = Time.now() 42 | 43 | line1, line2 = io.make_tle(a, e, i, pa, raan, true_anomaly, t) 44 | parsed = io.parse_tle((line1, line2)) 45 | assert isinstance(parsed, tuple) 46 | assert len(parsed) == 7 47 | assert np.isclose(parsed[0], a, rtol=0.01) 48 | 49 | def test_parse_overpunched(): 50 | assert io.parse_overpunched("J1234") == "-11234" 51 | assert io.parse_overpunched("51234") == "51234" 52 | 53 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # Minimum required CMake version 2 | cmake_minimum_required(VERSION 3.15) 3 | 4 | # Set policies for compatibility 5 | cmake_policy(SET CMP0048 NEW) # Project version policy 6 | cmake_policy(SET CMP0063 NEW) # Visibility preset policy 7 | 8 | # Project settings 9 | project(ssapy VERSION 1.0 LANGUAGES CXX) 10 | 11 | # Compiler settings 12 | set(CMAKE_CXX_STANDARD 17) # Use C++17 standard 13 | set(CMAKE_CXX_STANDARD_REQUIRED ON) # Require C++17 14 | set(CMAKE_CXX_EXTENSIONS OFF) # Disable compiler-specific extensions 15 | set(CMAKE_CXX_VISIBILITY_PRESET hidden) # Hide symbols by default 16 | set(CMAKE_POSITION_INDEPENDENT_CODE ON) # Enable position-independent code (PIC) 17 | 18 | # macOS-specific settings 19 | set(CMAKE_MACOSX_RPATH 1) 20 | 21 | # Output directories 22 | if(NOT CMAKE_LIBRARY_OUTPUT_DIRECTORY) 23 | set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # Set default output directory for libraries 24 | endif() 25 | 26 | # Find Python 27 | find_package(Python3 REQUIRED COMPONENTS Interpreter Development.Module) 28 | set(PYTHON_MODULE_INSTALL_DIR "${Python3_SITEARCH}/ssapy") 29 | 30 | # Use the bundled pybind11 subdirectory 31 | add_subdirectory(pybind11) 32 | 33 | # Include directories 34 | include_directories(${CMAKE_CURRENT_SOURCE_DIR}/include) 35 | 36 | # Define the Python module target (_ssapy) 37 | pybind11_add_module(_ssapy pysrc/ssapy.cpp) 38 | 39 | # Ensure the Python module is installed in the correct location 40 | set(CMAKE_POSITION_INDEPENDENT_CODE True) 41 | install(TARGETS _ssapy 42 | LIBRARY DESTINATION ssapy # Install the compiled module in Python's site-packages 43 | ) 44 | 45 | # Define a static library target (ssapy) 46 | add_library(ssapy STATIC src/ssapy.cpp) 47 | 48 | # Link the static library to the Python module 49 | target_link_libraries(_ssapy PUBLIC ssapy) 50 | 51 | # Add the header file explicitly to the static library target 52 | target_sources(ssapy PRIVATE include/ssapy.h) 53 | 54 | # Ensure the header file is included in the Python module target 55 | target_include_directories(_ssapy PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/centos/centos:stream9 2 | 3 | ARG BUILD_DATE 4 | LABEL org.label-schema.build-date=$BUILD_DATE \ 5 | org.label-schema.name="SSAPy Docker image" \ 6 | org.label-schema.description="Provides an environment containing a working copy of SSAPy." \ 7 | org.label-schema.vendor="LLNL" 8 | 9 | # Copy over SSAPy 10 | COPY SSAPy/ /opt/SSAPy/ 11 | 12 | # Install C/C++ compilers 13 | RUN echo "sslverify=False" >> /etc/dnf/dnf.conf && \ 14 | dnf -y update && \ 15 | dnf groupinstall --exclude=selinux-policy --exclude=selinux-policy-targeted -y "Development Tools" && \ 16 | dnf -y clean all && \ 17 | sed -i '$ d' /etc/dnf/dnf.conf && \ 18 | gcc --version 19 | 20 | # Install necessary cmake packages 21 | RUN echo "sslverify=False" >> /etc/dnf/dnf.conf && \ 22 | dnf install -y make cmake3 tree wget vi && \ 23 | dnf -y clean all && \ 24 | sed -i '$ d' /etc/dnf/dnf.conf && \ 25 | make --version && \ 26 | cmake --version && \ 27 | ls -l / 28 | 29 | # Install Python and some requirement dependencies 30 | RUN echo "sslverify=False" >> /etc/dnf/dnf.conf && \ 31 | dnf install -y epel-release && \ 32 | dnf install -y hdf5 hdf5-devel && \ 33 | dnf install -y python3.12 python3.12-devel python3.12-pip && \ 34 | dnf -y clean all && \ 35 | sed -i '$ d' /etc/dnf/dnf.conf && \ 36 | unlink /usr/bin/python3 && \ 37 | ln -s /usr/bin/python3.12 /usr/bin/python3 && \ 38 | python3 --version 39 | 40 | # Install the pip packages 41 | RUN python3 -m pip --version && \ 42 | python3 -m pip install --no-cache-dir --upgrade pip && \ 43 | python3 -m pip install --no-cache-dir --upgrade setuptools && \ 44 | python3 -m pip install --no-cache-dir -r /opt/SSAPy/requirements.txt 45 | 46 | # Install SSAPY 47 | RUN ls -la / && \ 48 | ls -la /opt && \ 49 | ls -la /opt/SSAPy && \ 50 | tree -r /opt/SSAPy && \ 51 | cd /opt/SSAPy && \ 52 | python3 setup.py build && \ 53 | python3 setup.py install && \ 54 | chmod -R go+rwX ./ && \ 55 | chmod -R go+rwX /usr/lib64/python3.12/site-packages/ && \ 56 | chmod -R go+rwX /usr/local/lib64/python3.12/site-packages/ 57 | -------------------------------------------------------------------------------- /tests/test_linker.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.time import Time 3 | import astropy.units as u 4 | from astropy.coordinates import Longitude, Latitude 5 | from astropy.table import QTable 6 | 7 | import ssapy 8 | from ssapy.constants import RGEO, VGEO 9 | 10 | def _create_iods_small(num_epochs=2, num_obs_per_track=3, nBurn=10, nStep=10): 11 | from ssapy.rvsampler import RPrior, APrior 12 | np.random.seed(42) 13 | 14 | sigma_arcsec = 1.0 15 | sigma_rad = sigma_arcsec * np.pi / (180. * 3600.) 16 | time0 = Time(2458316., format='jd') 17 | 18 | lon, lat, elevation = 100.0, 33.0, 1300.0 19 | observer = ssapy.EarthObserver(lon, lat, elevation) 20 | orbit = ssapy.Orbit.fromKeplerianElements(RGEO * 0.98, 0.01, 0.001, 0.0, 1.2, 1.03, time0) 21 | times = time0 + np.linspace(0, 4, num_obs_per_track) * u.h 22 | coord = ssapy.radec(orbit, times, observer=observer) 23 | rstation, vstation = observer.getRV(times) 24 | 25 | iods = [] 26 | for _ in range(num_epochs): 27 | arc = QTable() 28 | arc['ra'] = Longitude((coord[0] + np.random.randn(len(coord[0])) * sigma_rad) * u.rad) 29 | arc['dec'] = Latitude((coord[1] + np.random.randn(len(coord[1])) * sigma_rad) * u.rad) 30 | arc['rStation_GCRF'] = rstation * u.m 31 | arc['vStation_GCRF'] = vstation * u.m / u.s 32 | arc['time'] = Time(times) 33 | arc['sigma'] = sigma_arcsec * np.ones(num_obs_per_track) * u.arcsec 34 | 35 | r, v = ssapy.rv(orbit, time0) 36 | initializer = ssapy.GaussianRVInitializer(r, v, rSigma=0.1 * RGEO, vSigma=0.1 * VGEO) 37 | priors = [RPrior(RGEO, RGEO * 0.2), APrior(RGEO, RGEO * 0.2)] 38 | rvprob = ssapy.RVProbability(arc, time0, priors=priors) 39 | 40 | sampler = ssapy.EmceeSampler(rvprob, initializer, nWalker=10) 41 | chain, lnprob, lnprior = sampler.sample(nBurn=nBurn, nStep=nStep) 42 | chain, lnprob, lnprior = ssapy.utils.cluster_emcee_walkers(chain, lnprob, lnprior, thresh_multiplier=4) 43 | 44 | iods.append(ssapy.Particles(chain, rvprob, lnpriors=lnprior)) 45 | 46 | return iods 47 | 48 | 49 | def test_model_selector_params_normalize(): 50 | p = ssapy.ModelSelectorParams(3, 3, init_val=1.0) 51 | p.normalize() 52 | for i in range(3): 53 | assert np.isclose(np.sum(p[i]), 1.0) 54 | -------------------------------------------------------------------------------- /tests/test_collision_event.py: -------------------------------------------------------------------------------- 1 | from ssapy import AccelKepler 2 | from ssapy.constants import RGEO, VGEO, EARTH_RADIUS 3 | from ssapy.compute import rv 4 | from ssapy.orbit import Orbit 5 | from ssapy.propagator import RK4Propagator, RK8Propagator, RK78Propagator, SciPyPropagator, KeplerianPropagator 6 | import numpy as np 7 | import matplotlib.pyplot as plt 8 | 9 | # Set up the suborbital test orbit (collides with Earth) 10 | r0 = np.array([RGEO, 0, 0]) 11 | v0 = np.array([0, VGEO / 2, 0]) # too slow to stay in orbit 12 | t0 = 0 13 | orbit = Orbit(r0, v0, t0) 14 | 15 | # Time span (long enough to ensure impact) 16 | tvals_full = np.arange(t0, 24000) 17 | 18 | # List of propagators to test 19 | propagators = [ 20 | RK4Propagator(AccelKepler(), h=10), 21 | RK8Propagator(AccelKepler(), h=10), 22 | RK78Propagator(AccelKepler(), h=10), 23 | SciPyPropagator(AccelKepler()), 24 | # KeplerianPropagator() # Keplerian won't handle impact correctly; optional to include 25 | ] 26 | 27 | # Run test for each propagator 28 | for prop in propagators: 29 | print(f"\nTesting: {prop}") 30 | try: 31 | r, v = rv(orbit, tvals_full, propagator=prop) 32 | altitudes = np.linalg.norm(r, axis=1) - EARTH_RADIUS 33 | tvals_trunc = tvals_full[:len(r)] 34 | 35 | print(f"Returned {len(r)} valid states before impact.") 36 | print(f"Final altitude: {altitudes[-1]:.2f} meters") 37 | 38 | # Plot Altitude vs Time 39 | plt.figure(figsize=(12, 5)) 40 | plt.subplot(1, 2, 1) 41 | plt.plot(tvals_trunc, altitudes / 1e3, label='Altitude (km)', color='tab:blue') 42 | plt.axhline(0, linestyle='--', color='red', label='Earth Surface (0 km)') 43 | plt.xlabel("Time (s)") 44 | plt.ylabel("Altitude (km)") 45 | plt.title(f"Altitude vs Time") 46 | plt.grid(True) 47 | plt.legend() 48 | 49 | # Plot Orbit in XY-plane 50 | plt.subplot(1, 2, 2) 51 | plt.plot(r[:, 0] / 1e3, r[:, 1] / 1e3, label='Trajectory (XY)', color='tab:green') 52 | earth = plt.Circle((0, 0), EARTH_RADIUS / 1e3, color='lightblue', alpha=0.5, label='Earth') 53 | plt.gca().add_patch(earth) 54 | plt.axis('equal') 55 | plt.xlabel("X Position (km)") 56 | plt.ylabel("Y Position (km)") 57 | plt.title("XY Orbit Projection") 58 | plt.grid(True) 59 | plt.legend() 60 | 61 | plt.suptitle(f"Propagator: {prop}") 62 | plt.tight_layout() 63 | plt.show() 64 | 65 | except Exception as e: 66 | print(f"Error with {prop}: {e}") 67 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import ssapy 7 | 8 | # -- Project information ----------------------------------------------------- 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 10 | 11 | project = 'SSAPy' 12 | copyright = '2018, Lawrence Livermore National Security, LLC' 13 | author = 'Michael Schneider, Josh Meyers, Edward Schlafly, Julia Ebert, Travis Yeager, et al.' 14 | 15 | # -- General configuration --------------------------------------------------- 16 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 17 | 18 | extensions = [ 19 | 'sphinx.ext.autodoc', 20 | 'sphinx.ext.autosummary', 21 | 'sphinx.ext.intersphinx', 22 | 'sphinx.ext.linkcode', 23 | 'sphinx_copybutton', 24 | 'sphinx_tabs.tabs', 25 | 'sphinx_automodapi.automodapi', 26 | 'sphinx_automodapi.smart_resolver', 27 | 'myst_parser', 28 | 'sphinx_rtd_theme', 29 | 'sphinx.ext.mathjax', 30 | 'sphinxcontrib.bibtex', 31 | 'sphinx.ext.napoleon', 32 | 'sphinx.ext.viewcode', 33 | ] 34 | 35 | def linkcode_resolve(domain, info): 36 | if domain != 'py': 37 | return None 38 | if not info['module']: 39 | return None 40 | filename = info['module'].replace('.', '/') 41 | return "https://github.com/LLNL/SSAPy/tree/main/%s.py" % filename 42 | 43 | autosummary_generate = True 44 | autosummary_imported_members = True 45 | numpydoc_show_class_members = False 46 | sphinx_tabs_valid_builders = ['linkcheck'] 47 | source_suffix = ['.rst', '.md'] 48 | templates_path = ['_templates'] 49 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 50 | intersphinx_mapping = { 51 | "python": ("https://docs.python.org/3", None), 52 | "numpy": ("https://numpy.org/doc/stable", None), 53 | } 54 | tls_verify = False 55 | master_doc = "index" 56 | 57 | # -- Options for HTML output ------------------------------------------------- 58 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 59 | 60 | html_theme = 'sphinx_rtd_theme' 61 | html_static_path = ['_static'] 62 | html_logo = "_static/images/logo/ssapy_logo.svg" 63 | html_theme_options = { 64 | 'logo_only': True, 65 | } 66 | html_favicon = '_static/images/logo/ssapy_logo.ico' 67 | 68 | bibtex_cache = 'none' 69 | bibtex_bibfiles = ["refs.bib"] 70 | bibtex_reference_style = "author_year" 71 | bibtex_debug = True 72 | -------------------------------------------------------------------------------- /devel/catalog_to_apparent.c: -------------------------------------------------------------------------------- 1 | // Script used to unit test proper motion, parallax, and aberration corrections in SSA library. 2 | 3 | #include 4 | #include "sofa.h" 5 | 6 | void reprd ( char*, double, double ); 7 | 8 | int main () { 9 | iauASTROM astrom; 10 | double utc1, utc2, tai1, tai2, tt1, tt2, 11 | rc, dc, pr, pd, px, rv, 12 | eo, ri, di, rca, dca; 13 | /* UTC date. */ 14 | if ( iauDtf2d ( "UTC", 2013, 4, 2, 23, 15, 43.55, 15 | &utc1, &utc2 ) ) return -1; 16 | /* TT date. */ 17 | if ( iauUtctai ( utc1, utc2, &tai1, &tai2 ) ) return -1; 18 | if ( iauTaitt ( tai1, tai2, &tt1, &tt2 ) ) return -1; 19 | /* Star ICRS RA,Dec (radians). */ 20 | if ( iauTf2a ( ' ', 14, 34, 16.81183, &rc ) ) return -1; 21 | if ( iauAf2a ( '-', 12, 31, 10.3965, &dc ) ) return -1; 22 | reprd ( "ICRS, epoch J2000.0:", rc, dc ); 23 | /* Proper motion */ 24 | pr = atan2 ( -354.45e-3 * DAS2R, cos(dc) ); 25 | pd = 595.35e-3 * DAS2R; 26 | px = 0.0; 27 | rv = 0.0; 28 | iauAtci13 ( rc, dc, pr, pd, px, rv, tt1, tt2, &ri, &di, &eo ); 29 | iauAtic13 ( ri, di, tt1, tt2, &rca, &dca, &eo ); 30 | reprd ( "Try proper motion:", rca, dca ); 31 | /* Parallax */ 32 | pr = 0.0; 33 | pd = 0.0; 34 | px = 0.16499; 35 | rv = 0.0; 36 | iauAtci13 ( rc, dc, pr, pd, px, rv, tt1, tt2, &ri, &di, &eo ); 37 | iauAtic13 ( ri, di, tt1, tt2, &rca, &dca, &eo ); 38 | reprd ( "Try parallax:", rca, dca ); 39 | /* aberration */ 40 | pr = 0.0; 41 | pd = 0.0; 42 | px = 0.0; 43 | rv = 0.0; 44 | double pco[3], ppr[3], rg, dg, w; 45 | iauApcg13(tt1, tt2, &astrom); 46 | iauPmpx(rc, dc, pr, pd, px, rv, astrom.pmt, astrom.eb, pco); 47 | iauAb(pco, astrom.v, astrom.em, astrom.bm1, ppr); 48 | iauC2s(ppr, &w, &dg); 49 | rg = iauAnp(w); 50 | reprd ( "aberration:", rg, dg); 51 | /* all of above */ 52 | pr = atan2 ( -354.45e-3 * DAS2R, cos(dc) ); 53 | pd = 595.35e-3 * DAS2R; 54 | px = 0.16499; 55 | rv = 0.0; 56 | iauApcg13(tt1, tt2, &astrom); 57 | iauPmpx(rc, dc, pr, pd, px, rv, astrom.pmt, astrom.eb, pco); 58 | iauAb(pco, astrom.v, astrom.em, astrom.bm1, ppr); 59 | iauC2s(ppr, &w, &dg); 60 | rg = iauAnp(w); 61 | reprd ( "all above:", rg, dg); 62 | 63 | return 0; 64 | } 65 | 66 | void reprd ( char* s, double ra, double dc ) 67 | { 68 | char pm; 69 | int i[4]; 70 | printf ( "%25s", s ); 71 | iauA2tf ( 7, ra, &pm, i ); 72 | printf ( " %2.2d %2.2d %2.2d.%7.7d", i[0],i[1],i[2],i[3] ); 73 | iauA2af ( 6, dc, &pm, i ); 74 | printf ( " %c%2.2d %2.2d %2.2d.%6.6d\n", pm, i[0],i[1],i[2],i[3] ); 75 | } 76 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, Extension, find_packages 2 | from setuptools.command.build_ext import build_ext 3 | from setuptools.command.install import install 4 | import subprocess 5 | import sys 6 | import os 7 | 8 | try: 9 | import tomllib 10 | except ImportError: 11 | import tomli as tomllib 12 | 13 | # Read dependencies from pyproject.toml 14 | def get_dependencies(): 15 | try: 16 | with open('pyproject.toml', 'rb') as f: 17 | pyproject = tomllib.load(f) 18 | return pyproject['project']['dependencies'] 19 | except (FileNotFoundError, KeyError): 20 | return [] 21 | 22 | class CMakeExtension(Extension): 23 | def __init__(self, name, sourcedir=''): 24 | Extension.__init__(self, name, sources=[]) 25 | self.sourcedir = os.path.abspath(sourcedir) 26 | 27 | class CMakeBuild(build_ext): 28 | def run(self): 29 | try: 30 | _ = subprocess.check_output(['cmake', '--version']) 31 | except OSError: 32 | raise RuntimeError("CMake must be installed to build the following extensions: " + ", ".join(e.name for e in self.extensions)) 33 | 34 | for ext in self.extensions: 35 | self.build_extension(ext) 36 | 37 | def build_extension(self, ext): 38 | extdir = os.path.abspath(os.path.dirname(self.get_ext_fullpath(ext.name))) 39 | cmake_args = ['-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=' + extdir, 40 | '-DPYTHON_EXECUTABLE=' + sys.executable] 41 | 42 | cfg = 'Debug' if self.debug else 'Release' 43 | build_args = ['--config', cfg] 44 | 45 | cmake_args += ['-DCMAKE_BUILD_TYPE=' + cfg] 46 | build_args += ['--', '-j4'] 47 | 48 | env = os.environ.copy() 49 | env['CXXFLAGS'] = '{} -DVERSION_INFO=\\"{}\\"'.format(env.get('CXXFLAGS', ''), 50 | self.distribution.get_version()) 51 | if not os.path.exists(self.build_temp): 52 | os.makedirs(self.build_temp) 53 | if 'CMAKE_VERBOSE_MAKEFILE' in env: 54 | cmake_args += ['-DCMAKE_VERBOSE_MAKEFILE=1'] 55 | subprocess.check_call(['cmake', ext.sourcedir] + cmake_args, cwd=self.build_temp, env=env) 56 | subprocess.check_call(['cmake', '--build', '.'] + build_args, cwd=self.build_temp) 57 | 58 | 59 | setup( 60 | name='ssapy', 61 | version='1.1.1', 62 | ext_modules=[CMakeExtension("ssapy._ssapy")], 63 | cmdclass={"build_ext": CMakeBuild}, 64 | packages=find_packages(), 65 | package_data={'ssapy': ['data/*','_ssapy*.so']}, 66 | license='MIT', 67 | zip_safe=False, 68 | include_package_data=True, 69 | install_requires=get_dependencies(), 70 | ) 71 | 72 | -------------------------------------------------------------------------------- /tests/ssapy_test_helpers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import ssapy 3 | 4 | 5 | def timer(f): 6 | import functools 7 | 8 | @functools.wraps(f) 9 | def f2(*args, **kwargs): 10 | import time 11 | t0 = time.time() 12 | result = f(*args, **kwargs) 13 | t1 = time.time() 14 | fname = repr(f).split()[1] 15 | print('time for %s = %.2f' % (fname, t1-t0)) 16 | return result 17 | return f2 18 | 19 | 20 | def checkAngle(a, b, rtol=0, atol=1e-14): 21 | diff = (a-b)%(2*np.pi) 22 | absdiff = np.min([np.abs(diff), np.abs(2*np.pi-diff)], axis=0) 23 | np.testing.assert_allclose(absdiff, 0, rtol=rtol, atol=atol) 24 | 25 | 26 | def checkSphere(lon1, lat1, lon2, lat2, atol=1e-14, verbose=False): 27 | from ssapy.utils import unitAngle3 28 | x1 = np.cos(lon1)*np.cos(lat1) 29 | y1 = np.sin(lon1)*np.cos(lat1) 30 | z1 = np.sin(lat1) 31 | x2 = np.cos(lon2)*np.cos(lat2) 32 | y2 = np.sin(lon2)*np.cos(lat2) 33 | z2 = np.sin(lat2) 34 | da = unitAngle3(np.array([x1, y1, z1]).T, np.array([x2, y2, z2]).T) 35 | if verbose: 36 | print(f"max angle difference {np.rad2deg(np.max(da))*3600} arcsec") 37 | np.testing.assert_allclose(da, 0, rtol=0, atol=atol) 38 | 39 | 40 | def sample_orbit(t, r_low, r_high, v_low, v_high): 41 | """ 42 | Sample a random orbit by drawing a position vector with magnitude in 43 | (r_low, r_high), a velocity vector with magnitude in (v_low, v_high), and 44 | a direction, at time t. 45 | """ 46 | def sample_vector(lower, upper): 47 | """ 48 | Helper function for sampling a position and velocity vector. 49 | """ 50 | # Sample magnitude of vector 51 | mag = np.random.uniform(lower, upper) 52 | # Sample a random direction (not uniform on sphere) 53 | theta = np.random.uniform(0, 2*np.pi) 54 | phi = np.random.uniform(0, np.pi) 55 | vec_x = mag * np.cos(theta) * np.sin(phi) 56 | vec_y = mag * np.sin(theta) * np.sin(phi) 57 | vec_z = mag * np.cos(phi) 58 | vec = np.array([vec_x, vec_y, vec_z]) 59 | return vec 60 | 61 | # Sample position and velocity 62 | r = sample_vector(r_low, r_high) 63 | v = sample_vector(v_low, v_high) 64 | 65 | return ssapy.Orbit(r, v, t) 66 | 67 | 68 | def sample_GEO_orbit(t, r_low=4e7, r_high=5e7, v_low=2.7e3, v_high=3.3e3): 69 | """ 70 | Returns a ssapy.Orbit object with a distance near RGEO and a velocity near VGEO. 71 | """ 72 | return sample_orbit(t, r_low, r_high, v_low, v_high) 73 | 74 | 75 | def sample_LEO_orbit(t, r_low=7e6, r_high=1e7, v_low=7.7e3, v_high=7.9e3): 76 | """ 77 | Returns a ssapy.Orbit object with a position near LEO and a velocity near VLEO. 78 | """ 79 | return sample_orbit(t, r_low, r_high, v_low, v_high) 80 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # SSAPy Community Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the SSAPy project or its community. Examples of representing the project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of the project may be further defined and clarified by SSAPy maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the LLNL GitHub Admins at github-admin@llnl.gov. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project or organization's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/) ([version 1.4](http://contributor-covenant.org/version/1/4)). 44 | -------------------------------------------------------------------------------- /ssapy/ellipsoid.py: -------------------------------------------------------------------------------- 1 | """ 2 | Class to handle transformations between ECEF x,y,z coords and geodetic 3 | longitude, latitude, and height. 4 | 5 | Technically, only handles a one-axis ellipsoid, defined via a flattening 6 | parameter f, but that's good enough for simple Earth models. 7 | """ 8 | 9 | import numpy as np 10 | from .utils import continueClass 11 | from . import _ssapy 12 | from ._ssapy import Ellipsoid 13 | 14 | 15 | @continueClass 16 | class Ellipsoid: 17 | """ 18 | A class representing an ellipsoid, providing methods to convert between spherical and Cartesian coordinates. 19 | 20 | Methods 21 | ------- 22 | sphereToCart(lon, lat, height) 23 | Converts spherical coordinates (longitude, latitude, height) to Cartesian coordinates (x, y, z). 24 | 25 | cartToSphere(x, y, z) 26 | Converts Cartesian coordinates (x, y, z) to spherical coordinates (longitude, latitude, height). 27 | """ 28 | 29 | def sphereToCart(self, lon, lat, height): 30 | """ 31 | Converts spherical coordinates to Cartesian coordinates. 32 | 33 | Parameters 34 | ---------- 35 | lon : array-like 36 | Longitude values in radians. 37 | lat : array-like 38 | Latitude values in radians. 39 | height : array-like 40 | Height above the ellipsoid surface. 41 | 42 | Returns 43 | ------- 44 | tuple of array-like 45 | A tuple containing: 46 | - x : array-like 47 | Cartesian x-coordinate. 48 | - y : array-like 49 | Cartesian y-coordinate. 50 | - z : array-like 51 | Cartesian z-coordinate. 52 | 53 | Notes 54 | ----- 55 | This method uses broadcasting to handle inputs of varying shapes and ensures 56 | contiguous arrays for efficient computation. 57 | """ 58 | 59 | lon, lat, height = np.broadcast_arrays(lon, lat, height) 60 | lon = np.ascontiguousarray(lon) 61 | lat = np.ascontiguousarray(lat) 62 | height = np.ascontiguousarray(height) 63 | 64 | x = np.empty_like(lon) 65 | y = np.empty_like(lon) 66 | z = np.empty_like(lon) 67 | 68 | self._sphereToCart( 69 | lon.ctypes.data, lat.ctypes.data, height.ctypes.data, lon.size, 70 | x.ctypes.data, y.ctypes.data, z.ctypes.data 71 | ) 72 | 73 | return x, y, z 74 | 75 | def cartToSphere(self, x, y, z): 76 | """ 77 | Converts Cartesian coordinates to spherical coordinates. 78 | 79 | Parameters 80 | ---------- 81 | x : array-like 82 | Cartesian x-coordinate. 83 | y : array-like 84 | Cartesian y-coordinate. 85 | z : array-like 86 | Cartesian z-coordinate. 87 | 88 | Returns 89 | ------- 90 | tuple of array-like 91 | A tuple containing: 92 | - lon : array-like 93 | Longitude values in radians. 94 | - lat : array-like 95 | Latitude values in radians. 96 | - height : array-like 97 | Height above the ellipsoid surface. 98 | 99 | Notes 100 | ----- 101 | This method uses broadcasting to handle inputs of varying shapes and ensures 102 | contiguous arrays for efficient computation. 103 | """ 104 | 105 | x, y, z = np.broadcast_arrays(x, y, z) 106 | x = np.ascontiguousarray(x) 107 | y = np.ascontiguousarray(y) 108 | z = np.ascontiguousarray(z) 109 | 110 | lon = np.empty_like(x) 111 | lat = np.empty_like(x) 112 | height = np.empty_like(x) 113 | 114 | self._cartToSphere( 115 | x.ctypes.data, y.ctypes.data, z.ctypes.data, x.size, 116 | lon.ctypes.data, lat.ctypes.data, height.ctypes.data 117 | ) 118 | 119 | return lon, lat, height 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /tests/test_particles.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.time import Time 3 | import astropy.units as u 4 | from astropy.coordinates import Longitude, Latitude 5 | from astropy.table import QTable 6 | import pytest 7 | 8 | import ssapy 9 | from ssapy.constants import RGEO, VGEO 10 | from ssapy.particles import Particles 11 | from ssapy.rvsampler import RPrior, APrior, GaussianRVInitializer 12 | from ssapy.utils import cluster_emcee_walkers 13 | 14 | # ------------------------------------------ 15 | # Helpers 16 | # ------------------------------------------ 17 | def random_vector(min_mag, max_mag): 18 | vec = np.random.normal(size=3) 19 | vec /= np.linalg.norm(vec) 20 | magnitude = np.random.uniform(min_mag, max_mag) 21 | return vec * magnitude 22 | 23 | @pytest.fixture 24 | def prepared_particles(): 25 | np.random.seed(42) 26 | time = Time(2458316., format='jd') 27 | observer = ssapy.EarthObserver(100.0, 33.0, 1300.0) 28 | 29 | r = random_vector(RGEO * 0.9, RGEO * 1.1) 30 | v = random_vector(VGEO * 0.9, VGEO * 1.1) 31 | orbit = ssapy.Orbit(r, v, time) 32 | times = time + np.linspace(0, 10, 5) * u.h 33 | coord = ssapy.radec(orbit, times, observer=observer) 34 | r_station, v_station = observer.getRV(times) 35 | 36 | arc = QTable() 37 | arc['ra'] = Longitude(coord[0] * u.rad) 38 | arc['dec'] = Latitude(coord[1] * u.rad) 39 | arc['rStation_GCRF'] = r_station * u.m 40 | arc['vStation_GCRF'] = v_station * u.m / u.s 41 | arc['time'] = Time(times) 42 | arc['sigma'] = np.ones(5) * u.arcsec 43 | 44 | priors = [RPrior(RGEO, RGEO * 0.2), APrior(RGEO, RGEO * 0.2)] 45 | initializer = GaussianRVInitializer(r, v, rSigma=0.1 * RGEO, vSigma=0.1 * VGEO) 46 | rvprob = ssapy.RVProbability(arc, time, priors=priors) 47 | sampler = ssapy.EmceeSampler(rvprob, initializer, nWalker=30) 48 | chain, lnprob, lnprior = sampler.sample(nBurn=100, nStep=10) 49 | chain, lnprob, lnprior = cluster_emcee_walkers(chain, lnprob, lnprior) 50 | 51 | return Particles(chain, rvprob, lnpriors=lnprior), rvprob 52 | 53 | # ------------------------------------------ 54 | # Tests 55 | # ------------------------------------------ 56 | 57 | def test_repr(prepared_particles): 58 | particles, _ = prepared_particles 59 | rep = repr(particles) 60 | assert "Particles(r=" in rep 61 | 62 | 63 | def test_epoch_property(prepared_particles): 64 | particles, rvprob = prepared_particles 65 | assert particles.epoch == rvprob.epoch 66 | 67 | 68 | def test_orbits_and_lnpriors_lazy(prepared_particles): 69 | particles, _ = prepared_particles 70 | assert isinstance(particles.orbits, list) 71 | assert particles._orbits is not None 72 | assert isinstance(particles.lnpriors, np.ndarray) 73 | assert particles._lnpriors is not None 74 | 75 | 76 | def test_lnlike_shape(prepared_particles): 77 | particles, _ = prepared_particles 78 | lnL = particles.lnlike(particles.orbits) 79 | assert lnL.shape == (particles.num_particles,) 80 | 81 | 82 | def test_draw_orbit(prepared_particles): 83 | particles, _ = prepared_particles 84 | orbit = particles.draw_orbit() 85 | assert len(orbit) == 1 86 | assert hasattr(orbit[0], 'r') and hasattr(orbit[0], 'v') 87 | 88 | 89 | def test_fuse_and_reweight(prepared_particles): 90 | p0, _ = prepared_particles 91 | p1, _ = prepared_particles 92 | old_particles = p0.particles.copy() 93 | p0.fuse(p1) 94 | assert p0.particles.shape[1] == 6 95 | assert not np.allclose(p0.particles, old_particles) 96 | 97 | 98 | def test_fuse_verbose(prepared_particles, capsys): 99 | p0, _ = prepared_particles 100 | p1, _ = prepared_particles 101 | p0.fuse(p1, verbose=True) 102 | out = capsys.readouterr().out 103 | # Allow verbose to print something under some edge cases 104 | assert isinstance(out, str) 105 | 106 | 107 | def test_resample(prepared_particles): 108 | particles, _ = prepared_particles 109 | initial_count = particles.particles.shape[0] 110 | particles.resample(num_particles=initial_count) 111 | assert particles.particles.shape[0] == initial_count 112 | 113 | 114 | 115 | def test_mean(prepared_particles): 116 | particles, _ = prepared_particles 117 | m = particles.mean() 118 | assert m.shape == (6,) 119 | 120 | def test_reweight_fails(prepared_particles, monkeypatch): 121 | p0, _ = prepared_particles 122 | p1, _ = prepared_particles 123 | 124 | # Monkeypatch lnlike to always return large negative values 125 | monkeypatch.setattr(p0.rvprobability, "lnlike", lambda orbit: -1e50) 126 | result = p0.reweight(p1) 127 | assert result is False 128 | -------------------------------------------------------------------------------- /devel/angles_to_orbits.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Angles to Orbits\n", 8 | "\n", 9 | "Explore the computations to convert angles-only measurements to orbit determinations as used in Stooker." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 13, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import numpy as np\n", 19 | "import matplotlib.pyplot as plt\n", 20 | "import astropy.units as u\n", 21 | "\n", 22 | "import ssa.planter.planter\n", 23 | "import ssa.planter.streaksim as streaksim" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": 4, 29 | "metadata": {}, 30 | "outputs": [ 31 | { 32 | "name": "stdout", 33 | "output_type": "stream", 34 | "text": [ 35 | "start time (sec): 212129064000.0 s\n", 36 | "exposure time (sec): 10.0 s\n", 37 | "RA (deg.): 0.0 deg\n", 38 | "DEC (deg.): 0.0 deg\n", 39 | "RA (deg.): 0.0426272094227916 deg\n", 40 | "DEC (deg.): 0.0 deg\n", 41 | "x0: 76.729, y0: 0.000, L: 153 (arcsec), phi0: 0.000 (deg.)\n", 42 | "x0: 0.021313604711395796 deg , y0: 0.0 deg\n" 43 | ] 44 | } 45 | ], 46 | "source": [ 47 | "gsm = ssa.planter.planter.simulate_streak_from_orbit()" 48 | ] 49 | }, 50 | { 51 | "cell_type": "code", 52 | "execution_count": 5, 53 | "metadata": {}, 54 | "outputs": [ 55 | { 56 | "data": { 57 | "text/plain": [ 58 | " 76.72897696102487 pix 0.0 pix 0.0 rad 100.0 adu / pix2 153.45795392204974 pix 2.52 pix" 59 | ] 60 | }, 61 | "execution_count": 5, 62 | "metadata": {}, 63 | "output_type": "execute_result" 64 | } 65 | ], 66 | "source": [ 67 | "gsm" 68 | ] 69 | }, 70 | { 71 | "cell_type": "code", 72 | "execution_count": 16, 73 | "metadata": {}, 74 | "outputs": [ 75 | { 76 | "name": "stdout", 77 | "output_type": "stream", 78 | "text": [ 79 | "76.72897696102487 pix 0.0 pix\n", 80 | "76.72897696102487 pix\n", 81 | "153.45795392204974 pix 0.0 pix\n" 82 | ] 83 | } 84 | ], 85 | "source": [ 86 | "dra = (gsm.L * np.cos(gsm.phi0) / 2.).to(u.pix)\n", 87 | "ddec = (gsm.L * np.sin(gsm.phi0) / 2.).to(u.pix)\n", 88 | "print(dra, ddec)\n", 89 | "print(gsm.x0)\n", 90 | "print(gsm.x0+dra, gsm.x0-dra)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 18, 96 | "metadata": {}, 97 | "outputs": [ 98 | { 99 | "name": "stdout", 100 | "output_type": "stream", 101 | "text": [ 102 | "[[153.45795392 0. ]\n", 103 | " [ 0. 0. ]] pix\n", 104 | "[[76.72897696 0. ]\n", 105 | " [ 0. 0. ]] arcsec\n" 106 | ] 107 | } 108 | ], 109 | "source": [ 110 | "endpoints = gsm.endpoints_ang_pos()\n", 111 | "print(endpoints)\n", 112 | "\n", 113 | "pixel_scale = 0.5 * u.arcsec / u.pix\n", 114 | "endpoints *= pixel_scale\n", 115 | "print(endpoints)" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 21, 121 | "metadata": {}, 122 | "outputs": [], 123 | "source": [ 124 | "x0_arcsec, y0_arcsec, L_arcsec, phi0 = streaksim.gaussian_streak_params_from_endpoints(\n", 125 | " endpoints[0,0], endpoints[0,1],\n", 126 | " endpoints[1,0], endpoints[1,1])\n", 127 | "phi0 = (phi0.value % np.pi) * u.rad" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": 22, 133 | "metadata": {}, 134 | "outputs": [ 135 | { 136 | "name": "stdout", 137 | "output_type": "stream", 138 | "text": [ 139 | "38.364488480512435 arcsec 0.0 arcsec 76.72897696102487 arcsec 0.0 rad\n" 140 | ] 141 | } 142 | ], 143 | "source": [ 144 | "print(x0_arcsec, y0_arcsec, L_arcsec, phi0)" 145 | ] 146 | }, 147 | { 148 | "cell_type": "code", 149 | "execution_count": null, 150 | "metadata": {}, 151 | "outputs": [], 152 | "source": [] 153 | } 154 | ], 155 | "metadata": { 156 | "kernelspec": { 157 | "display_name": "Python 3", 158 | "language": "python", 159 | "name": "python3" 160 | }, 161 | "language_info": { 162 | "codemirror_mode": { 163 | "name": "ipython", 164 | "version": 3 165 | }, 166 | "file_extension": ".py", 167 | "mimetype": "text/x-python", 168 | "name": "python", 169 | "nbconvert_exporter": "python", 170 | "pygments_lexer": "ipython3", 171 | "version": "3.7.0" 172 | } 173 | }, 174 | "nbformat": 4, 175 | "nbformat_minor": 2 176 | } 177 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | # If you are referencing SSAPy in a publication, please use the DOI 2 | # described here. 3 | # 4 | # MLA: 5 | # 6 | # 7 | # Schlafly, Edward, Yeager, Travis, Pruett, Kerianne, Schneider, Michael, Ebert, Julia, Merl, Daniel, Lifset, Noah, 8 | # Armstrong, Robert, Dawson, William, Meyers, Joshua, Perloff, Alexx, and Golovich, Nathan. 9 | # Space Situational Awareness for Python. Computer Software. https://github.com/LLNL/SSAPy. 10 | # USDOE National Nuclear Security Administration (NNSA). 08 Nov. 2023. Web. doi:10.11578/dc.20240417.1. 11 | # 12 | # APA: 13 | # 14 | # Schlafly, Edward, Yeager, Travis, Pruett, Kerianne, Schneider, Michael, Ebert, Julia, Merl, Daniel, Lifset, Noah, 15 | # Armstrong, Robert, Dawson, William, Meyers, Joshua, Perloff, Alexx, & Golovich, Nathan. (2023, November 08). 16 | # Space Situational Awareness for Python. [Computer software]. https://github.com/LLNL/SSAPy. 17 | # https://doi.org/10.11578/dc.20240417.1. 18 | # 19 | # Chicago: 20 | # 21 | # Schlafly, Edward, Yeager, Travis, Pruett, Kerianne, Schneider, Michael, Ebert, Julia, Merl, Daniel, Lifset, Noah, 22 | # Armstrong, Robert, Dawson, William, Meyers, Joshua, Perloff, Alexx, and Golovich, Nathan. 23 | # "Space Situational Awareness for Python." Computer software. November 08, 2023. https://github.com/LLNL/SSAPy. 24 | # https://doi.org/10.11578/dc.20240417.1. 25 | # 26 | # Or, in BibTeX: 27 | # 28 | # @misc{ doecode_126106, 29 | # title = {Space Situational Awareness for Python}, 30 | # author = {Schlafly, Edward and Yeager, Travis and Pruett, Kerianne and Schneider, Michael and Ebert, Julia and Merl, 31 | # Daniel and Lifset, Noah and Armstrong, Robert and Dawson, William and Meyers, Joshua and Perloff, Alexx and 32 | # Golovich, Nathan}, 33 | # abstractNote = {SSAPy is a python package allowing for fast and precise orbital modeling. SSAPy is designed with 34 | # speed and accuracy in mind and offers the following capabilities: - A variety of integrators, 35 | # including Runge-Kutta, SciPy, SGP4, etc. - Customizable force propagation models, including a variety 36 | # of Earth gravity models, lunar gravity, radiation pressure, etc. - Multiple-hypothesis tracking (MHT) 37 | # UCT linker - Vectorized computations - Short arc probabilistic orbit determination - Conjunction 38 | # probability estimation - Uncertainty quantification - Monte Carlo data fusion - Support for multiple 39 | # coordinate frames (with coordinate frame conversions)}, 40 | # doi = {10.11578/dc.20240417.1}, 41 | # url = {https://doi.org/10.11578/dc.20240417.1}, 42 | # howpublished = {[Computer Software] \url{https://doi.org/10.11578/dc.20240417.1}}, 43 | # year = {2023}, 44 | # month = {nov} 45 | # } 46 | # 47 | # And here's the CITATION.cff format: 48 | # 49 | cff-version: 1.2.0 50 | message: "If you use this software, please cite it as below." 51 | authors: 52 | - family-names: "Schlafly" 53 | given-names: "Edward" 54 | affiliation: "Space Telescope Science Institute" 55 | orcid: "https://orcid.org/0000-0002-3569-7421" 56 | email: "eschlafly@stsci.edu" 57 | - family-names: "Yeager" 58 | given-names: "Travis" 59 | affiliation: "Lawrence Livermore National Laboratory" 60 | orcid: "https://orcid.org/0000-0002-2582-0190" 61 | email: "yeager7@llnl.gov" 62 | - family-names: "Pruett" 63 | given-names: "Kerianne" 64 | affiliation: "Lawrence Livermore National Laboratory" 65 | orcid: "https://orcid.org/0000-0002-2911-8657" 66 | email: "pruett6@llnl.gov" 67 | - family-names: "Schneider" 68 | given-names: "Michael" 69 | affiliation: "Lawrence Livermore National Laboratory" 70 | orcid: "https://orcid.org/0000-0002-8505-7094" 71 | email: "schneider42@llnl.gov" 72 | - family-names: "Ebert" 73 | given-names: "Julia" 74 | affiliation: "Fleet Robotics" 75 | orcid: "https://orcid.org/0000-0002-1975-772X" 76 | email: "julia@juliaebert.com" 77 | - family-names: "Merl" 78 | given-names: "Daniel" 79 | affiliation: "Lawrence Livermore National Laboratory" 80 | orcid: "https://orcid.org/0000-0003-4196-5354" 81 | email: "merl1@llnl.gov" 82 | - family-names: "Lifset" 83 | given-names: "Noah" 84 | affiliation: "The University of Texas at Austin" 85 | orcid: "https://orcid.org/0000-0003-3397-7021" 86 | email: "" 87 | - family-names: "Armstrong" 88 | given-names: "Robert" 89 | affiliation: "Lawrence Livermore National Laboratory" 90 | orcid: "https://orcid.org/0000-0002-6911-1038" 91 | email: "armstrong46@llnl.gov" 92 | - family-names: "Dawson" 93 | given-names: "William" 94 | affiliation: "Lawrence Livermore National Laboratory" 95 | orcid: "https://orcid.org/0000-0003-0248-6123" 96 | email: "dawson29@llnl.gov" 97 | - family-names: "Meyers" 98 | given-names: "Joshua" 99 | affiliation: "SLAC National Accelerator Laboratory" 100 | orcid: "https://orcid.org/0000-0002-2308-4230" 101 | email: "jmeyers3@stanford.edu" 102 | - family-names: "Perloff" 103 | given-names: "Alexx" 104 | affiliation: "Lawrence Livermore National Laboratory" 105 | orcid: "https://orcid.org/0000-0001-5230-0396" 106 | email: "perloff11@llnl.gov" 107 | - family-names: "Golovich" 108 | given-names: "Nathan" 109 | affiliation: "Lawrence Livermore National Laboratory" 110 | orcid: "https://orcid.org/0000-0003-2632-572X" 111 | email: "golovich1@llnl.gov" 112 | title: "Space Situational Awareness for Python" 113 | version: 1.0 114 | doi: 10.11578/dc.20240417.1 115 | date-released: 2023-11-08 116 | url: "https://github.com/LLNL/SSAPy" 117 | -------------------------------------------------------------------------------- /tests/test_plotutils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import os 3 | import matplotlib.pyplot as plt 4 | import pytest 5 | from astropy.time import Time 6 | from PIL import Image as PILImage 7 | 8 | from ssapy.plotUtils import ( 9 | load_earth_file, draw_earth, load_moon_file, draw_moon, 10 | ground_track_plot, save_plot, groundTrackVideo, 11 | globe_plot, koe_plot, koe_hist_2d, orbit_plot, 12 | scatter_2d, scatter_3d, scatter_dot_colors_scaled, 13 | check_numpy_array, check_type, draw_dashed_circle, 14 | format_date_axis, save_plot_to_pdf, set_color_theme 15 | ) 16 | from ssapy.utils import find_file 17 | 18 | @pytest.fixture 19 | def dummy_r(): 20 | return np.random.rand(100, 3) * 1e7 21 | 22 | @pytest.fixture 23 | def dummy_t(): 24 | return Time("2000-01-01") + np.linspace(0, 1, 100) * 86400 25 | 26 | def test_load_earth_file_patch(): 27 | result = load_earth_file() 28 | assert isinstance(result, PILImage.Image) 29 | assert result.size == (1080, 540) 30 | 31 | def test_load_moon_file_patch(): 32 | result = load_moon_file() 33 | assert isinstance(result, PILImage.Image) 34 | assert result.size == (1080, 540) 35 | 36 | 37 | def test_check_numpy_array_behavior(): 38 | assert check_numpy_array(np.array([1, 2, 3])) == "numpy array" 39 | assert check_numpy_array([np.array([1]), np.array([2])]) == "list of numpy array" 40 | assert check_numpy_array([1, 2, 3]) == "not numpy" 41 | assert check_numpy_array([]) == "not numpy" 42 | assert check_numpy_array("string") == "not numpy" 43 | assert check_numpy_array([np.array([1]), 2]) == "not numpy" 44 | assert check_numpy_array(["string", 123]) == "not numpy" 45 | assert check_numpy_array([[np.array([1])]]) == "not numpy" 46 | 47 | def test_check_type_behavior(): 48 | assert check_type(None) is None 49 | assert check_type([np.array([1]), np.array([2])]) == "List of arrays" 50 | assert check_type([1, 2, 3]) == "List of non-arrays" 51 | assert check_type(np.array([1, 2])) == "Single array or list" 52 | assert check_type(Time("2023-01-01")) == "Single array or list" 53 | assert check_type([np.array([1]), 2]) == "List of non-arrays" 54 | assert check_type([]) == "List of arrays" 55 | assert check_type([[np.array([1])]]) == "List of arrays" 56 | assert check_type("string") == "Not a list or array" 57 | assert check_type(123) == "Not a list or array" 58 | 59 | def test_orbit_plot_basic(dummy_r, dummy_t): 60 | fig, axes = orbit_plot(dummy_r, t=dummy_t, frame='gcrf', show=False) 61 | assert isinstance(fig, plt.Figure) 62 | assert isinstance(axes, list) 63 | assert all(hasattr(ax, 'plot') for ax in axes) 64 | 65 | def test_scatter_dot_colors_scaled_shape(): 66 | assert scatter_dot_colors_scaled(0).shape == (0, 4) 67 | assert scatter_dot_colors_scaled(1).shape == (1, 4) 68 | assert scatter_dot_colors_scaled(10).shape == (10, 4) 69 | 70 | def test_set_color_theme_variants(): 71 | fig, ax = plt.subplots() 72 | fig, _ = set_color_theme(fig, ax, theme="black") 73 | assert ax.xaxis.label.get_color() == "white" 74 | 75 | fig, ax = plt.subplots() 76 | fig, _ = set_color_theme(fig, ax, theme="white") 77 | assert ax.xaxis.label.get_color() == "black" 78 | 79 | fig, ax = plt.subplots() 80 | fig, _ = set_color_theme(fig, ax, theme="dark") 81 | assert ax.xaxis.label.get_color() == "white" 82 | 83 | def test_draw_dashed_circle(): 84 | fig = plt.figure() 85 | ax = fig.add_subplot(111, projection="3d") 86 | draw_dashed_circle(ax, np.array([0, 0, 1]), radius=1.0, dashes=6) 87 | assert len(ax.lines) == 6 88 | 89 | def test_format_date_axis_formats_labels(): 90 | time_array = Time(['2024-07-01T00:00:00', '2024-07-01T06:00:00', '2024-07-01T12:00:00']) 91 | fig, ax = plt.subplots() 92 | ax.plot(time_array.decimalyear, np.random.rand(len(time_array))) 93 | format_date_axis(time_array, ax) 94 | xticklabels = [label.get_text() for label in ax.get_xticklabels()] 95 | assert any(":" in label for label in xticklabels) 96 | 97 | def test_save_plot_creates_file(tmp_path): 98 | fig, _ = plt.subplots() 99 | save_path = tmp_path / "fig_test.png" 100 | save_plot(fig, str(save_path)) 101 | assert save_path.exists() 102 | 103 | def test_save_plot_to_pdf_creates_file(tmp_path): 104 | fig, _ = plt.subplots() 105 | save_path = tmp_path / "plot.pdf" 106 | save_plot_to_pdf(fig, str(save_path)) 107 | assert save_path.exists() 108 | 109 | @pytest.fixture 110 | def dummy_ground_data(tmp_path): 111 | r = np.random.rand(100, 3) * 1e7 112 | t = Time("2025-01-01") + np.linspace(0, 1, 100) * 86400 113 | img = PILImage.new("RGB", (5400, 2700), color="blue") 114 | path = tmp_path / "earth.png" 115 | img.save(path) 116 | return r, t, str(path) 117 | 118 | def test_ground_track_plot_no_mock(dummy_ground_data, tmp_path): 119 | r, t, img_path = dummy_ground_data 120 | 121 | save_path = tmp_path / "ground_track.png" 122 | ground_track_plot(r, t, save_path=str(save_path)) 123 | 124 | assert save_path.exists() 125 | 126 | class MockStableData: 127 | def __init__(self): 128 | self.a = np.random.uniform(1 * 6371e3, 18 * 6371e3, 1000) 129 | self.e = np.random.uniform(0, 1, 1000) 130 | self.i = np.radians(np.random.uniform(0, 90, 1000)) 131 | self.ta = np.radians(np.random.uniform(0, 360, 1000)) 132 | 133 | def test_koe_hist_2d_basic(monkeypatch): 134 | monkeypatch.setattr("ssapy.plotUtils.set_color_theme", lambda fig, ax, theme='black': (fig, ax)) 135 | data = MockStableData() 136 | fig = koe_hist_2d(data) 137 | assert isinstance(fig, plt.Figure) 138 | -------------------------------------------------------------------------------- /tests/test_tracks.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.time import Time 3 | from astropy import units as u 4 | import math 5 | from functools import partial 6 | import pytest 7 | 8 | import ssapy 9 | from ssapy.constants import EARTH_MU, RGEO 10 | from ssapy.correlate_tracks import ( 11 | CircVelocityPrior, ZeroRadialVelocityPrior, GaussPrior, VolumeDistancePrior, 12 | orbit_to_param, make_param_guess, make_optimizer, fit_arc_blind, fit_arc, 13 | fit_arc_with_gaussian_prior, data_for_satellite, wrap_angle_difference, 14 | radeczn, param_to_orbit, Track, TrackGauss,TrackBase, MHT, summarize_tracklet, 15 | summarize_tracklets, iterate_mht, fit_arc_blind_via_track, Hypothesis, time_ordered_satIDs 16 | ) 17 | from ssapy.orbit import Orbit 18 | from ssapy import propagator, rvsampler 19 | from .ssapy_test_helpers import sample_GEO_orbit, sample_LEO_orbit, checkAngle, checkSphere 20 | 21 | @pytest.fixture 22 | def sample_data(): 23 | dtype = [ 24 | ('satID', 'int'), 25 | ('time', 'O'), # Object type to hold astropy Time instances 26 | ('rStation_GCRF', 'float', (3,)), 27 | ('vStation_GCRF', 'float', (3,)) 28 | ] 29 | data = np.zeros(10, dtype=dtype) 30 | data['satID'] = [2, 3, 1, 5, 4, 3, 2, 1, 5, 4] 31 | 32 | # Generate Time objects based on a reference GPS time 33 | times = np.linspace(0, 100, 10) 34 | data['time'] = [Time(t, format='gps') for t in times] 35 | 36 | data['rStation_GCRF'] = np.random.rand(10, 3) 37 | data['vStation_GCRF'] = np.random.rand(10, 3) 38 | return data 39 | 40 | @pytest.fixture 41 | def sample_arc(): 42 | dtype = [('satID', 'int'), ('rStation_GCRF', 'float', (3,)), ('vStation_GCRF', 'float', (3,)), 43 | ('time', 'float'), ('ra', 'float'), ('dec', 'float'), ('pmra', 'float'), ('pmdec', 'float')] 44 | arc = np.zeros(10, dtype=dtype) 45 | arc['satID'] = np.arange(10) 46 | arc['rStation_GCRF'] = np.random.rand(10, 3) 47 | arc['vStation_GCRF'] = np.random.rand(10, 3) 48 | arc['time'] = np.linspace(0, 100, 10) 49 | arc['ra'] = np.random.rand(10) 50 | arc['dec'] = np.random.rand(10) 51 | arc['pmra'] = np.random.rand(10) 52 | arc['pmdec'] = np.random.rand(10) 53 | return arc 54 | 55 | @pytest.fixture 56 | def sample_guess(): 57 | return np.array([1, 2, 3, 4, 5, 6, 123456789]) 58 | 59 | @pytest.fixture 60 | def sample_gaussian_prior(): 61 | mu = np.array([1, 2, 3, 4, 5, 6, 123456789]) 62 | cinvcholfac = np.eye(6) 63 | return mu, cinvcholfac 64 | 65 | @pytest.fixture 66 | def sample_propagator(): 67 | return propagator.KeplerianPropagator() 68 | 69 | @pytest.fixture 70 | def sample_truth(): 71 | return {1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E'} 72 | 73 | @pytest.fixture 74 | def sample_hypotheses(): 75 | return [Hypothesis([], nsat=1000)] 76 | 77 | @pytest.fixture 78 | def mht_instance(sample_data, sample_truth, sample_hypotheses, sample_propagator): 79 | return MHT(data=sample_data, nsat=1000, truth=sample_truth, 80 | hypotheses=sample_hypotheses, propagator=sample_propagator) 81 | 82 | @pytest.mark.parametrize("mode, expected_cls", [ 83 | ('rv', rvsampler.LMOptimizer), 84 | ('equinoctial', rvsampler.EquinoctialLMOptimizer), 85 | ]) 86 | 87 | def test_make_optimizer_modes(mode, expected_cls): 88 | param = list(range(9)) 89 | optimizer = make_optimizer(mode=mode, param=param, lsq=False) 90 | assert optimizer == expected_cls if not isinstance(optimizer, partial) else optimizer.func == expected_cls 91 | 92 | @pytest.mark.parametrize("mode", ['invalid', None]) 93 | def test_make_optimizer_invalid_mode(mode): 94 | with pytest.raises(ValueError): 95 | make_optimizer(mode=mode, param=[1]*9, lsq=False) 96 | 97 | 98 | @pytest.mark.parametrize("mode", ['rv', 'equinoctial']) 99 | def test_orbit_to_param_and_back(mode): 100 | original = sample_GEO_orbit(t=1000) 101 | params = orbit_to_param(original, mode=mode) 102 | recovered = param_to_orbit(params, mode=mode) 103 | np.testing.assert_allclose(recovered.r, original.r, atol=1e-6) 104 | np.testing.assert_allclose(recovered.v, original.v, atol=1e-6) 105 | 106 | 107 | @pytest.mark.parametrize("input_angle, wrap_range, center, expected", [ 108 | (3, 2 * math.pi, 0.5, (3 + math.pi) % (2 * math.pi) - math.pi), 109 | (3, 360, 0.25, (3 + 0.25 * 360) % 360 - 0.25 * 360), 110 | (1000, 360, 0.5, (1000 + 0.5 * 360) % 360 - 0.5 * 360), 111 | ]) 112 | 113 | def test_wrap_angle_difference_values(input_angle, wrap_range, center, expected): 114 | result = wrap_angle_difference(input_angle, wrap_range, center=center) 115 | assert pytest.approx(result, rel=1e-6) == expected 116 | 117 | 118 | def test_data_for_satellite_behavior(sample_data): 119 | result = data_for_satellite(sample_data, [1, 3]) 120 | assert set(result['satID']) <= {1, 3} 121 | 122 | 123 | def test_circ_velocity_prior_properties(): 124 | prior = CircVelocityPrior(sigma=0.2) 125 | assert isinstance(prior, CircVelocityPrior) 126 | assert math.isclose(prior.sigma, 0.2) 127 | 128 | 129 | def test_zero_radial_velocity_prior_properties(): 130 | prior = ZeroRadialVelocityPrior(sigma=0.3) 131 | assert isinstance(prior, ZeroRadialVelocityPrior) 132 | assert math.isclose(prior.sigma, 0.3) 133 | 134 | 135 | def test_gauss_prior_properties(): 136 | mu = np.zeros(6) 137 | cinv = np.eye(6) 138 | translator = lambda o: np.ones(6) 139 | prior = GaussPrior(mu, cinv, translator) 140 | assert np.array_equal(prior.mu, mu) 141 | assert np.array_equal(prior.cinvcholfac, cinv) 142 | 143 | 144 | def test_volume_distance_prior_behavior(): 145 | prior = VolumeDistancePrior(scale=RGEO) 146 | orbit = sample_LEO_orbit(t=0) 147 | logprob = prior(orbit, 7000e3) 148 | assert isinstance(logprob, float) -------------------------------------------------------------------------------- /docs/source/examples.rst: -------------------------------------------------------------------------------- 1 | SSAPy by Example 2 | ================ 3 | 4 | .. code-block:: python 5 | 6 | from ssapy import * 7 | import numpy as np 8 | 9 | Set an initial astropy time object 10 | 11 | .. code-block:: python 12 | 13 | t0 = Time("2024-1-1") 14 | print(t0) 15 | 16 | .. code-block:: python 17 | 18 | 2024-01-01 00:00:00.000 19 | 20 | Get the position and velocity of the Moon. 21 | 22 | .. code-block:: python 23 | 24 | r_moon = get_body("moon").position(t0).T 25 | v_moon = (r_moon - get_body("moon").position(t0 + 1).T) / 2 26 | print(r_moon, v_moon) 27 | 28 | .. code-block:: python 29 | 30 | [-3.67980873e+08 1.42721025e+08 8.93144235e+07], [13379651.47831511 35015714.2905997 18263361.07635442] 31 | 32 | Get a starting position and velocity (statevector) for an orbit. This is a Lunar bound orbit. 33 | 34 | .. code-block:: python 35 | 36 | r0 = r_moon[0] + (1000e3 * r_moon[0] / np.linalg.norm(r_moon[0])) 37 | v0 = v_moon[0] + 100 38 | print(r0, v0) 39 | 40 | .. code-block:: python 41 | 42 | -368980873.0925871 13379751.478315115 43 | 44 | Initialize an orbit object. 45 | 46 | .. code-block:: python 47 | 48 | a = constants.RGEO 49 | e = 0 50 | i = np.radians(45) 51 | pa = np.radians(0) 52 | raan = np.radians(0) 53 | ta = np.radians(180) 54 | 55 | kElements = [a, e, i, pa, raan, ta] 56 | orbit = Orbit.fromKeplerianElements(*kElements, t=t0) 57 | 58 | Set parameters of the satellite 59 | 60 | .. code-block:: python 61 | 62 | sat_kwargs = dict( 63 | mass=100, # [kg] 64 | area=1, # [m^2] 65 | CD=2.3, # Drag coefficient 66 | CR=1.3, # Radiation pressure coefficient 67 | ) 68 | 69 | Build a propagator and set custom accelerations. 70 | 71 | .. code-block:: python 72 | 73 | moon = get_body("moon") 74 | sun = get_body("Sun") 75 | Mercury = get_body("Mercury") 76 | Venus = get_body("Venus") 77 | Earth = get_body("Earth", model="EGM2008") 78 | Mars = get_body("Mars") 79 | Jupiter = get_body("Jupiter") 80 | Saturn = get_body("Saturn") 81 | Uranus = get_body("Uranus") 82 | Neptune = get_body("Neptune") 83 | aEarth = AccelKepler() + AccelHarmonic(Earth, 140, 140) 84 | aSun = AccelThirdBody(sun) 85 | aMoon = AccelThirdBody(moon) + AccelHarmonic(moon, 20, 20) 86 | aSolRad = AccelSolRad(**sat_kwargs) 87 | aEarthRad = AccelEarthRad(**sat_kwargs) 88 | accel = aEarth + aMoon + aSun + aSolRad + aEarthRad 89 | prop = SciPyPropagator(accel) 90 | 91 | Build a time array to evaluate the orbit at 92 | 93 | .. code-block:: python 94 | 95 | times = utils.get_times(duration=(2, 'day'), freq=(1, 'minute'), t0=t0) 96 | r, v = rv(orbit=orbit, time=times, propagator=prop) 97 | 98 | Plot the output in a GCRF (star fixed frame) and lunar (a non-interial Earth-Moon fixed frame) 99 | 100 | .. code-block:: python 101 | 102 | plotUtils.orbit_plot(r, times, frame="gcrf", show=True) 103 | plotUtils.orbit_plot(r, times, frame="lunar", show=True) 104 | 105 | .. figure:: ./orbit_plot_1.png 106 | .. figure:: ./orbit_plot_2.png 107 | 108 | Lets see a ground track of the orbit. 109 | 110 | .. code-block:: python 111 | 112 | plotUtils.ground_track_plot(r, times) 113 | 114 | .. figure:: ./ground_track_plot.png 115 | 116 | Calculate the Lambertian Reflectance of the orbit 117 | 118 | .. code-block:: python 119 | 120 | mv = compute.M_v_lambertian(r, times) 121 | import matplotlib.pyplot as plt 122 | 123 | def decimal_to_datetime_label(d): 124 | year = int(d) 125 | rem = d - year 126 | is_leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) 127 | days_in_year = 366 if is_leap else 365 128 | total_seconds = rem * days_in_year * 24 * 3600 129 | 130 | day = int(total_seconds // (24 * 3600)) 131 | seconds_in_day = total_seconds % (24 * 3600) 132 | hour = int(seconds_in_day // 3600) 133 | minute = int((seconds_in_day % 3600) // 60) 134 | 135 | base_date = np.datetime64(f'{year}-01-01') + np.timedelta64(day, 'D') 136 | return f"{base_date} {hour:02d}:{minute:02d}" 137 | 138 | xticks = np.linspace(times.decimalyear[0], times.decimalyear[-1], 6) 139 | xtick_labels = [decimal_to_datetime_label(t) for t in xticks] 140 | 141 | plt.figure(dpi=300) 142 | plt.plot(times.decimalyear, mv) 143 | plt.xlabel("Date") 144 | plt.ylabel("Lambertian Reflectance [Apparent Magnitude]") 145 | plt.xticks(xticks, xtick_labels, rotation=45) 146 | plt.tight_layout() 147 | plt.show() 148 | 149 | .. figure:: ./reflectance_plot.png 150 | 151 | Plot the apparent magnitude of the orbit at each timestep: 152 | 153 | .. code-block:: python 154 | 155 | r_sun = get_body("sun").position(times).T 156 | r_earth = get_body("earth").position(times).T 157 | 158 | # Calculate the apparent magnitude at each timestep 159 | mags = compute.calc_M_v(r, r_sun, r_earth) 160 | 161 | RGEO = constants.RGEO 162 | moon = get_body("moon").position(times).T 163 | 164 | fig = plt.figure(figsize=(12, 12), layout='constrained') 165 | plt.rcParams.update({'font.size': 12}) 166 | ax = fig.add_subplot(projection='3d') 167 | 168 | x = r[:, 0] / RGEO 169 | y = r[:, 1] / RGEO 170 | z = r[:, 2] / RGEO 171 | 172 | # Plot orbit 173 | scatter = ax.scatter3D(x, y, z, c=mags, cmap='RdYlBu') 174 | 175 | cbar = fig.colorbar(scatter, ax=ax, shrink=0.6, aspect=20, pad=0.1, orientation='vertical') 176 | cbar.set_label('Vis Mag') 177 | cbar.ax.invert_yaxis() 178 | 179 | # Plot Earth 180 | ax.scatter3D(0, 0, 0, color='green', label='Earth', s=100) 181 | 182 | # Plot Moon 183 | ax.plot(moon[:, 0] / RGEO, moon[:, 1] / RGEO, moon[:, 2] / RGEO, color='gray', label='Moon', lw=6) 184 | 185 | ax.set_xlabel('X [GEO]') 186 | ax.set_ylabel('Y [GEO]') 187 | ax.set_zlabel('Z [GEO]') 188 | 189 | plt.legend() 190 | plt.show() 191 | 192 | .. figure:: ./magnitude_plot.png 193 | 194 | Plot the solar phase angle at each timestep: 195 | 196 | .. code-block:: python 197 | 198 | sun_angle=compute.get_angle(r_sun,r,r_earth) 199 | 200 | plt.scatter(sun_angle,mags) 201 | plt.xlabel('Solar Equatorial Phase Angle [rad]') 202 | plt.ylabel('Apparent Magnitude') 203 | plt.ylim(max(mags), min(mags)) 204 | plt.grid() 205 | plt.show() 206 | 207 | .. figure:: ./phase_angle.png 208 | -------------------------------------------------------------------------------- /ssapy/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | A collection of physical constants. 3 | 4 | .. data:: WGS84_EARTH_MU 5 | 6 | Earth gravitational constant from WGS84 model [m³/s²] 7 | 8 | .. data:: WGS72_EARTH_MU 9 | 10 | Earth gravitational constant from WGS72 model [m³/s²] 11 | 12 | .. data:: WGS84_EARTH_OMEGA 13 | 14 | Earth angular velocity from WGS84 model [rad/s] 15 | 16 | .. data:: WGS72_EARTH_OMEGA 17 | 18 | Earth angular velocity from WGS72 model [rad/s] 19 | 20 | .. data:: WGS84_EARTH_RADIUS 21 | 22 | Earth radius at equator from WGS84 model [m] 23 | 24 | .. data:: WGS72_EARTH_RADIUS 25 | 26 | Earth radius at equator from WGS72 model [m] 27 | 28 | .. data:: WGS84_EARTH_FLATTENING 29 | 30 | Earth flattening f = (a - b) / a where a and b are the major 31 | and minor axes respectively, from WGS84 model [unitless] 32 | 33 | .. data:: WGS72_EARTH_FLATTENING 34 | 35 | Earth flattening f = (a - b) / a where a and b are the major 36 | and minor axes respectively, from WGS72 model [unitless] 37 | 38 | .. data:: WGS84_EARTH_POLAR_RADIUS 39 | 40 | Earth polar radius, calculated by multiplying the equatorial 41 | radius by (1 - flattening), from WGS84 model [m] 42 | 43 | .. data:: WGS72_EARTH_POLAR_RADIUS 44 | 45 | Earth polar radius, calculated by multiplying the equatorial 46 | radius by (1 - flattening), from WGS72 model [m] 47 | 48 | .. data:: RGEO 49 | 50 | GEO-synchronous radius [m] 51 | 52 | .. data:: VGEO 53 | 54 | GEO-synchronous velocity [m/s] 55 | 56 | .. data:: RGEOALT 57 | 58 | GEO-synchronous altitude [m] 59 | 60 | .. data:: VLEO 61 | 62 | Approximate orbital velocity for low earth orbit (altitude of 500km) [m/s] 63 | 64 | .. data:: LD 65 | 66 | Lunar semi-major axis [m] 67 | 68 | Gravitational constants 69 | ----------------------- 70 | 71 | .. data:: SUN_MU 72 | 73 | Sun gravitational constant, from IAU 1976 model [m³/s²] 74 | 75 | .. data:: MOON_MU 76 | 77 | Moon gravitational constant, from the DE200 ephemeris [m³/s²] 78 | 79 | .. data:: MERCURY_MU 80 | 81 | Mercury gravitational constant [m³/s²] 82 | 83 | .. data:: VENUS_MU 84 | 85 | Venus gravitational constant [m³/s²] 86 | 87 | .. data:: EARTH_MU 88 | 89 | Earth gravitational constant, from WGS84 model [m³/s²] 90 | 91 | .. data:: MARS_MU 92 | 93 | Mars gravitational constant [m³/s²] 94 | 95 | .. data:: JUPITER_MU 96 | 97 | Jupiter gravitational constant [m³/s²] 98 | 99 | .. data:: SATURN_MU 100 | 101 | Saturn gravitational constant [m³/s²] 102 | 103 | .. data:: URANUS_MU 104 | 105 | Uranus gravitational constant [m³/s²] 106 | 107 | .. data:: NEPTUNE_MU 108 | 109 | Neptune gravitational constant [m³/s²] 110 | 111 | Mass 112 | ---- 113 | 114 | .. data:: SUN_MASS 115 | 116 | Sun mass, from the DE405 ephemeris [kg] 117 | 118 | .. data:: MOON_MASS 119 | 120 | Moon mass, from the DE405 ephemeris [kg] 121 | 122 | .. data:: MERCURY_MASS 123 | 124 | Mercury mass, from the DE405 ephemeris [kg] 125 | 126 | .. data:: VENUS_MASS 127 | 128 | Venus mass, from the DE405 ephemeris [kg] 129 | 130 | .. data:: EARTH_MASS 131 | 132 | Earth mass, from the DE405 ephemeris [kg] 133 | 134 | .. data:: MARS_MASS 135 | 136 | Mars mass, from the DE405 ephemeris [kg] 137 | 138 | .. data:: JUPITER_MASS 139 | 140 | Jupiter mass, from the DE405 ephemeris [kg] 141 | 142 | .. data:: SATURN_MASS 143 | 144 | Saturn mass, from the DE405 ephemeris [kg] 145 | 146 | .. data:: URANUS_MASS 147 | 148 | Uranus mass, from the DE405 ephemeris [kg] 149 | 150 | .. data:: NEPTUNE_MASS 151 | 152 | Neptune mass, from the DE405 ephemeris [kg] 153 | 154 | Radius 155 | ------ 156 | 157 | .. data:: MOON_RADIUS 158 | 159 | Moon radius (source: 10.2138/rmg.2006.60.3) [m] 160 | 161 | .. data:: MERCURY_RADIUS 162 | 163 | Mercury radius [m] 164 | 165 | .. data:: VENUS_RADIUS 166 | 167 | Venus radius [m] 168 | 169 | .. data:: EARTH_RADIUS 170 | 171 | Earth radius [m] 172 | 173 | .. data:: MARS_RADIUS 174 | 175 | Mars radius [m] 176 | 177 | .. data:: JUPITER_RADIUS 178 | 179 | Jupiter radius [m] 180 | 181 | .. data:: SATURN_RADIUS 182 | 183 | Saturn radius [m] 184 | 185 | .. data:: URANUS_RADIUS 186 | 187 | Uranus radius [m] 188 | 189 | .. data:: NEPTUNE_RADIUS 190 | 191 | Neptune radius [m] 192 | """ 193 | 194 | import numpy as np 195 | 196 | # GM 197 | WGS84_EARTH_MU = 3.986004418e14 # [m^3/s^2] 198 | WGS72_EARTH_MU = 3.986005e14 199 | # angular velocity 200 | WGS84_EARTH_OMEGA = 72.92115147e-6 # [rad/s] 201 | WGS72_EARTH_OMEGA = WGS84_EARTH_OMEGA 202 | # radius at equator 203 | WGS84_EARTH_RADIUS = 6.378137e6 # [m] 204 | WGS72_EARTH_RADIUS = 6.378135e6 # [m] 205 | 206 | # flattening f = (a-b)/a with a,b the major,minor axes 207 | WGS84_EARTH_FLATTENING = 1 / 298.257223563 208 | WGS72_EARTH_FLATTENING = 1 / 298.26 209 | # polar radius can be derived from above; [m] 210 | WGS84_EARTH_POLAR_RADIUS = WGS84_EARTH_RADIUS * (1 - WGS84_EARTH_FLATTENING) 211 | WGS72_EARTH_POLAR_RADIUS = WGS72_EARTH_RADIUS * (1 - WGS72_EARTH_FLATTENING) 212 | 213 | # GEO-sync radius and velocity are derived. 214 | RGEO = np.cbrt(WGS84_EARTH_MU / WGS84_EARTH_OMEGA**2) # [m] 215 | VGEO = RGEO * WGS84_EARTH_OMEGA # [m/s] 216 | RGEOALT = RGEO - WGS84_EARTH_RADIUS # [m] altitude of GEO 217 | # Rough value: 218 | VLEO = np.sqrt(WGS84_EARTH_MU / (WGS84_EARTH_RADIUS + 500e3)) # [m/s] 219 | 220 | # Note JGM3 values from Montenbruck & Gill code are 221 | # reference_radius = 6378.1363e3 222 | # earth_mu = 398600.4415e+9 223 | 224 | # VALUES FROM WIKI UNLESS STATED 225 | SUN_MU = 1.32712438e+20 # [m^3/s^2] IAU 1976 226 | MOON_MU = 398600.4415e+9 / 81.300587 # [m^3/s^2] DE200 227 | MERCURY_MU = 2.2032e13 228 | VENUS_MU = 3.24859e14 229 | EARTH_MU = WGS84_EARTH_MU 230 | MARS_MU = 4.282837e13 231 | JUPITER_MU = 1.26686534e17 232 | SATURN_MU = 3.7931187e16 233 | URANUS_MU = 5.793939e15 234 | NEPTUNE_MU = 6.836529e15 235 | 236 | # MASS [kg] Values from the DE405 ephemeris 237 | SUN_MASS = 1.98847e+30 238 | MOON_MASS = 7.348e22 239 | MERCURY_MASS = 3.301e23 240 | VENUS_MASS = 4.687e24 241 | EARTH_MASS = 5.9722e24 242 | MARS_MASS = 6.417e23 243 | JUPITER_MASS = 1.899e27 244 | SATURN_MASS = 5.685e26 245 | URANUS_MASS = 8.682e25 246 | NEPTUNE_MASS = 1.024e26 247 | 248 | # RADIUS - MEAN RADIUS FROM WIKI UNLESS STATED 249 | MOON_RADIUS = 1738.1e3 # 10.2138/rmg.2006.60.3 250 | MERCURY_RADIUS = 2439.4e3 251 | VENUS_RADIUS = 6052e3 252 | EARTH_RADIUS = WGS84_EARTH_RADIUS 253 | MARS_RADIUS = 3389.5e3 254 | JUPITER_RADIUS = 69911e3 255 | SATURN_RADIUS = 58232e3 256 | URANUS_RADIUS = 25362e3 257 | NEPTUNE_RADIUS = 24622e3 258 | 259 | # Distance from Earth to Moon 260 | LD = 384399000 # lunar semi-major axis in meters 261 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | permissions: 4 | contents: write 5 | on: 6 | # Run on pushes that modify this branch or tag the repo 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - v*.*.* 12 | # Run on pull requests that modify this branch/file 13 | pull_request: 14 | branches: 15 | - main 16 | # Let's also run the workflow on releases 17 | release: 18 | types: [published] 19 | 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.ref }} 22 | cancel-in-progress: true 23 | 24 | env: 25 | CI_PROJECT_DIR: ${{ github.workspace }} 26 | 27 | jobs: 28 | test: 29 | runs-on: ${{ matrix.os }} 30 | strategy: 31 | matrix: 32 | os: [ubuntu-latest, macOS-latest] # other options: windows-latest 33 | python-version: ["3.8", "3.12"] 34 | 35 | name: test SSAPy (${{ matrix.os }}) - python ${{ matrix.python-version }} 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | submodules: 'recursive' 41 | lfs: true 42 | token: ${{ github.token }} 43 | - name: Set up Python ${{ matrix.python-version }} 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install dependencies (Linux) 48 | if: matrix.os == 'ubuntu-latest' 49 | run: | 50 | # Install dependencies 51 | sudo apt-get update 52 | sudo apt-get install build-essential git git-lfs python3 python3-setuptools python3-venv graphviz cmake 53 | 54 | # Initialize and update submodules 55 | git submodule update --init --recursive 56 | 57 | # Install Git LFS and pull LFS objects 58 | git lfs install 59 | git lfs pull 60 | 61 | # Upgrade pip and install Python dependencies 62 | python -m pip install --upgrade pip setuptools flake8 63 | python -m pip install -r requirements.txt 64 | 65 | # Build and install the project 66 | rm -rf build dist *.egg-info 67 | python3 -m build 68 | pip install dist/*.whl 69 | mv ssapy ssapy_src_backup 70 | 71 | # List installed Python packages 72 | python -m pip list 73 | - name: Install dependencies (MacOS) 74 | if: matrix.os == 'macOS-latest' 75 | run: | 76 | # Update Homebrew; only install missing formulae (avoid tap conflicts) 77 | brew update 78 | brew list --formula git >/dev/null 2>&1 || brew install git 79 | brew list --formula cmake >/dev/null 2>&1 || brew install cmake 80 | brew list --formula git-lfs >/dev/null 2>&1 || brew install git-lfs 81 | 82 | # Make LFS usable in this headless environment 83 | git lfs install --skip-repo 84 | git config --global credential.helper "" # avoid Keychain prompts 85 | 86 | # Use the actions/setup-python toolchain consistently 87 | python -m pip install --upgrade pip setuptools flake8 88 | python -m pip install -r requirements.txt 89 | 90 | PYTHON_VERSION=$(python -c "import sys; print(f'cp{sys.version_info.major}{sys.version_info.minor}')") 91 | export ARCHFLAGS="-arch arm64" 92 | export CMAKE_OSX_ARCHITECTURES=arm64 93 | export CIBW_BUILD="${PYTHON_VERSION}-macosx_*" 94 | 95 | echo "ARCHFLAGS=$ARCHFLAGS" 96 | echo "CMAKE_OSX_ARCHITECTURES=$CMAKE_OSX_ARCHITECTURES" 97 | echo "CIBW_BUILD=$CIBW_BUILD" 98 | 99 | python -m pip install --upgrade cibuildwheel delocate 100 | python -m cibuildwheel --platform macos 101 | 102 | # Check wheel deps 103 | find wheelhouse -name '*.whl' -exec delocate-listdeps {} \; 104 | 105 | # Install the generated wheel into the SAME interpreter 106 | python -m pip install wheelhouse/*.whl 107 | 108 | # Hide source tree so imports resolve to the wheel 109 | mv ssapy ssapy_src_backup 110 | 111 | python -m pip list 112 | - name: Debug build artifacts 113 | run: | 114 | find . -name "*_ssapy*.so" 115 | #- name: Install dependencies (Windows) 116 | # if: matrix.os == 'windows-latest' 117 | # run: | 118 | # python -m pip install --upgrade pip setuptools 119 | # python -m pip install -r requirements.txt 120 | # # install checked out SSAPy 121 | # python setup.py build 122 | # python setup.py install 123 | # python -m pip list 124 | #- name: Lint code 125 | # if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 126 | # run: | 127 | # python3 --version 128 | # flake8 --version 129 | # flake8 ssapy/ tests/ devel/ 130 | - name: Install pytest plugins 131 | if: matrix.os == 'ubuntu-latest' 132 | run: | 133 | pip install pytest pytest-cov pytest-xdist 134 | - name: Test with pytest 135 | if: matrix.os == 'ubuntu-latest' 136 | run: | 137 | # Run pytest with coverage, using all available CPUs and verbose output 138 | pytest -v -n auto --cov-report=xml --cov=ssapy --durations=20 139 | - name: Upload coverage reports to Codecov 140 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' 141 | uses: codecov/codecov-action@v4 142 | with: 143 | token: ${{ secrets.CODECOV_TOKEN }} # Only needed for private repos 144 | slug: LLNL/SSAPy # Correct slug: owner/repo 145 | files: coverage.xml # Optional if coverage.xml is in root 146 | flags: unittests # Optional, helps categorize 147 | name: codecov-ubuntu-py312 # Optional, helps identify the upload 148 | - name: Debug Python environment 149 | run: python -c "import ssapy; print('ssapy imported successfully')" 150 | - name: Build documentation 151 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 152 | run: | 153 | pip install -r docs/requirements.txt 154 | 155 | # python3 ${CI_PROJECT_DIR}/ssapy/update_benchmarks.py 156 | cd docs 157 | # Ensure necessary directories exist 158 | mkdir -p source/modules source/_templates 159 | 160 | rm -rf build 161 | sphinx-build -b html source build/html 162 | sphinx-autogen source/api.rst -t source/_templates -o source/modules 163 | 164 | # Build the HTML docs once 165 | make html 166 | 167 | # Add .nojekyll for GitHub Pages 168 | touch _build/html/.nojekyll 169 | cd .. 170 | 171 | - name: Deploy documentation 172 | if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 173 | uses: crazy-max/ghaction-github-pages@v4 174 | with: 175 | target_branch: gh-pages 176 | build_dir: docs/_build/html 177 | env: 178 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 179 | 180 | pass: 181 | needs: [test] 182 | runs-on: ubuntu-latest 183 | steps: 184 | - run: echo "All jobs passed" 185 | -------------------------------------------------------------------------------- /tests/data/aeroB3_1.obs: -------------------------------------------------------------------------------- 1 | U9611851110010022044250038039 0702438 500 0 0 0 2 | U9611851110010022045750038050 0702453 500 0 0 0 3 | U9611851110010022047250038061 0702468 500 0 0 0 4 | U9749851110010041418850077874 0857487 500 0 0 0 5 | U9749851110010041420350077877 0857503 500 0 0 0 6 | U9749851110010041421850077884 0857518 500 0 0 0 7 | U9969151110010060707150086252 1053460 500 0 0 0 8 | U9969151110010060708650086252 1053475 500 0 0 0 9 | U9969151110010060710150086250 1053490 500 0 0 0 10 | U9623751110011051122550086673 1000597 500 0 0 0 11 | U9623751110011051124050086675 1001013 500 0 0 0 12 | U9623751110011051125550086677 1001028 500 0 0 0 13 | U9898051110011070349350076671 1156205 500 0 0 0 14 | U9898051110011070350850076667 1156220 500 0 0 0 15 | U9898051110011070352350076659 1156235 500 0 0 0 16 | U9409751110012032218050066060 0813475 500 0 0 0 17 | U9409751110012032219550066068 0813491 500 0 0 0 18 | U9409751110012032221050066075 0813506 500 0 0 0 19 | U9081551110012051538950087118 1009577 500 0 0 0 20 | U9081551110012051540450087116 1009592 500 0 0 0 21 | U9081551110012051541950087121 1010008 500 0 0 0 22 | U9766951110012070839350074402 1205464 500 0 0 0 23 | U9766951110012070840850074398 1205479 500 0 0 0 24 | U9766951110012070842350074390 1205495 500 0 0 0 25 | U9801051110013032906350069573 0825134 500 0 0 0 26 | U9801051110013032907850069579 0825149 500 0 0 0 27 | U9801051110013032909350069585 0825164 500 0 0 0 28 | U9375951110013061546050083954 1116181 500 0 0 0 29 | U9375951110013061547550083949 1116196 500 0 0 0 30 | U9375951110013061549050083947 1116212 500 0 0 0 31 | U9463451110013080535950054367 1307445 500 0 0 0 32 | U9463451110013080537450054359 1307461 500 0 0 0 33 | U9463451110013080538950054349 1307476 500 0 0 0 34 | U9893551110014023719250052922 0737117 500 0 0 0 35 | U9893551110014023720750052932 0737132 500 0 0 0 36 | U9893551110014023722250052944 0737147 500 0 0 0 37 | U9563151110014043201450084178 0934108 500 0 0 0 38 | U9563151110014043202950084182 0934124 500 0 0 0 39 | U9563151110014043204450084185 0934139 500 0 0 0 40 | U9331551110014062317550082155 1128321 500 0 0 0 41 | U9331551110014062319050082151 1128336 500 0 0 0 42 | U9331551110014062320550082146 1128351 500 0 0 0 43 | U9182151110015024347050057159 0748106 500 0 0 0 44 | U9182151110015024348550057168 0748122 500 0 0 0 45 | U9182151110015024350050057176 0748136 500 0 0 0 46 | U9342851110015043859750085490 0945531 500 0 0 0 47 | U9342851110015043901250085491 0945547 500 0 0 0 48 | U9342851110015043902750085495 0945563 500 0 0 0 49 | U9455651110016034909250078190 0859134 500 0 0 0 50 | U9455651110016034910750078197 0859149 500 0 0 0 51 | U9455651110016034912250078201 0859165 500 0 0 0 52 | U9789051110016073003250062590 1245192 500 0 0 0 53 | U9789051110016073004750062584 1245207 500 0 0 0 54 | U9789051110016073006250062572 1245221 500 0 0 0 55 | U9908551110017020551750045280 0718494 500 0 0 0 56 | U9908551110017020553250045290 0718509 500 0 0 0 57 | U9908551110017020554750045302 0718524 500 0 0 0 58 | U9474151110017035920250081098 0914102 500 0 0 0 59 | U9474151110017035921750081101 0914118 500 0 0 0 60 | U9474151110017035923250081106 0914133 500 0 0 0 61 | U9202651110017055220650084653 1110216 500 0 0 0 62 | U9202651110017055222150084651 1110231 500 0 0 0 63 | U9202651110017055223650084648 1110247 500 0 0 0 64 | U9019551110018021732750052030 0734599 500 0 0 0 65 | U9019551110018021734250052041 0735015 500 0 0 0 66 | U9019551110018021735750052049 0735029 500 0 0 0 67 | U9799251110018050420050087328 1025319 500 0 0 0 68 | U9799251110018050421550087330 1025335 500 0 0 0 69 | U9799251110018050423050087327 1025350 500 0 0 0 70 | U9271251110018065646850070359 1220341 500 0 0 0 71 | U9271251110018065648350070350 1220355 500 0 0 0 72 | U9271251110018065649850070341 1220371 500 0 0 0 73 | U9606951110019050839950087181 1034299 500 0 0 0 74 | U9606951110019050841450087180 1034315 500 0 0 0 75 | U9606951110019050842950087177 1034330 500 0 0 0 76 | U9599051110019070128150067595 1229450 500 0 0 0 77 | U9599051110019070129650067587 1229466 500 0 0 0 78 | U9599051110019070131150067581 1229481 500 0 0 0 79 | -------------------------------------------------------------------------------- /JOSS/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'SSAPy - Space Situational Awareness for Python' 3 | tags: 4 | - Python 5 | - space domain awareness 6 | - orbits 7 | - cislunar space 8 | authors: 9 | - name: Joshua E. Meyers 10 | affiliation: "1, 2" 11 | orcid: 0000-0002-2308-4230 12 | - name: Michael D. Schneider 13 | affiliation: 3 14 | orcid: 0000-0002-8505-7094 15 | - name: Julia T. Ebert 16 | affiliation: 4 17 | orcid: 0000-0002-1975-772X 18 | - name: Edward F. Schlafly 19 | affiliation: 5 20 | orcid: 0000-0002-3569-7421 21 | - name: Travis Yeager 22 | affiliation: 3 23 | orcid: 0000-0002-2582-0190 24 | - name: Alexx Perloff 25 | affiliation: 3 26 | orcid: 0000-0001-5230-0396 27 | - name: Daniel Merl 28 | affiliation: 3 29 | orcid: 0000-0003-4196-5354 30 | - name: Noah Lifset 31 | affiliation: 6 32 | orcid: 0000-0003-3397-7021 33 | - name: Jason Bernstein 34 | affiliation: 3 35 | orcid: 0000-0002-3391-5931 36 | - name: William A. Dawson 37 | affiliation: 3 38 | orcid: 0000-0003-0248-6123 39 | - name: Nathan Golovich 40 | affiliation: 3 41 | orcid: 0000-0003-2632-572X 42 | - name: Denvir Higgins 43 | affiliation: 3 44 | orcid: 0000-0002-7579-1092 45 | - name: Peter McGill 46 | affiliation: 3 47 | orcid: 0000-0002-1052-6749 48 | corresponding: true 49 | - name: Caleb Miller 50 | affiliation: 3 51 | orcid: 0000-0001-6249-0031 52 | - name: Kerianne Pruett 53 | affiliation: 3 54 | orcid: 0000-0002-2911-8657 55 | affiliations: 56 | - name: SLAC National Accelerator Laboratory, 2575 Sand Hill Road, Menlo Park, CA 94025, USA 57 | index: 1 58 | - name: Kavli Institute for Particle Astrophysics and Cosmology, Stanford University, 452 Lomita Mall, Stanford, CA 94035, USA 59 | index: 2 60 | - name: Lawrence Livermore National Laboratory, 7000 East Ave., Livermore, CA 94550, USA 61 | index: 3 62 | - name: Fleet Robotics, 21 Properzi Way, Somerville, MA 02143, USA 63 | index: 4 64 | - name: Space Telescope Science Institute, 3700 San Martin Drive, Baltimore, MD 21218, USA 65 | index: 5 66 | - name: University of Texas at Austin, 2515 Speedway, Austin, TX 78712, USA 67 | index: 6 68 | 69 | 70 | 71 | date: 13 January 2025 72 | bibliography: paper.bib 73 | 74 | aas-doi: 75 | aas-journal: 76 | --- 77 | 78 | # Summary 79 | 80 | SSAPy is a fast and flexible orbit modeling and analysis tool for orbits spanning from 81 | low-Earth into the cislunar regime. Orbits can be flexibly specified from common 82 | input formats such as Keplerian elements or two-line 83 | element (TLE) data files. SSAPy allows users to model satellites and specify parameters such 84 | as satellite area, mass, and drag coefficients. SSAPy includes a customizable force-propagation 85 | with a range of Earth, Lunar, radiation, atmospheric, and maneuvering models. SSAPy makes 86 | use of various community integration methods and can calculate 87 | time-evolved orbital quantities, including satellite magnitudes and state vectors. 88 | Users can specify various space- and ground-based observation models with support for 89 | multiple coordinate and reference frames. SSAPy also supports orbit analysis and 90 | propagation methods such as multiple hypothesis tracking and has built-in uncertainty quantification. 91 | The majority of SSAPy's methods are vectorized and parallelizable, allowing for effective use of 92 | high-performance computer (HPC) systems. Finally, SSAPy has plotting functionality, allowing users to 93 | visualize orbits and trajectories. Examples are shown in \autoref{fig:ground_track} and \autoref{fig:orbit_plot}. 94 | 95 | SSAPy has been used for the classification of cislunar [@Higgins2024] and closely-spaced [@Pruett2024] orbits as 96 | well as for studying the long-term stability of orbits in cislunar space [@Yeager2023]. SSAPy 97 | has also been used to build a case study for rare events analysis in the context of satellites 98 | passing close to each other in space [@Miller2022;@Bernstein2021]. 99 | 100 | # Statement of need 101 | 102 | Cislunar space is a region between Earth out to beyond the Moon's orbit that includes the 103 | Lagrange points. This region of space is of growing importance to scientific and other space exploration endeavors [e.g., @Duggan2019]. 104 | Understanding, mapping, and modeling orbits through cislunar space is 105 | critical to all of these endeavors. The challenge for cislunar orbits is that N-body dynamics (e.g., gravitational forces 106 | from the Sun, Earth, Moon and other planets) are significant, leading to unpredictable and chaotic orbital motion. 107 | In this chaotic regime, orbits cannot be reduced to simple parametric descriptions making scalable orbit 108 | simulation and modeling a critical analysis tool [@Yeager2023]. Current orbit modeling software tools 109 | are predominantly used via graphical user interfaces [e.g., The Systems Tool Kit or the General Mission Analysis Tool, @Hughes2014] 110 | and are not optimized for large-scale simulation on HPC systems. Orbital modeling codes that 111 | can be run on HPC systems [e.g., REBOUND, @Rein2012] lack full observable generation and modeling capabilities 112 | with uncertainty quantification. Existing space dynamics libraries such as Orekit [@OREKIT_2024] and Tudat [@TUDAT] share many 113 | features with SSAPy. However, one point of difference is that they rely on spherical 114 | harmonics or model the Moon as a point mass, whereas SSAPy incorporates more comprehensive physical modeling relevant to 115 | cislunar dynamics such as Earth [EGM2008, @earthmodel] and Lunar [GRGM1200A, @lunarmodel] surface gravity models. Additionally, 116 | SSAPy has utilities for determining---from any location on Earth---on-sky brightness, proper motion, right ascension and declination, 117 | and provides conversions between on-sky coordinates, TLEs, the Geocentric Celestial Reference Frame and other commonly used coordinates. 118 | There are also built-in observation-linking tools and orbit refinement. SSAPy, with its full-featured modeling framework and scalable, parallelizable 119 | functionality, fills the gap in the orbital software landscape. 120 | 121 | ![Example SSAPy visualization plot of an orbit ground track over the surface of the Earth. The 12–13 hour orbit has a semi-major axis of 27,000 km, an eccentricity of 0.2 and an inclination of 45 degrees.\label{fig:ground_track}](ground_track.png) 122 | 123 | 124 | ![Example SSAPy visualization plot of a cislunar orbit. The color on this plot represents time.\label{fig:orbit_plot}](orbit_plot.png){ width=50% } 125 | 126 | # Acknowledgements 127 | 128 | SSAPy depends on NumPy [@Harris2020], SciPy [@Virtanen2020], Matplotlib [@Hunter2007], emcee [@ForemanMackey2013], 129 | Astropy [@astropy2022], PyERFA [@Kerkwijk2023], lmfit [@newville2024], and SGP4 [@Vallado2006]. 130 | We would like to thank Robert Armstrong and Iméne Goumiri for valuable contributions to this project. 131 | This work was performed under the auspices of the U.S. 132 | Department of Energy by Lawrence Livermore National 133 | Laboratory (LLNL) under Contract DE-AC52-07NA27344. 134 | The document number is LLNL-JRNL-871602 and the code number is LLNL-CODE-862420. SSAPy was developed with support 135 | from LLNL's Laboratory Directed Research and Development Program under projects 19-SI-004 and 22-ERD-054. 136 | 137 | # References 138 | -------------------------------------------------------------------------------- /tests/test_plots.py: -------------------------------------------------------------------------------- 1 | import ssapy 2 | import os 3 | import shutil 4 | import numpy as np 5 | import matplotlib.pyplot as plt 6 | import glob 7 | 8 | save_folder = './ssapy_test_plots' 9 | print(f"Putting test_plot.py output in: {save_folder}") 10 | 11 | temp_directory = f'{save_folder}/rotate_vector_frames/' 12 | 13 | # Check if the directory exists, and create it if it doesn't 14 | try: 15 | if not os.path.exists(temp_directory): 16 | os.makedirs(temp_directory) 17 | print(f"Created directory: {temp_directory}") 18 | else: 19 | print(f"Directory already exists: {temp_directory}") 20 | except Exception as e: 21 | print(f"Error creating directory: {e}") 22 | 23 | # Testing rotate_vector() in utils. 24 | v_unit = np.array([1, 0, 0]) # Replace this with your actual unit vector 25 | 26 | figs = [] 27 | 28 | i = 0 29 | for theta in range(0, 181, 20): 30 | for phi in range(0, 361, 20): 31 | try: 32 | new_unit_vector = ssapy.utils.rotate_vector(v_unit, theta, phi, plot_path=temp_directory, save_idx=i) 33 | print(f"Generated file: {temp_directory}/frame_{i}.png") # Adjust filename format if needed 34 | except Exception as e: 35 | print(f"Error generating frame {i}: {e}") 36 | i += 1 37 | 38 | files = glob.glob(f"{temp_directory}*") 39 | print(f"Generated files: {files}") 40 | for file in files: 41 | try: 42 | with open(file, 'rb') as f: 43 | print(f"File {file} is valid.") 44 | except Exception as e: 45 | print(f"File {file} is invalid: {e}") 46 | 47 | gif_path = f"{save_folder}/rotate_vectors_{v_unit[0]:.0f}_{v_unit[1]:.0f}_{v_unit[2]:.0f}.gif" 48 | try: 49 | ssapy.plotUtils.save_animated_gif(gif_name=gif_path, frames=ssapy.io.listdir(f'{temp_directory}*', sorted=True), fps=20) 50 | except Exception as e: 51 | print(f"Error creating GIF: {e}") 52 | # shutil.rmtree(temp_directory) 53 | 54 | # Creating orbit plots 55 | times = ssapy.utils.get_times(duration=(1, 'year'), freq=(1, 'hour'), t0='2025-3-1') 56 | moon = ssapy.get_body("moon").position(times).T 57 | 58 | 59 | def initialize_DRO(t, delta_r=7.52064e7, delta_v=344): 60 | """ 61 | Calculate a distant retrograde orbit (DRO) as an orbit with 62 | adjustments based on the Moon's position and velocity. 63 | 64 | Parameters: 65 | ---------- 66 | t : Time 67 | The time at which to calculate the orbit. 68 | delta_r : float, optional 69 | The adjustment to the Moon's position (default is 7.52064e7 meters). 70 | delta_v : float, optional 71 | The adjustment to the Moon's velocity (default is 344 meters/second). 72 | 73 | Returns: 74 | ------- 75 | Orbit 76 | SSAPy Orbit object. 77 | """ 78 | moon = ssapy.get_body("moon") 79 | 80 | unit_vector_moon = moon.position(t) / np.linalg.norm(moon.position(t)) 81 | moon_v = (moon.position(t.gps) - moon.position(t.gps - 1)) / 1 82 | unit_vector_moon_velocity = moon_v / np.linalg.norm(moon_v) 83 | ssapy.compute.lunar_lagrange_points(t=times[0]) 84 | 85 | r = (np.linalg.norm(moon.position(t)) - delta_r) * unit_vector_moon 86 | v = (np.linalg.norm(moon_v) + delta_v) * unit_vector_moon_velocity 87 | 88 | orbit = ssapy.Orbit(r=r, v=v, t=t) 89 | return orbit 90 | 91 | 92 | # Distant Retrograde Orbit (DRO) 93 | dro_orbit = initialize_DRO(t=times[0]) 94 | r, v = ssapy.simple.ssapy_orbit(orbit=dro_orbit, t=times) 95 | ssapy.plotUtils.orbit_plot(r=r, t=times, save_path=f"{save_folder}/DRO_orbit", frame='Lunar', show=False) 96 | r_lunar, v_lunar = ssapy.utils.gcrf_to_lunar_fixed(r, t=times, v=True) 97 | print("Successfully converted GCRF to lunar frame.") 98 | ssapy.plotUtils.koe_plot(r, v, t=times, body='Earth', save_path=f"{save_folder}Keplerian_orbital_elements.png") 99 | 100 | ssapy.plotUtils.orbit_plot(r=r, t=times, save_path=f"{save_folder}/gcrf_plot.png", frame='gcrf', show=True) 101 | ssapy.plotUtils.orbit_plot(r=r, t=times, save_path=f"{save_folder}/itrf_plot", frame='itrf', show=True) 102 | ssapy.plotUtils.orbit_plot(r=r, t=times, save_path=f"{save_folder}/lunar_plot", frame='lunar', show=True) 103 | ssapy.plotUtils.orbit_plot(r=r, t=times, save_path=f"{save_folder}/lunar_axis_lot", frame='lunar axis', show=True) 104 | print("Created a GCRF orbit plot.") 105 | print("Created a ITRF orbit plot.") 106 | print("Created a Lunar orbit plot.") 107 | print("Created a Lunar axis orbit plot.") 108 | 109 | # Globe plot of a Geostationary Transfer Orbit (GTO) 110 | r_geo, _ = ssapy.simple.ssapy_orbit(a=ssapy.constants.RGEO, e=0.3, t=times) 111 | ssapy.plotUtils.globe_plot(r=r_geo, t=times, save_path=f"{save_folder}/globe_plot", scale=5) 112 | print('Created a globe plot.') 113 | 114 | ssapy.plotUtils.ground_track_plot(r=r_geo, t=times, ground_stations=None, save_path=f"{save_folder}/ground_track_plot") 115 | print('Created a ground track plot.') 116 | 117 | # Example usage 118 | earth_pos = np.array([0, 0, 0]) # Earth at the origin 119 | moon_pos = ssapy.get_body("moon").position(times[0]).T 120 | 121 | # Plotting 122 | fig = plt.figure(figsize=(8, 8)) 123 | fig.patch.set_facecolor('white') 124 | ax = fig.add_subplot(111, projection='3d') 125 | 126 | # Plot Earth 127 | ax.scatter(earth_pos[0], earth_pos[1], earth_pos[2], color='blue', label='Earth') 128 | ax.text(earth_pos[0], earth_pos[1], earth_pos[2], 'Earth', color='blue') 129 | 130 | # Plot Moon 131 | ax.scatter(moon_pos[0], moon_pos[1], moon_pos[2], color='grey', label='Moon') 132 | ax.text(moon_pos[0], moon_pos[1], moon_pos[2], 'Moon', color='grey') 133 | 134 | # Plot Lagrange points 135 | colors = ['red', 'green', 'purple', 'orange', 'cyan'] 136 | for (point, pos), color in zip(ssapy.compute.lunar_lagrange_points(t=times[0]).items(), colors): 137 | ax.scatter(pos[0], pos[1], pos[2], color=color, label=point) 138 | ax.text(pos[0], pos[1], pos[2], point, color=color) 139 | 140 | # Add a dashed black circle at the lunar distance (LD) 141 | current_LD = np.linalg.norm(moon_pos, axis=-1) 142 | normal_vector = ssapy.compute.moon_normal_vector(t=times[0]) 143 | ssapy.plotUtils.draw_dashed_circle(ax, normal_vector, current_LD, 12) 144 | ax.quiver(0, 0, 0, normal_vector[0], normal_vector[1], normal_vector[2], color='r', length=1) 145 | 146 | # Labels and legend 147 | ax.set_xlabel('X (m)') 148 | ax.set_ylabel('Y (m)') 149 | ax.set_zlabel('Z (m)') 150 | ax.set_title("Lunar Lagrange Points using Moon's true position") 151 | ax.axis('equal') 152 | ax.legend() 153 | plt.show() 154 | ssapy.plotUtils.save_plot(fig, save_path=f"{save_folder}/lagrange_points") 155 | 156 | # Plotting 157 | fig = plt.figure(figsize=(8, 8)) 158 | fig.patch.set_facecolor('white') 159 | ax = fig.add_subplot(111, projection='3d') 160 | 161 | # Plot Earth 162 | ax.scatter(earth_pos[0], earth_pos[1], earth_pos[2], color='blue', label='Earth') 163 | ax.text(earth_pos[0], earth_pos[1], earth_pos[2], 'Earth', color='blue') 164 | 165 | # Plot Moon 166 | ax.scatter(moon_pos[0], moon_pos[1], moon_pos[2], color='grey', label='Moon') 167 | ax.text(moon_pos[0], moon_pos[1], moon_pos[2], 'Moon', color='grey') 168 | 169 | # Plot Lagrange points 170 | colors = ['red', 'green', 'purple', 'orange', 'cyan'] 171 | for (point, pos), color in zip(ssapy.compute.lunar_lagrange_points_circular(t=times[0]).items(), colors): 172 | ax.scatter(pos[0], pos[1], pos[2], color=color, label=point) 173 | ax.text(pos[0], pos[1], pos[2], point, color=color) 174 | 175 | # Add a dashed black circle at distance LD 176 | current_LD = np.linalg.norm(moon_pos, axis=-1) 177 | normal_vector = ssapy.compute.moon_normal_vector(t=times[0]) 178 | ssapy.plotUtils.draw_dashed_circle(ax, normal_vector, current_LD, 12) 179 | ax.quiver(0, 0, 0, normal_vector[0], normal_vector[1], normal_vector[2], color='r', length=1) 180 | 181 | # Labels and legend 182 | ax.set_xlabel('X (m)') 183 | ax.set_ylabel('Y (m)') 184 | ax.set_zlabel('Z (m)') 185 | ax.set_title('Lunar Lagrange Points assuming circular orbit.') 186 | ax.axis('equal') 187 | ax.legend() 188 | plt.show() 189 | ssapy.plotUtils.save_plot(fig, save_path=f"{save_folder}/lagrange_points") 190 | 191 | print("Lagrange points were calculated correctly.") 192 | print("Rotate vector plot successfully created.") 193 | print("save_plot() executed successfully.") 194 | print("save_animated_gif() executed successfully.") 195 | 196 | print("\nFinished plot testing!\n") 197 | -------------------------------------------------------------------------------- /include/ssapy.h: -------------------------------------------------------------------------------- 1 | #ifndef ssapy_ssapy_H 2 | #define ssapy_ssapy_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #ifndef M_PI 10 | #define M_PI 3.14159265358979323846 11 | #endif 12 | 13 | namespace ssapy { 14 | 15 | ///////////////////////////////////////////////// 16 | // 17 | // Ellipsoid 18 | // 19 | // Class to handle transformations between ECEF x,y,z coords and geodetic 20 | // longitude, latitude, and height. Technically, only handles a one-axis 21 | // ellipsoid, defined via a flattening parameter f, but that's good 22 | // enough for simple Earth models. 23 | // 24 | // 25 | ///////////////////////////////////////////////// 26 | class Ellipsoid { 27 | public: 28 | //////////////////////////////////////////// 29 | // 30 | // Constructor 31 | // 32 | // Parameters 33 | // 34 | // Req : Earth radius at the equator. 35 | // f : flattening parameter defined as (a-b)/a, where a and b are the 36 | // semimajor and semiminor axes of the ellipse containing the Earth's 37 | // rotational axis. 38 | // 39 | //////////////////////////////////////////// 40 | Ellipsoid(const double Req=6378.137e3, const double f=1.0/298.257223563) : 41 | _Req(Req), _f(f), _e2(f*(2.0-f)), _1mf2((1-f)*(1-f)) {} 42 | 43 | //////////////////////////////////////////// 44 | // 45 | // sphereToCart 46 | // 47 | // Convert geodetic "spherical" coordinates to cartesian coordinates. 48 | // 49 | // Parameters 50 | // lon [in] : Longitude in radians 51 | // lat [in] : Latitude in radians 52 | // height [in] : Height above ellipsoid 53 | // x, y, z [out] : Cartesian coordinates 54 | // 55 | //////////////////////////////////////////// 56 | void sphereToCart( 57 | const double lon, const double lat, const double height, 58 | double& x, double& y, double& z 59 | ) const; 60 | 61 | //////////////////////////////////////////// 62 | // 63 | // cartToSphere 64 | // 65 | // Convert cartesian coordinates to geodetic "spherical" coordinates. 66 | // 67 | // Parameters 68 | // x, y, z [in] : Cartesian coordinates 69 | // lon [out] : Longitude in radians 70 | // lat [out] : Latitude in radians 71 | // height [out] : Height above ellipsoid 72 | // 73 | //////////////////////////////////////////// 74 | void cartToSphere( 75 | const double x, const double y, const double z, 76 | double& lon, double& lat, double& height 77 | ) const; 78 | 79 | private: 80 | const double _Req; 81 | const double _f; 82 | const double _e2; // e^2 = f*(2-f) MG (5.86) 83 | const double _1mf2; // (1-f)^2 84 | }; 85 | 86 | 87 | ///////////////////////////////////////////////// 88 | // 89 | // HarrisPriester 90 | // 91 | // Atmospheric density model of Harris and Priester (1962) 92 | // 93 | ///////////////////////////////////////////////// 94 | class HarrisPriester { 95 | public: 96 | //////////////////////////////////////////// 97 | // 98 | // Constructor 99 | // 100 | // Parameters 101 | // 102 | // ellip : Earth ellipsoid. 103 | // n : A scaling parameter for inclination. Low inclination 104 | // orbits have n ~ 2, polar orbits have n ~ 6. 105 | // 106 | //////////////////////////////////////////// 107 | HarrisPriester( 108 | const Ellipsoid& ellip, double n=6.0 109 | ) : _ellip(ellip), _n(n) {} 110 | 111 | //////////////////////////////////////////// 112 | // 113 | // density 114 | // 115 | // Compute atmospheric density 116 | // 117 | // Parameters 118 | // x, y, z [in] : True-of-date Cartesian equatorial coordinates in meters. 119 | // ra_Sun [in] : True-of-date right ascention of sun in radians. 120 | // dec_Sun [in] : True-of-date declination of sun in radians. 121 | // 122 | // Returns 123 | // density in kg/m^3 124 | //////////////////////////////////////////// 125 | double density( 126 | const double x, const double y, const double z, 127 | const double ra_Sun, const double dec_Sun 128 | ) const; 129 | 130 | 131 | private: 132 | const double _n; 133 | const Ellipsoid& _ellip; 134 | 135 | // MG Table 3.8 136 | static const std::array _h; 137 | static const std::array _rho_antapex; // density at bulge antipode 138 | static const std::array _rho_apex; // density at bulge 139 | static const std::array _scale_antapex; 140 | static const std::array _scale_apex; 141 | 142 | static const std::array _compute_scale_antapex(); 143 | static const std::array _compute_scale_apex(); 144 | }; 145 | 146 | 147 | // Super simple matrix class just to enable mat(i,j) element access. 148 | class Mat { 149 | public: 150 | Mat(const int rows, const int cols) : 151 | _owns(true), _ncol(cols) 152 | { 153 | _data = new double[rows*cols]; 154 | for (int i = 0; i<(rows*cols); i++) { 155 | _data[i] = 0; 156 | } 157 | } 158 | 159 | Mat(const int ncol, double* const data) : 160 | _owns(false), _ncol(ncol), _data(data) 161 | {} 162 | 163 | ~Mat() { 164 | if(_owns) delete _data; 165 | } 166 | 167 | double& operator()(int irow, int icol) { 168 | return _data[icol + _ncol*irow]; 169 | } 170 | 171 | double& operator()(int irow, int icol) const { 172 | return _data[icol + _ncol*irow]; 173 | } 174 | 175 | private: 176 | const bool _owns; 177 | const int _ncol; 178 | double* _data; 179 | }; 180 | 181 | 182 | ///////////////////////////////////////////////// 183 | // 184 | // AccelHarmonic 185 | // 186 | // Compute acceleration from harmonic expansion of gravitational 187 | // field. 188 | // 189 | ///////////////////////////////////////////////// 190 | class AccelHarmonic { 191 | public: 192 | //////////////////////////////////////////// 193 | // 194 | // Constructor 195 | // 196 | // Parameters 197 | // 198 | // GM : Gravitational parameter in m^3/s^2 199 | // R : Reference radius in m 200 | // ncol : Number of columns in CS array 201 | // CS : Harmonic coefficients stored as 202 | // C[n,m] = CS[n,m] 203 | // S[n,m] = CS[m-1,n] 204 | // 205 | //////////////////////////////////////////// 206 | AccelHarmonic( 207 | const double GM, const double R, 208 | const int ncol, double* const CSptr 209 | ) : 210 | _R(R), _Rsq(R*R), _GMinvRsq(GM/_Rsq), _ncol(ncol), 211 | _CS(_ncol, CSptr), _V(_ncol+1, _ncol+1), _W(_ncol+1, _ncol+1) 212 | {} 213 | 214 | //////////////////////////////////////////// 215 | // 216 | // accel 217 | // 218 | // Compute graviational acceleration 219 | // 220 | // Parameters 221 | // x, y, z [in] : True-of-date Cartesian equatorial coordinates in meters. 222 | // n_max [in] : Maximum order of model to compute. 223 | // m_max [in] : Maximum degree of model to compute. 224 | // ax, ay, az [out] : Acceleration in m/s^2. 225 | // 226 | //////////////////////////////////////////// 227 | void accel( 228 | const double x, const double y, const double z, 229 | const int n_max, const int m_max, 230 | double& ax, double& ay, double& az 231 | ) const; 232 | 233 | private: 234 | const double _R; 235 | const double _Rsq; // R_ref^2 236 | const double _GMinvRsq; // GM / R_ref^2 237 | const int _ncol; 238 | Mat _CS; // Harmonic coefficients 239 | Mat _V, _W; // Preallocate scratch arrays. 240 | }; 241 | } 242 | 243 | #endif 244 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from astropy.time import Time 3 | import astropy.units as u 4 | 5 | import ssapy 6 | from ssapy import utils 7 | from ssapy.utils import normed 8 | from .ssapy_test_helpers import checkSphere, timer 9 | from ssapy import utils 10 | 11 | def test_wrap_and_num_wraps(): 12 | angles = np.array([4, -4, np.pi * 3]) 13 | wrapped = utils._wrapToPi(angles) 14 | assert np.all((-np.pi <= wrapped) & (wrapped <= np.pi)) 15 | 16 | assert utils.num_wraps(np.pi * 5) == 2 17 | 18 | 19 | def test_norm_functions(): 20 | v = np.array([[1.0, 2.0, 2.0]]) 21 | assert np.isclose(utils.normSq(v), 9.0) 22 | assert np.isclose(utils.norm(v), 3.0) 23 | np.testing.assert_allclose(utils.normed(v), v / 3.0) 24 | 25 | a = np.random.randn(10, 3) 26 | np.testing.assert_allclose(utils.einsum_norm(a, 'ij,ij->i'), utils.norm(a)) 27 | 28 | 29 | def test_unit_angle(): 30 | a = np.random.randn(10, 3) 31 | a = utils.normed(a) 32 | b = a.copy() 33 | np.testing.assert_allclose(utils.unitAngle3(a, b), 0.0) 34 | 35 | 36 | def test_newton_raphson(): 37 | f = lambda x: x**2 - 2 38 | fprime = lambda x: 2 * x 39 | root = utils.newton_raphson(1.0, f, fprime) 40 | assert np.isclose(root, np.sqrt(2), atol=1e-10) 41 | 42 | 43 | def test_find_extrema_brackets(): 44 | y = np.array([1, 2, 1, 0, -1, -2, -1]) 45 | brackets = utils.find_extrema_brackets(y) 46 | assert len(brackets) > 0 47 | 48 | 49 | def test_sample_points(): 50 | x = np.array([0.0, 0.0]) 51 | C = np.eye(2) 52 | samples = utils.sample_points(x, C, 100) 53 | assert samples.shape == (100, 2) 54 | 55 | 56 | def test_sigma_points(): 57 | x = np.array([1.0, 2.0]) 58 | C = np.eye(2) 59 | f = lambda pts: pts @ np.array([1.0, 1.0]) 60 | out = utils.sigma_points(f, x, C) 61 | assert out.shape[0] == 2 * len(x) + 1 62 | 63 | 64 | def test_lru_cache(): 65 | hits = [] 66 | def f(x): hits.append(x); return x * 2 67 | cached = utils.LRU_Cache(f, maxsize=2) 68 | assert cached(2) == 4 69 | assert cached(2) == 4 70 | assert len(hits) == 1 # Cached 71 | 72 | 73 | def test_lazy_property(): 74 | class Foo: 75 | @utils.lazy_property 76 | def val(self): 77 | return 42 78 | f = Foo() 79 | assert f.val == 42 80 | f.__dict__['val'] = 100 81 | assert f.val == 100 82 | 83 | 84 | @timer 85 | def test_catalog_to_apparent(): 86 | """No real test here, just want to make sure it runs vectorized""" 87 | size = 1_000_000 88 | ra = np.random.uniform(0.0, 2*np.pi, size=size) 89 | dec = np.arccos(np.random.uniform(-1.0, 1.0, size=size)) 90 | pmra = np.random.uniform(-100.0, 100.0, size=size) 91 | pmdec = np.random.uniform(-100.0, 100.0, size=size) 92 | parallax = np.random.uniform(0.0, 0.1, size=size) 93 | t = Time("J2020") 94 | observer = ssapy.EarthObserver(lon=100., lat=10., elevation=100.) 95 | ra1, dec1 = ssapy.utils.catalog_to_apparent(ra, dec, t, skipAberration=True) 96 | ra2, dec2 = ssapy.utils.catalog_to_apparent(ra, dec, t, pmra=pmra, pmdec=pmdec, skipAberration=True) 97 | ra3, dec3 = ssapy.utils.catalog_to_apparent(ra, dec, t, parallax=parallax, skipAberration=True) 98 | ra4, dec4 = ssapy.utils.catalog_to_apparent(ra, dec, t) 99 | ra5, dec5 = ssapy.utils.catalog_to_apparent(ra, dec, t, observer=observer) 100 | ra6, dec6 = ssapy.utils.catalog_to_apparent(ra, dec, t, pmra=pmra, pmdec=pmdec, parallax=parallax, observer=observer) 101 | 102 | 103 | @timer 104 | def test_catalog_to_apparent_SOFA(): 105 | """Checking against test case using SOFA library, 106 | where SOFA is the Standards of Fundamental Astronomy. 107 | """ 108 | t = Time("2013-04-02T23:15:43.55", scale='utc') 109 | ra = np.array([np.deg2rad(15*(14+34/60+16.81183/3600))]) 110 | dec = np.array([-np.deg2rad(12+31/60+10.3965/3600)]) 111 | # Verify null transformation first 112 | ra1, dec1 = ssapy.utils.catalog_to_apparent( 113 | ra, dec, t, skipAberration=True 114 | ) 115 | checkSphere(ra, dec, ra1, dec1, atol=1e-15) 116 | 117 | # Try proper motion 118 | pmra = -354.45 119 | pmdec = 595.35 120 | ra2, dec2 = ssapy.utils.catalog_to_apparent( 121 | ra, dec, t, pmra=pmra, pmdec=pmdec, skipAberration=True 122 | ) 123 | ra2_SOFA = np.array([np.deg2rad(15*(14+34/60+16.4910486/3600))]) 124 | dec2_SOFA = np.array([-np.deg2rad(12+31/60+2.506613/3600)]) 125 | # milliarcsec precision 126 | checkSphere(ra2, dec2, ra2_SOFA, dec2_SOFA, atol=np.deg2rad(1e-5/3600)) 127 | 128 | # Try parallax 129 | ra3, dec3 = ssapy.utils.catalog_to_apparent( 130 | ra, dec, t, parallax=0.16499, skipAberration=True 131 | ) 132 | ra3_SOFA = np.array([np.deg2rad(15*(14+34/60+16.8168100/3600))]) 133 | dec3_SOFA = np.array([-np.deg2rad(12+31/60+10.413678/3600)]) 134 | checkSphere(ra3, dec3, ra3_SOFA, dec3_SOFA, atol=np.deg2rad(1e-5/3600)) 135 | 136 | # Try aberration 137 | ra4, dec4 = ssapy.utils.catalog_to_apparent( 138 | ra, dec, t, 139 | ) 140 | ra4_SOFA = np.array([np.deg2rad(15*(14+34/60+17.9779815/3600))]) 141 | dec4_SOFA = np.array([-np.deg2rad(12+31/60+16.427072/3600)]) 142 | checkSphere(ra4, dec4, ra4_SOFA, dec4_SOFA, atol=np.deg2rad(1e-3/3600)) 143 | 144 | # Try all together 145 | ra5, dec5 = ssapy.utils.catalog_to_apparent( 146 | ra, dec, t, pmra=pmra, pmdec=pmdec, parallax=0.16499 147 | ) 148 | ra5_SOFA = np.array([np.deg2rad(15*(14+34/60+17.6621826/3600))]) 149 | dec5_SOFA = np.array([-np.deg2rad(12+31/60+08.554809/3600)]) 150 | checkSphere(ra5, dec5, ra5_SOFA, dec5_SOFA, atol=np.deg2rad(1e-3/3600)) 151 | 152 | 153 | @timer 154 | def test_angular_conversions(): 155 | seed = 42 156 | np.random.seed(seed) 157 | npts = 10000 158 | uv = normed(np.random.randn(npts, 3)) 159 | lb = utils.unit_to_lb(uv) 160 | tp = utils.unit_to_tp(uv) 161 | # round trips 162 | # 3 systems, back and forth to all other systems -> 6 tests. 163 | np.testing.assert_allclose(uv, 164 | utils.lb_to_unit(*utils.unit_to_lb(uv)), 165 | rtol=0, atol=1e-10) 166 | np.testing.assert_allclose(uv, 167 | utils.tp_to_unit(*utils.unit_to_tp(uv)), 168 | rtol=0, atol=1e-10) 169 | np.testing.assert_allclose(np.concatenate(tp), 170 | np.concatenate(utils.unit_to_tp(utils.tp_to_unit(*tp))), 171 | rtol=0, atol=1e-10) 172 | np.testing.assert_allclose(np.concatenate(tp), 173 | np.concatenate(utils.lb_to_tp(*utils.tp_to_lb(*tp))), 174 | rtol=0, atol=1e-10) 175 | np.testing.assert_allclose(np.concatenate(lb), 176 | np.concatenate(utils.unit_to_lb(utils.lb_to_unit(*lb))), 177 | rtol=0, atol=1e-10) 178 | np.testing.assert_allclose(np.concatenate(lb), 179 | np.concatenate(utils.tp_to_lb(*utils.lb_to_tp(*lb))), 180 | rtol=0, atol=1e-10) 181 | 182 | # check tangent plane round tripping. 183 | # this is just orthographic, so if you're on the wrong side of the globe 184 | # you won't round trip back to the right side. 185 | # so we need to make up some lcen, bcen to project from. 186 | noise = np.random.randn(npts, 3)*0.01 187 | uv2 = normed(uv + noise) 188 | lcen, bcen = utils.unit_to_lb(uv2) 189 | xy = utils.lb_to_tan(*lb, lcen=lcen, bcen=bcen) 190 | 191 | # vector lcen, bcen 192 | np.testing.assert_allclose( 193 | np.concatenate(lb), 194 | np.concatenate(utils.tan_to_lb(*utils.lb_to_tan(*lb, lcen=lcen, bcen=bcen), 195 | lcen=lcen, bcen=bcen))) 196 | np.testing.assert_allclose( 197 | np.concatenate(xy), 198 | np.concatenate(utils.lb_to_tan(*utils.tan_to_lb(*xy, lcen=lcen, bcen=bcen), 199 | lcen=lcen, bcen=bcen))) 200 | 201 | # single lcen, bcen; careful to choose all points to be on same hemisphere 202 | uv2 = uv.copy() 203 | uv2[:, 0] = np.abs(uv2[:, 0]) 204 | lcen, bcen = (0, 0) 205 | lb2 = utils.unit_to_lb(uv2) 206 | xy2 = utils.lb_to_tan(*lb2, lcen=lcen, bcen=bcen) 207 | 208 | np.testing.assert_allclose( 209 | np.concatenate(lb2), 210 | np.concatenate(utils.tan_to_lb(*utils.lb_to_tan(*lb2, lcen=lcen, bcen=bcen), 211 | lcen=lcen, bcen=bcen))) 212 | np.testing.assert_allclose( 213 | np.concatenate(xy2), 214 | np.concatenate(utils.lb_to_tan(*utils.tan_to_lb(*xy2, lcen=lcen, bcen=bcen), 215 | lcen=lcen, bcen=bcen))) 216 | 217 | 218 | if __name__ == '__main__': 219 | test_catalog_to_apparent() 220 | test_catalog_to_apparent_SOFA() 221 | test_angular_conversions() 222 | -------------------------------------------------------------------------------- /tests/test_frame.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | import time 4 | import numpy as np 5 | from astropy.time import Time 6 | 7 | import ssapy 8 | from .ssapy_test_helpers import timer 9 | 10 | try: 11 | import orekit 12 | orekit.initVM() 13 | from orekit.pyhelpers import setup_orekit_curdir 14 | setup_orekit_curdir() 15 | from org.orekit.time import TimeScalesFactory 16 | ts = TimeScalesFactory.getUTC() 17 | except: 18 | no_orekit = True 19 | else: 20 | no_orekit = False 21 | 22 | # List to store benchmark results 23 | benchmark_results = [] 24 | 25 | def log_result(test_name, execution_time, status): 26 | """Log benchmark result to a global list.""" 27 | benchmark_results.append({ 28 | "test_name": test_name, 29 | "execution_time": execution_time, 30 | "status": status 31 | }) 32 | 33 | @timer 34 | def test_vallado(): 35 | test_name = "test_vallado" 36 | start_time = time.time() 37 | try: 38 | t = Time("2004-04-06 07:51:28.386009") 39 | # These are in km and km/s 40 | rteme = np.array([5094.18016210, 6127.64465950, 6380.34453270]) 41 | vteme = np.array([-4.7461314870, 0.7858180410, 5.5319312880]) 42 | rgcrf = np.array([5102.50895290, 6123.01139910, 6378.13693380]) 43 | vgcrf = np.array([-4.7432201610, 0.7905364950, 5.5337557240]) 44 | 45 | # No longer need these? 46 | ddpsi = -0.052195/206265 # arcsec->radians 47 | ddeps = -0.003875/206265 # arcsec->radians 48 | 49 | rot = ssapy.utils.teme_to_gcrf(t) 50 | 51 | # 10 cm precision 52 | np.testing.assert_allclose(np.dot(rot, rteme), rgcrf, rtol=0, atol=1e-4) 53 | # 0.1 mm/s precision 54 | np.testing.assert_allclose(np.dot(rot, vteme), vgcrf, rtol=0, atol=1e-7) 55 | 56 | # Test reverse direction too 57 | np.testing.assert_allclose(np.dot(rot.T, rgcrf), rteme, rtol=0, atol=1e-4) 58 | np.testing.assert_allclose(np.dot(rot.T, vgcrf), vteme, rtol=0, atol=1e-7) 59 | 60 | rotT = ssapy.utils.gcrf_to_teme(t) 61 | np.testing.assert_allclose(np.dot(rotT, rgcrf), rteme, rtol=0, atol=1e-4) 62 | np.testing.assert_allclose(np.dot(rotT, vgcrf), vteme, rtol=0, atol=1e-7) 63 | 64 | status = "passed" 65 | except Exception as e: 66 | status = f"failed: {str(e)}" 67 | finally: 68 | execution_time = time.time() - start_time 69 | log_result(test_name, execution_time, status) 70 | 71 | @unittest.skipIf(no_orekit, 'Unable to import orekit') 72 | @timer 73 | def test_teme_orekit(): 74 | test_name = "test_teme_orekit" 75 | start_time = time.time() 76 | try: 77 | from org.orekit.frames import FramesFactory 78 | from org.hipparchus.geometry.euclidean.threed import Vector3D 79 | from org.orekit.time import AbsoluteDate 80 | from org.orekit.utils import PVCoordinates 81 | 82 | # Some cheap utilities to convert between java PVCoordinates and numpy vectors 83 | def v3toarr(v3): 84 | p3 = v3.getPosition() 85 | return np.array([p3.x, p3.y, p3.z]) 86 | 87 | def arrtov3(arr): 88 | return PVCoordinates(Vector3D(float(arr[0]), float(arr[1]), float(arr[2]))) 89 | 90 | 91 | TEME = FramesFactory.getTEME() 92 | GCRF = FramesFactory.getGCRF() 93 | 94 | for _ in range(100): 95 | rteme = np.random.uniform(low=-1000, high=1000, size=(10, 3)) 96 | t = Time(np.random.uniform(low=0, high=1e8), format='gps') 97 | rot = ssapy.utils.teme_to_gcrf(t) 98 | rgcrf = np.dot(rot, rteme.T).T 99 | 100 | oreDate = AbsoluteDate(t.isot, TimeScalesFactory.getUTC()) 101 | transform = TEME.getTransformTo(GCRF, oreDate) 102 | 103 | for rteme_, rgcrf_ in zip(rteme, rgcrf): 104 | rtest = v3toarr(transform.transformPVCoordinates(arrtov3(rteme_))) 105 | np.testing.assert_allclose(rtest, rgcrf_, rtol=0, atol=1e-3) # 1m precision 106 | 107 | status = "passed" 108 | except Exception as e: 109 | status = f"failed: {str(e)}" 110 | finally: 111 | execution_time = time.time() - start_time 112 | log_result(test_name, execution_time, status) 113 | 114 | @timer 115 | def test_MG_5_1(): 116 | test_name = "test_MG_5_1" 117 | start_time = time.time() 118 | try: 119 | """Exercise 5.1 from Montenbruck and Gill""" 120 | import astropy.units as u 121 | t0 = Time("1999-03-04T00:00:00", scale='utc') 122 | mjd_tt_j2000 = 51544.5 123 | mjd_tt = ssapy.utils._gpsToTT(t0.gps) 124 | dut1_mjd = t0.ut1.mjd - t0.tt.mjd 125 | 126 | # Using erfa instead of rolling our own... 127 | try: 128 | import erfa 129 | except ImportError: 130 | import astropy._erfa as erfa 131 | 132 | prec = erfa.pmat76(2400000.5, mjd_tt) 133 | nut = erfa.nutm80(2400000.5, mjd_tt) 134 | gst = erfa.gst94(2400000.5, mjd_tt + dut1_mjd) 135 | sg, cg = np.sin(gst), np.cos(gst) 136 | GHA = np.array([ 137 | [cg, sg, 0], 138 | [-sg, cg, 0], 139 | [0, 0, 1] 140 | ]) 141 | 142 | # Precession transformation matrix 143 | MG_prec = np.array([ 144 | [+0.99999998, +0.00018581, +0.00008074], 145 | [-0.00018581, +0.99999998, -0.00000001], 146 | [-0.00008074, -0.00000001, +1.00000000] 147 | ]) 148 | 149 | # Nutation transformation matrix 150 | MG_nut = np.array([ 151 | [+1.00000000, +0.00004484, +0.00001944], 152 | [-0.00004484, +1.00000000, +0.00003207], 153 | [-0.00001944, -0.00003207, +1.00000000] 154 | ]) 155 | 156 | # Earth rotation transformation matrix 157 | MG_GHA = np.array([ 158 | [-0.94730417, +0.32033547, +0.00000000], 159 | [-0.32033547, -0.94730417, +0.00000000], 160 | [+0.00000000, +0.00000000, +1.00000000] 161 | ]) 162 | 163 | np.testing.assert_allclose(prec, MG_prec, rtol=0, atol=1e-8) 164 | np.testing.assert_allclose(nut, MG_nut, rtol=0, atol=1e-8) 165 | np.testing.assert_allclose(GHA, MG_GHA, rtol=0, atol=1e-8) 166 | 167 | # Get dut1, polar motion from astropy, compare to M&G 168 | dut1_mjd_2, pmx, pmy = ssapy.utils.iers_interp(t0.gps) 169 | np.testing.assert_allclose(dut1_mjd, dut1_mjd_2, rtol=0, atol=1e-11) 170 | np.testing.assert_allclose(pmx, 0.00000033, rtol=0, atol=1e-8) 171 | np.testing.assert_allclose(pmy, 0.00000117, rtol=0, atol=1e-8) 172 | 173 | # We don't have a route to compute polar motion in ssapy. So just assert 174 | # polar motion transformation matrix from MG for now. 175 | pol = np.array([ 176 | [+1.0, +0.0, +pmx], 177 | [+0.0, +1.0, -pmy], 178 | [-pmx, +pmy, +1.0] 179 | ]) 180 | 181 | # Transformation matrix from International Celestial Reference System (ICRS) to 182 | # International Terrestrial Reference System (ITRS) 183 | U = pol@GHA@nut@prec 184 | MG_U = np.array([ 185 | [-0.94737803, +0.32011696, -0.00008431], 186 | [-0.32011696, -0.94737803, -0.00006363], 187 | [-0.00010024, -0.00003330, +0.99999999] 188 | ]) 189 | np.testing.assert_allclose(U, MG_U, rtol=0, atol=1e-8) 190 | 191 | # Now see if we can do a getRV on an EarthObserver 192 | # Use transformation matrix without polar motion since that's what we'll 193 | # have available in production generally. 194 | U = GHA@nut@prec 195 | lon = np.deg2rad(70+44/60+11.7/3600) 196 | lat = np.deg2rad(-30-14/60-26.6/3600) 197 | elevation = 2715.0 198 | observer = ssapy.EarthObserver(lon, lat, elevation) 199 | r_itrs = observer._location.itrs.cartesian.xyz.to(u.m).value 200 | r_gcrs = U.T@r_itrs 201 | 202 | dUdt = ( 203 | np.array([[0,1,0],[-1,0,0],[0,0,0]], dtype=float) 204 | * 1.002737909350795*2*np.pi/86400 205 | @ U 206 | ) 207 | v_gcrs = dUdt.T @ r_itrs 208 | 209 | r, v = observer.getRV(t0) 210 | 211 | np.testing.assert_allclose(r, r_gcrs, rtol=0, atol=2) 212 | np.testing.assert_allclose(v, v_gcrs, rtol=0, atol=1e-4) 213 | 214 | status = "passed" 215 | except Exception as e: 216 | status = f"failed: {str(e)}" 217 | finally: 218 | execution_time = time.time() - start_time 219 | log_result(test_name, execution_time, status) 220 | 221 | if __name__ == '__main__': 222 | test_vallado() 223 | try: 224 | test_teme_orekit() 225 | except unittest.case.SkipTest: 226 | print("Skipping test_teme_orekit()") 227 | test_MG_5_1() 228 | 229 | # Save benchmark results to a JSON file 230 | with open("benchmark_results_frame.json", "w") as f: 231 | json.dump(benchmark_results, f) -------------------------------------------------------------------------------- /src/ssapy.cpp: -------------------------------------------------------------------------------- 1 | #include "ssapy.h" 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | namespace ssapy { 8 | 9 | void Ellipsoid::sphereToCart( 10 | const double lon, const double lat, const double height, 11 | double& x, double& y, double& z 12 | ) const { 13 | double slat2 = sin(lat)*sin(lat); 14 | double N = _Req / sqrt(1-_e2*slat2); // MG (5.84) 15 | // MG (5.83) 16 | x = (N+height)*cos(lat)*cos(lon); 17 | y = (N+height)*cos(lat)*sin(lon); 18 | z = (_1mf2*N+height)*sin(lat); 19 | } 20 | 21 | void Ellipsoid::cartToSphere( 22 | const double x, const double y, const double z, 23 | double& lon, double& lat, double& height 24 | ) const { 25 | const double r2 = x*x + y*y; 26 | double dz = _e2*z; 27 | double dz1; 28 | double zdz, slat, N; 29 | while (true) { 30 | // MG (5.87) 31 | zdz = z + dz; 32 | slat = zdz/sqrt(r2+zdz*zdz); 33 | N = _Req/sqrt(1-_e2*slat*slat); 34 | dz1 = N*_e2*slat; 35 | if (fabs(dz-dz1) < 1e-6) break; // micron precision is plenty 36 | dz = dz1; 37 | } 38 | zdz = z+dz; 39 | // MG (5.88) 40 | lon = atan2(y, x); 41 | lat = atan(zdz/sqrt(r2)); 42 | height = sqrt(r2+zdz*zdz) - N; 43 | } 44 | 45 | 46 | double HarrisPriester::density( 47 | const double x, const double y, const double z, 48 | const double ra_Sun, const double dec_Sun 49 | ) const { 50 | const double ra_lag = 0.5235987755982988; 51 | 52 | // Start with height so we can early exit 53 | double lon, lat, height; 54 | _ellip.cartToSphere(x, y, z, lon, lat, height); 55 | height *= 1.e-3; // m -> km 56 | 57 | if (height < _h.front()) throw std::runtime_error("Satellite has burned up on re-entry"); 58 | if (height > _h.back()) return 0.0; 59 | 60 | double cos_dec_Sun = cos(dec_Sun); 61 | double ex_bulge = cos_dec_Sun * cos(ra_Sun + ra_lag); 62 | double ey_bulge = cos_dec_Sun * sin(ra_Sun + ra_lag); 63 | double ez_bulge = sin(dec_Sun); 64 | 65 | // Bulge angle scaling factor MG (3.104) 66 | double cosnpsi2 = pow( 67 | 0.5 + 0.5*(x*ex_bulge + y*ey_bulge + z*ez_bulge)/sqrt(x*x+y*y+z*z), 68 | _n/2 69 | ); 70 | 71 | auto hptr = std::upper_bound(_h.cbegin(), _h.cend(), height); 72 | auto idx = std::distance(_h.cbegin(), hptr); 73 | double rho_antapex = _rho_antapex[idx-1] * exp(-(height-_h[idx-1])/_scale_antapex[idx-1]); // MG (3.101) 74 | double rho_apex = _rho_apex[idx-1] * exp(-(height-_h[idx-1])/_scale_apex[idx-1]); 75 | return (rho_antapex + (rho_apex - rho_antapex)*cosnpsi2) * 1e-12; 76 | } 77 | 78 | const std::array HarrisPriester::_h = { // model heights [km] 79 | 100.0, 120.0, 130.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0, 200.0, 80 | 210.0, 220.0, 230.0, 240.0, 250.0, 260.0, 270.0, 280.0, 290.0, 300.0, 81 | 320.0, 340.0, 360.0, 380.0, 400.0, 420.0, 440.0, 460.0, 480.0, 500.0, 82 | 520.0, 540.0, 560.0, 580.0, 600.0, 620.0, 640.0, 660.0, 680.0, 700.0, 83 | 720.0, 740.0, 760.0, 780.0, 800.0, 840.0, 880.0, 920.0, 960.0,1000.0 84 | }; 85 | 86 | const std::array HarrisPriester::_rho_antapex = { // density at bulge antipode [g/km^3] 87 | 4.974e+05, 2.490e+04, 8.377e+03, 3.899e+03, 2.122e+03, 1.263e+03, 88 | 8.008e+02, 5.283e+02, 3.617e+02, 2.557e+02, 1.839e+02, 1.341e+02, 89 | 9.949e+01, 7.488e+01, 5.709e+01, 4.403e+01, 3.430e+01, 2.697e+01, 90 | 2.139e+01, 1.708e+01, 1.099e+01, 7.214e+00, 4.824e+00, 3.274e+00, 91 | 2.249e+00, 1.558e+00, 1.091e+00, 7.701e-01, 5.474e-01, 3.916e-01, 92 | 2.819e-01, 2.042e-01, 1.488e-01, 1.092e-01, 8.070e-02, 6.012e-02, 93 | 4.519e-02, 3.430e-02, 2.632e-02, 2.043e-02, 1.607e-02, 1.281e-02, 94 | 1.036e-02, 8.496e-03, 7.069e-03, 4.680e-03, 3.200e-03, 2.210e-03, 95 | 1.560e-03, 1.150e-03 96 | }; 97 | 98 | const std::array HarrisPriester::_rho_apex = { // density at bulge [g/km^3] 99 | 4.974e+05, 2.490e+04, 8.710e+03, 4.059e+03, 2.215e+03, 1.344e+03, 100 | 8.758e+02, 6.010e+02, 4.297e+02, 3.162e+02, 2.396e+02, 1.853e+02, 101 | 1.455e+02, 1.157e+02, 9.308e+01, 7.555e+01, 6.182e+01, 5.095e+01, 102 | 4.226e+01, 3.526e+01, 2.511e+01, 1.819e+01, 1.337e+01, 9.955e+00, 103 | 7.492e+00, 5.684e+00, 4.355e+00, 3.362e+00, 2.612e+00, 2.042e+00, 104 | 1.605e+00, 1.267e+00, 1.005e+00, 7.997e-01, 6.390e-01, 5.123e-01, 105 | 4.121e-01, 3.325e-01, 2.691e-01, 2.185e-01, 1.779e-01, 1.452e-01, 106 | 1.190e-01, 9.776e-02, 8.059e-02, 5.741e-02, 4.210e-02, 3.130e-02, 107 | 2.360e-02, 1.810e-02 108 | }; 109 | 110 | const std::array HarrisPriester::_compute_scale_antapex() { 111 | std::array out{}; 112 | for(int idx=1; idx<50; idx++) { 113 | double dh = HarrisPriester::_h[idx] - HarrisPriester::_h[idx-1]; 114 | out[idx-1] = dh/log(HarrisPriester::_rho_antapex[idx-1]/HarrisPriester::_rho_antapex[idx]); 115 | } 116 | return out; 117 | } 118 | 119 | const std::array HarrisPriester::_compute_scale_apex() { 120 | std::array out{}; 121 | for(int idx=1; idx<50; idx++) { 122 | double dh = HarrisPriester::_h[idx] - HarrisPriester::_h[idx-1]; 123 | out[idx-1] = dh/log(HarrisPriester::_rho_apex[idx-1]/HarrisPriester::_rho_apex[idx]); 124 | } 125 | return out; 126 | } 127 | 128 | const std::array HarrisPriester::_scale_antapex = HarrisPriester::_compute_scale_antapex(); 129 | const std::array HarrisPriester::_scale_apex = HarrisPriester::_compute_scale_apex(); 130 | 131 | 132 | void AccelHarmonic::accel( 133 | const double x, const double y, const double z, 134 | const int n_max, const int m_max, 135 | double& ax, double& ay, double& az 136 | ) const { 137 | const double rsq = x*x + y*y + z*z; 138 | const double Rrsqr = _Rsq/rsq; 139 | 140 | const double x0 = _R * x / rsq; 141 | const double y0 = _R * y / rsq; 142 | const double z0 = _R * z / rsq; 143 | 144 | _V(0,0) = _R / sqrt(rsq); // MG (3.31) 145 | _W(0,0) = 0.0; 146 | 147 | _V(1,0) = z0 * _V(0,0); // MG (3.30) 148 | _W(1,0) = 0.0; 149 | 150 | // MG (3.30) with m=0 151 | for (int n=2; n<=n_max+1; n++) { 152 | _V(n,0) = ( (2*n-1)*z0*_V(n-1,0) - (n-1)*Rrsqr*_V(n-2,0))/n; 153 | _W(n,0) = 0.0; 154 | } 155 | 156 | for (int m=1; m<=m_max+1; m++) { 157 | // MG (3.29) 158 | _V(m,m) = (2*m-1)*(x0*_V(m-1,m-1) - y0*_W(m-1,m-1)); 159 | _W(m,m) = (2*m-1)*(x0*_W(m-1,m-1) + y0*_V(m-1,m-1)); 160 | 161 | // MG (3.30) with n=m+1 162 | if (m<=n_max) { 163 | _V(m+1,m) = (2*m+1)*z0*_V(m,m); 164 | _W(m+1,m) = (2*m+1)*z0*_W(m,m); 165 | } 166 | 167 | // MG (3.30) 168 | for(int n=m+2; n<=n_max+1; n++) { 169 | _V(n,m) = ( (2*n-1)*z0*_V(n-1,m) - (n+m-1)*Rrsqr*_V(n-2,m) ) / (n-m); 170 | _W(n,m) = ( (2*n-1)*z0*_W(n-1,m) - (n+m-1)*Rrsqr*_W(n-2,m) ) / (n-m); 171 | } 172 | } 173 | 174 | // acceleration 175 | double C, S; 176 | ax = ay = az = 0.0; 177 | bool overflowed = false; 178 | // MG (3.33) 179 | for (int m=0; m<=m_max; m++) { 180 | if (overflowed) { 181 | break; 182 | } 183 | for (int n=m; n<=n_max; n++) { 184 | if (std::isinf(_V(n+1,1)) || std::isinf(_W(n+1,1)) || 185 | std::isinf(_V(n+1,m+1)) || std::isinf(_W(n+1,m+1)) || 186 | std::isinf(_V(n+1,m-1)) || std::isinf(_W(n+1,m-1)) || 187 | std::isinf(_V(n+1,m)) || std::isinf(_W(n+1,m))) { 188 | overflowed = true; 189 | break; 190 | } 191 | if (m==0) { 192 | C = _CS(n,0); // = C_n,0 193 | ax -= C * _V(n+1,1); 194 | ay -= C * _W(n+1,1); 195 | az -= (n+1)*C * _V(n+1,0); 196 | } else { 197 | C = _CS(n,m); // = C_n,m 198 | S = _CS(m-1,n); // = S_n,m 199 | double factor = 0.5*(n-m+1)*(n-m+2); // (n-m+2)!/(n-m)!/2 200 | ax += 0.5*(-C*_V(n+1,m+1) - S*_W(n+1,m+1)); 201 | ax += factor*(C*_V(n+1,m-1) + S*_W(n+1,m-1)); 202 | ay += 0.5*(-C*_W(n+1,m+1) + S*_V(n+1,m+1)); 203 | ay += factor*(-C*_W(n+1,m-1) + S*_V(n+1,m-1)); 204 | az += (n-m+1)*(-C*_V(n+1,m) - S*_W(n+1,m)); 205 | } 206 | } 207 | } 208 | 209 | ax *= _GMinvRsq; 210 | ay *= _GMinvRsq; 211 | az *= _GMinvRsq; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /docs/source/concepts.rst: -------------------------------------------------------------------------------- 1 | SSAPy Concepts 2 | ============== 3 | 4 | This page explains concepts and terminology used within the SSAPy package. 5 | It is intended to provide a high-level overview, while details can be found in other sections of the documentation. 6 | 7 | .. _def-coordinate-systems: 8 | 9 | Coordinate Systems 10 | ------------------ 11 | A `coordinate system `_ is a set of one or more values which define a unique position in space. The SSAPy package is capable of using a variety of coordinate systems: 12 | 13 | - `Cartesian coordinates `_ 14 | - Right ascension/Declination (``ra``/``dec``) of stars from catalog positions (J2000/ICRS) 15 | - Apparent positions 16 | - NTW 17 | 18 | - ``T`` gives the projection of ``rcoord - r`` along ``V`` (tangent to track) 19 | - ``W`` gives the projection of ``rcoord - r`` along ``(V cross r)`` (normal to plane) 20 | - ``N`` gives the projection of ``rcoord - r`` along ``(V cross (V cross r))`` (in plane, perpendicular to ``T``) 21 | 22 | - theta-phi-like coordinates: the system where ``theta`` is the angle between zenith and the point in question, and ``phi`` is the corresponding azimuthal angle. (radians) 23 | - lb-like coordinates & proper motions 24 | 25 | - If converting from theta-phi, sets ``b = pi - theta`` and renames ``phi`` -> ``l``. Everything is in radians. 26 | 27 | - Orthographic tangent plane (radiants) 28 | 29 | - If converting from lb-like coordinates, the tangent plane is always chosen so that ``+Y`` is towards ``b = 90``, and ``+X`` is towards ``+l``. 30 | 31 | - Geocentric Celestial Reference Frame (GCRF) 32 | - International Terrestrial Reference System (ITRS) 33 | - Geocentric Celestial Reference System (GCRS) Cartesian coordinates 34 | - True Equator Mean Equinox frame (TEME) Cartesian coordinates 35 | 36 | Not every function will be able to make use of every coordinate system, so please be sure to read the documentation associated with a given operation. 37 | 38 | .. _def-time-standards: 39 | 40 | Time Standards 41 | -------------- 42 | 43 | - UTC vs others 44 | 45 | .. _def-orbits: 46 | 47 | Types of Standard Orbits 48 | ------------------------ 49 | While SSAPy is not technically limited to modeling a specific orbit, there are certain types of orbits which are more closely related to SSAPy's capabilities. Those orbit types are listed below: 50 | 51 | - Low Earth Orbit (LEO) 52 | - Mid-earth orbit (MEO) 53 | - Geosynchronous Earth Orbit (GEO) 54 | - Geostationary Earth Orbit 55 | - Highly Elliptical Orbit (HEO) 56 | - Cislunar Orbits 57 | 58 | Of course, many other types of near Earth orbits are possible (i.e. lunar rectilinear halo orbits). The sky, or the universe as it may be, is the limit! 59 | 60 | .. _def-models: 61 | 62 | Types of Models 63 | --------------- 64 | Accurately predicting the path of a spacecraft requires accounting for not only gravity, but also subtle influences like radiation pressure and atmospheric drag. SSAPy provides different models that can be combined depending on needs. 65 | 66 | Gravitational models 67 | ^^^^^^^^^^^^^^^^^^^^ 68 | Several graviational models are provided to balance accuracy and performance. 69 | 70 | A point source model assumes all the mass of the celestial body is concentrated at a single point in space, typically its center. This is a simplification, but useful for long distances or preliminary calculations. 71 | Using a point source model for the Sun or a planet to calculate the gravitational influence on a spacecraft is sufficient for initial orbit design or basic mission planning, but as the spacecraft gets closer, the limitations of the point source become apparent. 72 | 73 | Harmonics describe the variations in the gravitational field across the sphere. 74 | A model with more harmonics turned on provides a more accurate picture of the gravity field, but also requires more complex calculations. 75 | 76 | For Earth and the Moon, more sophisticated models are needed due to their non-uniform mass distribution. These models incorporate: 77 | 78 | - **Spherical Harmonics:** As mentioned above, with higher orders capturing features like mountains and valleys that affect the gravity field. 79 | - **Mascons:** These are specific regions of high or low mass density that significantly impact the gravitational field. 80 | 81 | These complexities make Earth/Lunar modeling more computationally demanding compared to a simple point source, but significantly improve the accuracy of orbit predictions. 82 | 83 | Radiation pressure 84 | ^^^^^^^^^^^^^^^^^^ 85 | Radiation pressure is the force exerted by light or electromagnetic radiation on a surface. In space, where there's minimal drag from other particles, radiation pressure from the Sun can be a significant influence on a spacecraft's trajectory. 86 | 87 | Radiation pressure depends on three key factors: 88 | 89 | 1. **Intensity of Sunlight:** Stronger sunlight exerts a greater force. 90 | 2. **Surface Properties:** Reflective surfaces experience a larger force compared to absorbent ones. 91 | 3. **Spacecraft Orientation:** The angle at which sunlight hits the spacecraft affects the force direction. 92 | 93 | There are different levels of complexity in modeling radiation pressure, depending on the desired accuracy and computational resources: 94 | 95 | - **Cannonball Model:** This is the simplest approach, treating the spacecraft as a perfect sphere. 96 | It calculates the force based on the spacecraft's area-to-mass ratio and reflectivity properties, assuming a constant force along the Sun-spacecraft direction. 97 | It's quick and easy to use, but lacks accuracy for complex spacecraft shapes. 98 | 99 | - **N-Plate Model:** This model approximates the spacecraft using multiple flat plates, each with its own size, orientation, and reflectivity. 100 | It offers more detail than the cannonball model, capturing the effect of different surfaces on the spacecraft. 101 | The number of plates determines the accuracy, but also increases computational complexity. 102 | 103 | - **Ray-Tracing Techniques:** This advanced method uses software that simulates the path of sunlight rays reflecting off the spacecraft's actual 3D geometry. 104 | It provides the most accurate picture of radiation pressure but requires significant computational power. 105 | 106 | The choice of model depends on the specific mission requirements. For initial planning, a cannonball model might suffice. However, high-precision orbit determination for critical missions might require ray-tracing techniques. 107 | 108 | Atmospheric Modeling 109 | ^^^^^^^^^^^^^^^^^^^^ 110 | Atmospheric drag is the resistance a spacecraft experiences due to collisions with gas molecules in a planet's atmosphere. While negligible at high altitudes, it becomes a significant force during atmospheric entry or when operating in low-Earth orbit. 111 | 112 | Accurate atmospheric models are crucial for: 113 | 114 | - Predicting spacecraft re-entry paths to ensure safe landing zones. 115 | - Maintaining orbit stability for low-Earth satellites by compensating for drag-induced orbital decay. 116 | - Optimizing fuel usage by accounting for drag during maneuvers. 117 | 118 | .. _def-numerical-integrators: 119 | 120 | Numerical Integrators 121 | --------------------- 122 | Numerical integrators solve the complex differential equations that govern the motion of a spacecraft under various gravitational and environmental influences. 123 | Different integrators offer trade-offs between accuracy, efficiency, and stability. 124 | 125 | The step size in a numerical integrator defines the time interval between calculations. Smaller step sizes lead to more accurate results but require more computations. Choosing the right step size involves balancing accuracy needs with computational resources. 126 | Different integrators handle step sizes differently: 127 | 128 | - **Fixed-Step Integrators** use a constant time step size for calculations. They are simple to implement but can be inefficient, especially for rapidly changing forces or highly elliptical orbits. 129 | - **Variable-Step Integrators** adjust the time step size dynamically based on the complexity of the motion. They are more efficient for problems with varying forces but can be more complex to implement. 130 | - **Multi-Step Integrators** utilize information from previous time steps to improve accuracy. They can be efficient but may introduce stability issues for certain types of orbits. 131 | 132 | The choice of integrator depends on several factors: 133 | 134 | - **Orbit Type:** Highly elliptical orbits require more sophisticated methods than circular ones. 135 | - **Force Model Complexity:** Simpler models might allow for simpler integrators, while complex models may necessitate more advanced methods. 136 | - **Propagation Time:** Longer propagations benefit from efficient integrators. 137 | - **Desired Accuracy:** Higher accuracy often comes at the cost of increased computation time. 138 | 139 | .. _def-computing-considerations: 140 | 141 | Computing Considerations 142 | ------------------------ 143 | .. 144 | - Many orbits 145 | - vectorization 146 | - orbit sampling and propagation for error estimation (rvsampling) 147 | - Linking observations together 148 | When propagating multiple orbits, make sure to leverage SSAPy's vectorization. 149 | For instance, the :class:`.Orbit` class can represents either a single scalar orbit or a vector of orbits. 150 | 151 | .. _def-other-codes: 152 | 153 | Other Codes 154 | ----------- 155 | Below is a list of other orbit propagation codes, both commercial and free. While these other pieces of software may have some features in common with SSAPy, we believe SSAPy brings a more complete list of capabilities within one package. 156 | 157 | - `General Mission Analysis Tool (GMAT) `_ 158 | - `Ansys Systems Tool Kit (STK) `_ 159 | - `a.i. solutions FreeFlyer Astrodynamics Software `_ 160 | - `MathWorks MATLAB `_ 161 | - `AstroPy `_ 162 | - `REBOUND `_ 163 | - `REBOUNDx `_ 164 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | SSAPy - Space Situational Awareness for Python 2 | ============================================== 3 | 4 | |ci_badge| |docs_badge| |codecov_badge| |joss_badge| |pypi-badge| 5 | 6 | .. |ci_badge| image:: https://github.com/LLNL/SSAPy/actions/workflows/ci.yml/badge.svg 7 | :target: https://github.com/LLNL/SSAPy/actions/workflows/ci.yml 8 | 9 | .. |docs_badge| image:: https://github.com/LLNL/SSAPy/actions/workflows/pages/pages-build-deployment/badge.svg 10 | :target: https://LLNL.github.io/SSAPy 11 | 12 | .. |codecov_badge| image:: https://codecov.io/gh/LLNL/SSAPy/branch/main/graph/badge.svg 13 | :target: https://codecov.io/gh/LLNL/SSAPy 14 | 15 | .. |joss_badge| image:: https://joss.theoj.org/papers/a629353cbdd8d64a861bb807e12c5d06/status.svg 16 | :target: https://joss.theoj.org/papers/a629353cbdd8d64a861bb807e12c5d06 17 | 18 | .. |pypi-badge| image:: https://badge.fury.io/py/llnl-ssapy.svg 19 | :target: https://badge.fury.io/py/llnl-ssapy 20 | 21 | `SSAPy `_ is a fast, flexible, high-fidelity orbital modeling and analysis tool for orbits spanning from low-Earth orbit into the cislunar regime, and includes the following: 22 | 23 | - Ability to define satellite parameters (area, mass, radiation and drag coefficients, etc.) 24 | - Support for multiple data types (e.g., read in orbit from TLE file, define a set of Keplerian, Equinoctial, or Kozai Mean Keplerian elements, etc.) 25 | - Define a fully customizable analytic force propagation model including the following: 26 | - Earth gravity models (WGS84, EGM84, EGM96, EGM2008) 27 | - Lunar gravity model (point source & harmonic) 28 | - Radiation pressure (Earth & solar) 29 | - Forces for all planets out to Neptune 30 | - Atmospheric drag models 31 | - Maneuvering (takes a user defined burn profile) 32 | - Various community used integrators: SGP4, Runge-Kutta (4, 8, and 7/8), SciPy, Keplerian, Taylor Series 33 | - User definable timesteps with the ability to return various parameters for any orbit and at any desired timestep (e.g., magnitude, state vector, TLE, Keplerian elements, periapsis, apoapsis, specific angular momentum, and many more.) 34 | - Ground and space-based observer models 35 | - Location and time of various lighting conditions of interest 36 | - Multiple-hypothesis tracking (MHT) UCT linker 37 | - Vectorized computations (use of array broadcasting for fast computation, easily parallelizable and deployable on HPC machines) 38 | - Short arc probabilistic orbit determination methods 39 | - Conjunction probability estimation 40 | - Built-in uncertainty quantification 41 | - Support for Monte Carlo runs and data fusion 42 | - Support for multiple coordinate frames and coordinate frame conversions (GCRF, IERS, GCRS Cartesian, TEME Cartesian, ra/dec, NTW, zenith/azimuth, apparent positions, orthoginal tangent plane, and many more.) 43 | - Various plotting capabilities (ground tracks, 3D orbit plotting, cislunar trajectory visualization, etc.) 44 | - User definable timesteps and orbit information retrieval times, in which the user can query parameters of interest for that orbit and time. 45 | 46 | Installation 47 | ------------ 48 | 49 | For installation details, see the `Installing SSAPy `_ section of the documentation. 50 | 51 | Strict dependencies 52 | ------------------- 53 | 54 | - `Python `_ (3.8+) 55 | 56 | The following are installed automatically when you install SSAPy: 57 | 58 | - `numpy `_; 59 | - `scipy `_ for many statistical functions; 60 | - `astropy `_ for astronomy related functions; 61 | - `pyerfa `_ a Python wrapper for the ERFA library; 62 | - `emcee `_ an affine-invariant ensemble sampler for Markov chain Monte Carlo; 63 | - `lmfit `_ a package for non-linear least-squares minimization and curve fitting; 64 | - `sgp4 `_ contains functions to compute the positions of satellites in Earth orbit; 65 | - `matplotlib `_ as a plotting backend; 66 | - and other utility packages, as enumerated in `setup.py`. 67 | 68 | Documentation 69 | ------------- 70 | 71 | All documentation is hosted at `https://LLNL.github.io/SSAPy/ `_. 72 | 73 | The API documentation may also be seen by doing: 74 | 75 | .. code-block:: bash 76 | 77 | python3 78 | >>> import ssapy 79 | >>> help(ssapy) 80 | 81 | Contributing 82 | ------------ 83 | 84 | Contributing to SSAPy is relatively easy. Just send us a `pull request `_. When you send your request, make `main` the destination branch on the `SSAPy repository `_. 85 | 86 | Your PR must pass SSAPy's unit tests and documentation tests, and must be `PEP 8 `_ compliant. We enforce these guidelines with our CI process. To run these tests locally, and for helpful tips on git, see our `Contribution Guide `_. 87 | 88 | SSAPy's `main` branch has the latest contributions. Pull requests should target `main`, and users who want the latest package versions, features, etc. can use `main`. 89 | 90 | Releases 91 | -------- 92 | 93 | For multi-user site deployments or other use cases that need very stable software installations, we recommend using SSAPy's `stable releases `_. 94 | 95 | Each SSAPy release series also has a corresponding branch, e.g. `releases/v0.14` has `0.14.x` versions of SSAPy, and `releases/v0.13` has `0.13.x` versions. We backport important bug fixes to these branches but we do not advance the package versions or make other changes that would change the way SSAPy concretizes dependencies within a release branch. So, you can base your SSAPy deployment on a release branch and `git pull` to get fixes, without the package churn that comes with `main`. 96 | 97 | The latest release is always available with the `releases/latest` tag. 98 | 99 | See the `docs on releases `_ for more details. 100 | 101 | Code of Conduct 102 | --------------- 103 | 104 | Please note that SSAPy has a `Code of Conduct `_. By participating in the SSAPy community, you agree to abide by its rules. 105 | 106 | Authors 107 | ------- 108 | 109 | SSAPy was developed with support from Lawrence Livermore National Laboratory's (LLNL) Laboratory Directed Research and Development (LDRD) Program under projects 110 | `19-SI-004 `_ and 111 | `22-ERD-054 `_, by the following individuals (in alphabetical order): 112 | 113 | - `Robert Armstrong `_ (`LLNL `_) 114 | - `Nathan Golovich `_ (`LLNL `_) 115 | - `Julia Ebert `_ (formerly `LLNL `_, now at Fleet Robotics) 116 | - `Noah Lifset `_ (formerly `LLNL `_, now PhD student at `UT Austin `_) 117 | - `Dan Merl `_ (`LLNL `_) - Developer 118 | - `Joshua Meyers `_ (formerly `LLNL `_, now at `KIPAC `_) - Former Lead Developer 119 | - `Caleb Miller `_ (`LLNL `_) 120 | - `Alexx Perloff `_ (`LLNL `_) 121 | - `Kerianne Pruett `_ (formerly `LLNL `_) 122 | - `Edward Schlafly `_ (formerly `LLNL `_, now `STScI `_) - Former Lead Developer 123 | - `Michael Schneider `_ (`LLNL `_) - Creator, Former Lead Developer 124 | - `Travis Yeager `_ (`LLNL `_) - Current Lead Developer 125 | 126 | Many thanks go to SSAPy's other `contributors `_. 127 | 128 | 129 | Citing SSAPy 130 | ^^^^^^^^^^^^ 131 | 132 | On GitHub, you can copy this citation in APA or BibTeX format via the "Cite this repository" button. 133 | If you prefer MLA or Chicago style citations, see the comments in `CITATION.cff `_. 134 | 135 | You may also cite the following publications (click `here `_ for list of BibTeX citations): 136 | 137 | - Yeager, T., Pruett, K., & Schneider, M. (2022). *Unaided Dynamical Orbit Stability in the Cislunar Regime.* [Poster presentation]. Cislunar Security Conference, USA. 138 | - Yeager, T., Pruett, K., & Schneider, M. (2023). *Long-term N-body Stability in Cislunar Space.* [Poster presentation]. Advanced Maui Optical and Space Surveillance (AMOS) Technologies Conference, USA. 139 | - Yeager, T., Pruett, K., & Schneider, M. (2023, September). Long-term N-body Stability in Cislunar Space. In S. Ryan (Ed.), *Proceedings of the Advanced Maui Optical and Space Surveillance (AMOS) Technologies Conference* (p. 208). Retrieved from `https://amostech.com/TechnicalPapers/2023/Poster/Yeager.pdf `_ 140 | 141 | License 142 | ------- 143 | 144 | SSAPy is distributed under the terms of the MIT license. All new contributions must be made under the MIT license. 145 | 146 | See `Link to license `_ and `NOTICE `_ for details. 147 | 148 | SPDX-License-Identifier: MIT 149 | 150 | LLNL-CODE-862420 151 | 152 | Documentation Inspiration 153 | ------------------------- 154 | The structure and organization of this repository's documentation were inspired by the excellent design and layout of the `Coffea `_ project. 155 | -------------------------------------------------------------------------------- /ssapy/body.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes representing celestial bodies. 3 | """ 4 | 5 | import erfa 6 | import numpy as np 7 | from .utils import _gpsToTT, iers_interp 8 | from .constants import EARTH_MU, EARTH_RADIUS, MOON_MU, SUN_MU, MERCURY_MU, VENUS_MU, MARS_MU, JUPITER_MU, SATURN_MU, URANUS_MU, NEPTUNE_MU, MERCURY_RADIUS, VENUS_RADIUS, MARS_RADIUS, JUPITER_RADIUS, SATURN_RADIUS, URANUS_RADIUS, NEPTUNE_RADIUS 9 | from .gravity import HarmonicCoefficients 10 | 11 | 12 | class EarthOrientation: 13 | """Orientation of earth in GCRF. This is a callable class that returns the 14 | orientation matrix at a given time. 15 | 16 | Parameters 17 | ---------- 18 | recalc_threshold : float 19 | Threshold for recomputing the orientation matrix. Default is 30 days. 20 | 21 | """ 22 | def __init__(self, recalc_threshold=86400 * 30): 23 | self.recalc_threshold = recalc_threshold 24 | self._t = None 25 | 26 | def __call__(self, t, _E=None): 27 | """Return the orientation matrix at time t. 28 | 29 | Parameters 30 | ---------- 31 | t : float 32 | Time in GPS seconds. 33 | 34 | Returns 35 | ------- 36 | E : `numpy.ndarray` 37 | Orientation matrix at time t. 38 | """ 39 | if _E is None: 40 | mjd_tt = _gpsToTT(t) 41 | if self._t is None or np.abs(t - self._t) > self.recalc_threshold: 42 | self._t = t 43 | self._dut1, _, _ = iers_interp(t) 44 | self._T = erfa.pnm80(2400000.5, mjd_tt) 45 | gst = erfa.gst94(2400000.5, mjd_tt + self._dut1) 46 | _E = erfa.rxr(erfa.rv2m([0, 0, gst]), self._T) 47 | return _E 48 | 49 | 50 | class MoonOrientation: 51 | """Orientation of moon in GCRF. This is a callable class that returns the 52 | orientation matrix at a given time. 53 | """ 54 | def __init__(self): 55 | import os 56 | from jplephem.pck import PCK 57 | from . import datadir 58 | 59 | fn = os.path.join(datadir, "moon_pa_de440_200625.bpc") 60 | self.kernel = PCK.open(fn) 61 | 62 | def __call__(self, t, _E=None): 63 | """Return the orientation matrix at time t. 64 | 65 | Parameters 66 | ---------- 67 | t : float 68 | Time in GPS seconds. 69 | 70 | Returns 71 | ------- 72 | E : `numpy.ndarray` 73 | Orientation matrix at time t. 74 | """ 75 | mjd_tt = _gpsToTT(t) 76 | value, _ = self.kernel.segments[0].compute(2400000.5, mjd_tt) 77 | 78 | return (self._Rz(-value[0]) @ self._Rx(-value[1]) @ self._Rz(-value[2])).T 79 | 80 | @staticmethod 81 | def _Rx(alpha): 82 | ca, sa = np.cos(alpha), np.sin(alpha) 83 | out = np.eye(3) 84 | out[1, 1] = ca 85 | out[1, 2] = sa 86 | out[2, 1] = -sa 87 | out[2, 2] = ca 88 | return out 89 | 90 | @staticmethod 91 | def _Rz(alpha): 92 | ca, sa = np.cos(alpha), np.sin(alpha) 93 | out = np.eye(3) 94 | out[0, 0] = ca 95 | out[0, 1] = sa 96 | out[1, 0] = -sa 97 | out[1, 1] = ca 98 | return out 99 | 100 | 101 | class MoonPosition: 102 | """Position of moon in GCRF. This is a callable class that returns the 103 | position vector at a given time. 104 | """ 105 | def __init__(self): 106 | import os 107 | from jplephem.spk import SPK 108 | from . import datadir 109 | 110 | fn = os.path.join(datadir, "de430.bsp") # https://naif.jpl.nasa.gov/pub/naif/LUCY/kernels/spk/de430s.bsp.lbl 111 | self.kernel = SPK.open(fn) 112 | 113 | def __call__(self, t): 114 | """Return the position vector at time t. 115 | 116 | Parameters 117 | ---------- 118 | t : float 119 | Time in GPS seconds. 120 | 121 | Returns 122 | ------- 123 | pos : `numpy.ndarray` 124 | Position vector at time t in meters. 125 | """ 126 | mjd_tt = _gpsToTT(t) 127 | pos = self.kernel[3, 301].compute(2400000.5, mjd_tt) # Earth-moon barycenter -> moon 128 | pos -= self.kernel[3, 399].compute(2400000.5, mjd_tt) # Earth-moon barycenter -> earth 129 | return pos * 1e3 130 | 131 | 132 | class SunPosition: 133 | """Position of sun in GCRF. This is a callable class that returns the 134 | position vector at a given time. 135 | """ 136 | def __init__(self): 137 | import os 138 | from jplephem.spk import SPK 139 | from . import datadir 140 | 141 | fn = os.path.join(datadir, "de430.bsp") # https://naif.jpl.nasa.gov/pub/naif/LUCY/kernels/spk/de430s.bsp.lbl 142 | self.kernel = SPK.open(fn) 143 | 144 | def __call__(self, t): 145 | """Return the position vector at time t. 146 | 147 | Parameters 148 | ---------- 149 | t : float 150 | Time in GPS seconds. 151 | 152 | Returns 153 | ------- 154 | pos : `numpy.ndarray` 155 | Position vector at time t in meters. 156 | """ 157 | mjd_tt = _gpsToTT(t) 158 | 159 | pos = self.kernel[0, 10].compute(2400000.5, mjd_tt) # SS bary -> sun 160 | pos -= self.kernel[0, 3].compute(2400000.5, mjd_tt) # SS bary -> Earth-moon bary 161 | pos -= self.kernel[3, 399].compute(2400000.5, mjd_tt) # Earth-moon bary -> Earth 162 | return pos * 1e3 163 | 164 | 165 | class PlanetPosition: 166 | """Position of a planet in GCRF. This is a callable class that returns the 167 | position vector at a given time. 168 | """ 169 | def __init__(self, planet_index): 170 | import os 171 | from jplephem.spk import SPK 172 | from . import datadir 173 | 174 | fn = os.path.join(datadir, "de430.bsp") 175 | self.kernel = SPK.open(fn) 176 | self.planet_index = planet_index 177 | 178 | def __call__(self, t): 179 | """Return the position vector at time t. 180 | 181 | Parameters 182 | ---------- 183 | t : float 184 | Time in GPS seconds. 185 | 186 | Returns 187 | ------- 188 | pos : `numpy.ndarray` 189 | Position vector at time t in meters. 190 | """ 191 | mjd_tt = _gpsToTT(t) 192 | pos = self.kernel[0, self.planet_index].compute(2400000.5, mjd_tt) # SS bary -> Jupiter 193 | pos -= self.kernel[0, 3].compute(2400000.5, mjd_tt) # SS bary -> Earth-moon bary 194 | pos -= self.kernel[3, 399].compute(2400000.5, mjd_tt) # Earth-moon bary -> Earth 195 | return pos * 1e3 196 | 197 | 198 | class Body: 199 | """A celestial body. 200 | 201 | Parameters 202 | ---------- 203 | mu : `float` 204 | Gravitational parameter of the body in m^3/s^2. 205 | radius : `float` 206 | Radius of the body in meters. 207 | position : callable, optional 208 | A callable that returns the position vector of the body in GCRF at a 209 | given time. [default: zero vector] 210 | orientation : callable, optional 211 | A callable that returns the orientation matrix of the body in GCRF at a 212 | given time. [default: identity matrix] 213 | harmonics : `HarmonicCoefficients`, optional 214 | Harmonic coefficients for the body. [default: None] 215 | """ 216 | def __init__( 217 | self, 218 | mu, 219 | radius, 220 | position=lambda t: np.zeros(3), 221 | orientation=lambda t: np.eye(3), 222 | harmonics=None 223 | ): 224 | self.mu = mu 225 | self.radius = radius 226 | self.position = position 227 | self.orientation = orientation 228 | self.harmonics = harmonics 229 | 230 | 231 | def get_body(name, model=None): 232 | """ 233 | Get a Body object for a named body. 234 | 235 | Parameters 236 | ---------- 237 | name : str 238 | Name of the body. Must be one of "earth", "moon", "sun", or other supported planets. 239 | model : str, optional only available for Earth 240 | Name of the Earth harmonic model to use. Default is EGM84. options: EGM96, EGM2008. 241 | 242 | Returns 243 | ------- 244 | body : Body 245 | Body object for the named body. 246 | """ 247 | if name.lower() == "earth": 248 | if model is None: 249 | return Body( 250 | mu=EARTH_MU, 251 | radius=EARTH_RADIUS, 252 | orientation=EarthOrientation(), 253 | harmonics=HarmonicCoefficients.fromEGM("EGM84") 254 | ) 255 | else: 256 | if model == "1984" or model == "84": 257 | model = "EGM84" 258 | elif model == "1996" or model == "96": 259 | model = "EGM96" 260 | elif model == "2008" or model == "08": 261 | model = "EGM2008" 262 | return Body( 263 | mu=EARTH_MU, 264 | radius=EARTH_RADIUS, 265 | orientation=EarthOrientation(), 266 | harmonics=HarmonicCoefficients.fromEGM(model) 267 | ) 268 | elif name.lower() == "moon": 269 | return Body( 270 | mu=MOON_MU, 271 | radius=1737.4e3, 272 | position=MoonPosition(), 273 | orientation=MoonOrientation(), 274 | harmonics=HarmonicCoefficients.fromTAB("gggrx_1200a_sha.tab") 275 | ) 276 | elif name.lower() == "sun": 277 | return Body( 278 | mu=SUN_MU, 279 | radius=695700000.0, 280 | position=SunPosition(), 281 | ) 282 | elif name.lower() == "mercury": 283 | return Body( 284 | mu=MERCURY_MU, 285 | radius=MERCURY_RADIUS, 286 | position=PlanetPosition(planet_index=1), 287 | ) 288 | elif name.lower() == "venus": 289 | return Body( 290 | mu=VENUS_MU, 291 | radius=VENUS_RADIUS, 292 | position=PlanetPosition(planet_index=2), 293 | ) 294 | elif name.lower() == "mars": 295 | return Body( 296 | mu=MARS_MU, 297 | radius=MARS_RADIUS, 298 | position=PlanetPosition(planet_index=4), 299 | ) 300 | elif name.lower() == "jupiter": 301 | return Body( 302 | mu=JUPITER_MU, 303 | radius=JUPITER_RADIUS, 304 | position=PlanetPosition(planet_index=5), 305 | ) 306 | elif name.lower() == "saturn": 307 | return Body( 308 | mu=SATURN_MU, 309 | radius=SATURN_RADIUS, 310 | position=PlanetPosition(planet_index=6), 311 | ) 312 | elif name.lower() == "uranus": 313 | return Body( 314 | mu=URANUS_MU, 315 | radius=URANUS_RADIUS, 316 | position=PlanetPosition(planet_index=7), 317 | ) 318 | elif name.lower() == "neptune": 319 | return Body( 320 | mu=NEPTUNE_MU, 321 | radius=NEPTUNE_RADIUS, 322 | position=PlanetPosition(planet_index=8), 323 | ) 324 | else: 325 | raise ValueError(f"Unknown body {name}") 326 | -------------------------------------------------------------------------------- /pysrc/ssapy.cpp: -------------------------------------------------------------------------------- 1 | #include "ssapy.h" 2 | #include 3 | #include 4 | 5 | namespace py = pybind11; 6 | using namespace pybind11::literals; 7 | 8 | namespace ssapy { 9 | void pyExportSSAPy(py::module& m) { 10 | py::class_>(m, "Ellipsoid", 11 | "An ellipsoid representation for coordinate transformations.\n\n" 12 | "Represents a reference ellipsoid (such as WGS84) for converting between\n" 13 | "geodetic coordinates (longitude, latitude, height) and Earth-Centered\n" 14 | "Earth-Fixed (ECEF) Cartesian coordinates (x, y, z).\n\n" 15 | "Parameters\n" 16 | "----------\n" 17 | "Req : float, optional\n" 18 | " Equatorial radius in meters. Default is WGS84 value (6378137.0 m).\n" 19 | "f : float, optional\n" 20 | " Flattening parameter. Default is WGS84 value (1/298.257223563).\n\n" 21 | "Examples\n" 22 | "--------\n" 23 | ">>> ellipsoid = Ellipsoid() # WGS84 ellipsoid\n" 24 | ">>> ellipsoid = Ellipsoid(6378137.0, 1/298.257223563) # Custom ellipsoid" 25 | ) 26 | .def(py::init(), 27 | "Req"_a=6378.137e3, "f"_a=1.0/298.257223563, 28 | "Initialize ellipsoid with equatorial radius and flattening.\n\n" 29 | "Parameters\n" 30 | "----------\n" 31 | "Req : float\n" 32 | " Equatorial radius in meters\n" 33 | "f : float\n" 34 | " Flattening parameter (dimensionless)" 35 | ) 36 | .def("_sphereToCart", 37 | []( 38 | const Ellipsoid& e, 39 | size_t lonarr, size_t latarr, size_t heightarr, size_t size, 40 | size_t xarr, size_t yarr, size_t zarr 41 | ) { 42 | double* lonptr = reinterpret_cast(lonarr); 43 | double* latptr = reinterpret_cast(latarr); 44 | double* heightptr = reinterpret_cast(heightarr); 45 | double* xptr = reinterpret_cast(xarr); 46 | double* yptr = reinterpret_cast(yarr); 47 | double* zptr = reinterpret_cast(zarr); 48 | for(int i=0; i(xarr); 75 | double* yptr = reinterpret_cast(yarr); 76 | double* zptr = reinterpret_cast(zarr); 77 | double* lonptr = reinterpret_cast(lonarr); 78 | double* latptr = reinterpret_cast(latarr); 79 | double* heightptr = reinterpret_cast(heightarr); 80 | for(int i=0; i(m, "HarrisPriester", 102 | "Harris-Priester atmospheric density model.\n\n" 103 | "Implements the Harris-Priester semi-empirical atmospheric density\n" 104 | "model for calculating atmospheric drag effects on satellites.\n" 105 | "This model accounts for diurnal density variations and solar activity.\n\n" 106 | "Parameters\n" 107 | "----------\n" 108 | "ellip : Ellipsoid\n" 109 | " Reference ellipsoid for coordinate transformations\n" 110 | "n : float, optional\n" 111 | " Density variation parameter (default: 3.0)\n\n" 112 | "References\n" 113 | "----------\n" 114 | "Harris, I. and Priester, W. (1962). Time-dependent structure of the\n" 115 | "upper atmosphere. Journal of Atmospheric Sciences, 19(4), 286-301." 116 | ) 117 | .def(py::init(), 118 | "ellip"_a, "n"_a=3.0, py::keep_alive<1, 2>(), 119 | "Initialize Harris-Priester atmospheric model.\n\n" 120 | "Parameters\n" 121 | "----------\n" 122 | "ellip : Ellipsoid\n" 123 | " Reference ellipsoid for coordinate transformations\n" 124 | "n : float\n" 125 | " Density variation parameter (typically 2-6)" 126 | ) 127 | .def("density", &HarrisPriester::density, 128 | "Calculate atmospheric density at given position and time.\n\n" 129 | "Parameters\n" 130 | "----------\n" 131 | "position : array-like\n" 132 | " Position vector in ECEF coordinates (meters)\n" 133 | "time : float\n" 134 | " Time (typically in seconds since epoch)\n\n" 135 | "Returns\n" 136 | "-------\n" 137 | "float\n" 138 | " Atmospheric density in kg/m³" 139 | ); 140 | 141 | py::class_(m, "AccelHarmonic", 142 | "Spherical harmonic gravitational acceleration model.\n\n" 143 | "Computes gravitational acceleration using spherical harmonic expansion\n" 144 | "of the gravitational potential. This allows for high-fidelity modeling\n" 145 | "of non-uniform gravitational fields (e.g., Earth's J2, J3, J4 terms).\n\n" 146 | "Parameters\n" 147 | "----------\n" 148 | "GM : float\n" 149 | " Gravitational parameter (m³/s²)\n" 150 | "R : float\n" 151 | " Reference radius (meters)\n" 152 | "ncol : int\n" 153 | " Number of columns in coefficient matrix\n" 154 | "CSptr : memory address\n" 155 | " Pointer to spherical harmonic coefficients array\n\n" 156 | "Notes\n" 157 | "-----\n" 158 | "The coefficient matrix should contain normalized spherical harmonic\n" 159 | "coefficients Cnm and Snm arranged in the standard format." 160 | ) 161 | .def(py::init( 162 | [](const double GM, const double R, const int ncol, size_t CSptr) { 163 | return new AccelHarmonic(GM, R, ncol, reinterpret_cast(CSptr)); 164 | }), 165 | "GM"_a, "R"_a, "ncol"_a, "CSptr"_a, 166 | "Initialize spherical harmonic acceleration model.\n\n" 167 | "Parameters\n" 168 | "----------\n" 169 | "GM : float\n" 170 | " Gravitational parameter in m³/s²\n" 171 | "R : float\n" 172 | " Reference radius in meters\n" 173 | "ncol : int\n" 174 | " Number of columns in coefficient matrix\n" 175 | "CSptr : int\n" 176 | " Memory address of coefficient array" 177 | ) 178 | .def("accel", 179 | []( 180 | const AccelHarmonic& ah, 181 | const int n_max, const int m_max, 182 | size_t inptr, size_t outptr 183 | ){ 184 | double* in = reinterpret_cast(inptr); 185 | double* out = reinterpret_cast(outptr); 186 | ah.accel(in[0], in[1], in[2], n_max, m_max, out[0], out[1], out[2]); 187 | }, 188 | "n_max"_a, "m_max"_a, "inptr"_a, "outptr"_a, 189 | "Calculate gravitational acceleration using spherical harmonics.\n\n" 190 | "Computes acceleration vector at a given position using spherical\n" 191 | "harmonic expansion up to specified degree and order.\n\n" 192 | "Parameters\n" 193 | "----------\n" 194 | "n_max : int\n" 195 | " Maximum degree of spherical harmonic expansion\n" 196 | "m_max : int\n" 197 | " Maximum order of spherical harmonic expansion\n" 198 | "inptr : int\n" 199 | " Memory address of input position vector [x, y, z]\n" 200 | "outptr : int\n" 201 | " Memory address of output acceleration vector [ax, ay, az]\n\n" 202 | "Notes\n" 203 | "-----\n" 204 | "This is a low-level method. Use higher-level Python wrappers\n" 205 | "for more convenient access to gravitational acceleration calculations." 206 | ); 207 | } 208 | 209 | PYBIND11_MODULE(_ssapy, m) { 210 | m.doc() = "SSAPy C++ Extension Module\n\n" 211 | "High-performance orbital mechanics computations for the SSAPy\n" 212 | "(Space Situational Awareness for Python) package.\n\n" 213 | "This module provides optimized C++ implementations of:\n" 214 | "- Ellipsoid coordinate transformations\n" 215 | "- Atmospheric density models (Harris-Priester)\n" 216 | "- Spherical harmonic gravitational acceleration"; 217 | 218 | pyExportSSAPy(m); 219 | } 220 | } --------------------------------------------------------------------------------