Feature/tle propagation - #115
Merged
Merged
Conversation
added 6 commits
July 14, 2026 11:12
Path A: Orbit.fromTLETuple now retains the native SGP4 record (Satrec + raw lines), and SGP4Propagator propagates with it when present. This preserves B* and the mean-motion-rate terms that parse_tle discards, so output matches direct sgp4.api.Satrec to machine precision, including for drag-sensitive LEO (ISS: 7.15 km/day error -> ~1e-10 m). _sat is carried through the scalar/vector Orbit copies in _countOrbit, __next__, and __getitem__ (as kozaiMeanKeplerianElements already is). Path B: new ssapy.tle_drag module seeds a physical Cd*A/m from B* and fits it by OD against a reference arc, so the numerical force model (two-body + geopotential + Sun/Moon + Harris-Priester drag) tracks the arc. This is a distinct dynamical product, validated against truth rather than SGP4; the SGP4-vs-numerical residual floor is model difference, not integration error. Adds tests/test_tle_drag.py and examples/tle_drag_paths.py.
SGP4Propagator applied a single epoch-time TEME->GCRF rotation to every output. SGP4 output is TEME of date, so the precession-nutation rotation must be evaluated at each output time; the single-epoch approximation leaves a slowly growing frame error that masquerades as propagator error against a GCRF ephemeris. Measured vs astropy TEME->GCRS: object single-epoch @ 12 h per-timestep ISS 2.2 m ~0.01 m GEO 13.5 m (26.5 m @ 24h) ~0.05 m Changes: - utils.gcrf_to_teme now vectorizes over time arrays (the [0,0,era-gst] rotation vector was ragged and raised for array input, despite the docstring promising array support); scalar results are bit-identical. - SGP4Propagator rotates each output at its own time by default (t=None). Passing t pins a single fixed-epoch rotation (faster, approximate) and recovers the legacy behaviour. Velocity uses the same per-time rotation (the frame-rate term is sub-mm/s and neglected, as before). The Path A exactness test/example references are updated to rotate per-timestep to match, so Path A still reproduces direct sgp4 to ~1e-9 m. Adds tests/test_frame_perstep.py (array support; per-timestep vs single-epoch validated against astropy for ISS and GEO).
- Update the JOSS badge to the published DOI (10.21105/joss.08147) and add the paper as the primary citation in 'Citing SSAPy', with a BibTeX entry (Meyers et al. 2025, JOSS 10(111), 8147). The section previously listed only conference posters and omitted the JOSS paper. - Add a dedicated 'SSAPy-Toolkit' section so the companion project and its higher-level capabilities (convenience workflows, plotting, GCRF-to-ITRF helpers, Lambertian magnitude/brightness, related extensions) are surfaced prominently rather than only as scattered inline asides.
- README linked docs/source/citations.bib, which does not exist; the bib
file is docs/source/refs.bib. Point the 'BibTeX entries' link there.
- docs/source/refs.bib carried the JOSS paper as an unpublished @inproceedings
('Submitted to', 2024) with a misspelled author. Update it to the published
@Article (Meyers et al. 2025, JOSS 10(111), 8147, doi 10.21105/joss.08147),
matching the citation now shown in the README. Citation key unchanged.
fit_drag holds the epoch state fixed and solves only for a scalar Cd*A/m, so any error in that state is absorbed into the drag coefficient and biases it. Add fit_orbit_drag, which estimates the full 6-element GCRF epoch state together with Cd*A/m (optionally with velocity observations and a returned parameter covariance) by scaled least squares against a reference arc. Demonstration (self-consistent numerical truth arc, epoch state perturbed by ~210 m / 0.18 m/s, true Cd*A/m = 5e-3): drag-only (state frozen): Cd*A/m -> 3.3e-1 (+6400%), RMS floor ~1.7 km joint (state + drag): Cd*A/m -> 5e-3 (~0%), state recovered to ~1e-7 m Also fix fit_drag to propagate the whole time array in a single dense-output integration instead of re-integrating once per output time (the array form is what makes the joint fit tractable). Adds fit_orbit_drag (with solve_drag=False state-only mode and optional covariance), two tests, and a joint-fit demo in examples/tle_drag_paths.py.
…FS pointer CI does not pull the Git LFS gravity/ephemeris data, so building the numerical drag propagator (get_body -> EGM84/DE430) fails there. The three Path B tests that need it (fit_drag and the joint fits) now skip in that case, matching the HAS_BODY_DATA / _is_lfs_pointer guard already used in tests/test_accel.py. They still run wherever the real data is present. Path A and frame tests are unaffected (no gravity data needed).
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #115 +/- ##
==========================================
- Coverage 59.89% 58.81% -1.07%
==========================================
Files 17 18 +1
Lines 5574 5730 +156
==========================================
+ Hits 3338 3370 +32
- Misses 2236 2360 +124
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
SuperdoerTrav
added a commit
that referenced
this pull request
Jul 14, 2026
* Add exact-SGP4 (Path A) and physical-drag (Path B) TLE propagation
Path A: Orbit.fromTLETuple now retains the native SGP4 record (Satrec +
raw lines), and SGP4Propagator propagates with it when present. This
preserves B* and the mean-motion-rate terms that parse_tle discards, so
output matches direct sgp4.api.Satrec to machine precision, including for
drag-sensitive LEO (ISS: 7.15 km/day error -> ~1e-10 m). _sat is carried
through the scalar/vector Orbit copies in _countOrbit, __next__, and
__getitem__ (as kozaiMeanKeplerianElements already is).
Path B: new ssapy.tle_drag module seeds a physical Cd*A/m from B* and fits
it by OD against a reference arc, so the numerical force model
(two-body + geopotential + Sun/Moon + Harris-Priester drag) tracks the arc.
This is a distinct dynamical product, validated against truth rather than
SGP4; the SGP4-vs-numerical residual floor is model difference, not
integration error.
Adds tests/test_tle_drag.py and examples/tle_drag_paths.py.
* SGP4Propagator: per-output-time TEME->GCRF rotation by default
SGP4Propagator applied a single epoch-time TEME->GCRF rotation to every
output. SGP4 output is TEME of date, so the precession-nutation rotation
must be evaluated at each output time; the single-epoch approximation
leaves a slowly growing frame error that masquerades as propagator error
against a GCRF ephemeris. Measured vs astropy TEME->GCRS:
object single-epoch @ 12 h per-timestep
ISS 2.2 m ~0.01 m
GEO 13.5 m (26.5 m @ 24h) ~0.05 m
Changes:
- utils.gcrf_to_teme now vectorizes over time arrays (the [0,0,era-gst]
rotation vector was ragged and raised for array input, despite the
docstring promising array support); scalar results are bit-identical.
- SGP4Propagator rotates each output at its own time by default (t=None).
Passing t pins a single fixed-epoch rotation (faster, approximate) and
recovers the legacy behaviour. Velocity uses the same per-time rotation
(the frame-rate term is sub-mm/s and neglected, as before).
The Path A exactness test/example references are updated to rotate
per-timestep to match, so Path A still reproduces direct sgp4 to ~1e-9 m.
Adds tests/test_frame_perstep.py (array support; per-timestep vs
single-epoch validated against astropy for ISS and GEO).
* README: cite the published JOSS paper and highlight SSAPy-Toolkit
- Update the JOSS badge to the published DOI (10.21105/joss.08147) and add
the paper as the primary citation in 'Citing SSAPy', with a BibTeX entry
(Meyers et al. 2025, JOSS 10(111), 8147). The section previously listed only
conference posters and omitted the JOSS paper.
- Add a dedicated 'SSAPy-Toolkit' section so the companion project and its
higher-level capabilities (convenience workflows, plotting, GCRF-to-ITRF
helpers, Lambertian magnitude/brightness, related extensions) are surfaced
prominently rather than only as scattered inline asides.
* docs: fix broken bib link and update JOSS entry to published version
- README linked docs/source/citations.bib, which does not exist; the bib
file is docs/source/refs.bib. Point the 'BibTeX entries' link there.
- docs/source/refs.bib carried the JOSS paper as an unpublished @inproceedings
('Submitted to', 2024) with a misspelled author. Update it to the published
@Article (Meyers et al. 2025, JOSS 10(111), 8147, doi 10.21105/joss.08147),
matching the citation now shown in the README. Citation key unchanged.
* Path B: joint state + ballistic-coefficient orbit-determination fit
fit_drag holds the epoch state fixed and solves only for a scalar Cd*A/m,
so any error in that state is absorbed into the drag coefficient and biases
it. Add fit_orbit_drag, which estimates the full 6-element GCRF epoch state
together with Cd*A/m (optionally with velocity observations and a returned
parameter covariance) by scaled least squares against a reference arc.
Demonstration (self-consistent numerical truth arc, epoch state perturbed by
~210 m / 0.18 m/s, true Cd*A/m = 5e-3):
drag-only (state frozen): Cd*A/m -> 3.3e-1 (+6400%), RMS floor ~1.7 km
joint (state + drag): Cd*A/m -> 5e-3 (~0%), state recovered to ~1e-7 m
Also fix fit_drag to propagate the whole time array in a single dense-output
integration instead of re-integrating once per output time (the array form
is what makes the joint fit tractable).
Adds fit_orbit_drag (with solve_drag=False state-only mode and optional
covariance), two tests, and a joint-fit demo in examples/tle_drag_paths.py.
* tests: skip numerical-propagator drag tests when body data is a Git LFS pointer
CI does not pull the Git LFS gravity/ephemeris data, so building the
numerical drag propagator (get_body -> EGM84/DE430) fails there. The three
Path B tests that need it (fit_drag and the joint fits) now skip in that
case, matching the HAS_BODY_DATA / _is_lfs_pointer guard already used in
tests/test_accel.py. They still run wherever the real data is present.
Path A and frame tests are unaffected (no gravity data needed).
---------
Co-authored-by: dev <dev@local>
SuperdoerTrav
added a commit
that referenced
this pull request
Jul 14, 2026
* Add exact-SGP4 (Path A) and physical-drag (Path B) TLE propagation
Path A: Orbit.fromTLETuple now retains the native SGP4 record (Satrec +
raw lines), and SGP4Propagator propagates with it when present. This
preserves B* and the mean-motion-rate terms that parse_tle discards, so
output matches direct sgp4.api.Satrec to machine precision, including for
drag-sensitive LEO (ISS: 7.15 km/day error -> ~1e-10 m). _sat is carried
through the scalar/vector Orbit copies in _countOrbit, __next__, and
__getitem__ (as kozaiMeanKeplerianElements already is).
Path B: new ssapy.tle_drag module seeds a physical Cd*A/m from B* and fits
it by OD against a reference arc, so the numerical force model
(two-body + geopotential + Sun/Moon + Harris-Priester drag) tracks the arc.
This is a distinct dynamical product, validated against truth rather than
SGP4; the SGP4-vs-numerical residual floor is model difference, not
integration error.
Adds tests/test_tle_drag.py and examples/tle_drag_paths.py.
* SGP4Propagator: per-output-time TEME->GCRF rotation by default
SGP4Propagator applied a single epoch-time TEME->GCRF rotation to every
output. SGP4 output is TEME of date, so the precession-nutation rotation
must be evaluated at each output time; the single-epoch approximation
leaves a slowly growing frame error that masquerades as propagator error
against a GCRF ephemeris. Measured vs astropy TEME->GCRS:
object single-epoch @ 12 h per-timestep
ISS 2.2 m ~0.01 m
GEO 13.5 m (26.5 m @ 24h) ~0.05 m
Changes:
- utils.gcrf_to_teme now vectorizes over time arrays (the [0,0,era-gst]
rotation vector was ragged and raised for array input, despite the
docstring promising array support); scalar results are bit-identical.
- SGP4Propagator rotates each output at its own time by default (t=None).
Passing t pins a single fixed-epoch rotation (faster, approximate) and
recovers the legacy behaviour. Velocity uses the same per-time rotation
(the frame-rate term is sub-mm/s and neglected, as before).
The Path A exactness test/example references are updated to rotate
per-timestep to match, so Path A still reproduces direct sgp4 to ~1e-9 m.
Adds tests/test_frame_perstep.py (array support; per-timestep vs
single-epoch validated against astropy for ISS and GEO).
* README: cite the published JOSS paper and highlight SSAPy-Toolkit
- Update the JOSS badge to the published DOI (10.21105/joss.08147) and add
the paper as the primary citation in 'Citing SSAPy', with a BibTeX entry
(Meyers et al. 2025, JOSS 10(111), 8147). The section previously listed only
conference posters and omitted the JOSS paper.
- Add a dedicated 'SSAPy-Toolkit' section so the companion project and its
higher-level capabilities (convenience workflows, plotting, GCRF-to-ITRF
helpers, Lambertian magnitude/brightness, related extensions) are surfaced
prominently rather than only as scattered inline asides.
* docs: fix broken bib link and update JOSS entry to published version
- README linked docs/source/citations.bib, which does not exist; the bib
file is docs/source/refs.bib. Point the 'BibTeX entries' link there.
- docs/source/refs.bib carried the JOSS paper as an unpublished @inproceedings
('Submitted to', 2024) with a misspelled author. Update it to the published
@Article (Meyers et al. 2025, JOSS 10(111), 8147, doi 10.21105/joss.08147),
matching the citation now shown in the README. Citation key unchanged.
* Path B: joint state + ballistic-coefficient orbit-determination fit
fit_drag holds the epoch state fixed and solves only for a scalar Cd*A/m,
so any error in that state is absorbed into the drag coefficient and biases
it. Add fit_orbit_drag, which estimates the full 6-element GCRF epoch state
together with Cd*A/m (optionally with velocity observations and a returned
parameter covariance) by scaled least squares against a reference arc.
Demonstration (self-consistent numerical truth arc, epoch state perturbed by
~210 m / 0.18 m/s, true Cd*A/m = 5e-3):
drag-only (state frozen): Cd*A/m -> 3.3e-1 (+6400%), RMS floor ~1.7 km
joint (state + drag): Cd*A/m -> 5e-3 (~0%), state recovered to ~1e-7 m
Also fix fit_drag to propagate the whole time array in a single dense-output
integration instead of re-integrating once per output time (the array form
is what makes the joint fit tractable).
Adds fit_orbit_drag (with solve_drag=False state-only mode and optional
covariance), two tests, and a joint-fit demo in examples/tle_drag_paths.py.
* tests: skip numerical-propagator drag tests when body data is a Git LFS pointer
CI does not pull the Git LFS gravity/ephemeris data, so building the
numerical drag propagator (get_body -> EGM84/DE430) fails there. The three
Path B tests that need it (fit_drag and the joint fits) now skip in that
case, matching the HAS_BODY_DATA / _is_lfs_pointer guard already used in
tests/test_accel.py. They still run wherever the real data is present.
Path A and frame tests are unaffected (no gravity data needed).
---------
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Make TLE propagation faithful, add drag fitting, and fix a frame approximation
What this does
When you load a TLE (two-line element set) into SSAPy today, the drag
information in it is thrown away, so propagating a TLE drifts away from the
standard SGP4 result — about 7 km/day for the ISS. This PR fixes that and adds
two related capabilities, plus a documentation refresh. Each of the five commits
stands on its own.
Three things change:
answer the reference SGP4 library gives — including the drag term.
you can propagate a TLE through SSAPy's full numerical force model and fit the
satellite's drag (and, if you want, its state) to real tracking data.
1. Faithful SGP4 from a TLE
A TLE carries a drag term (
B*) and mean-motion rates. SSAPy was discardingthem and propagating from drag-free elements, so results diverged from SGP4 —
worst for low, high-drag objects.
Fix: keep the original SGP4 record when a TLE is loaded and propagate with
it directly.
Orbit.fromTLETuplenow stores the parsed SGP4 record; the SGP4propagator uses it when present (the old reconstruction path stays as a
fallback). The record is preserved when an orbit is indexed or iterated, which
is where it was previously being dropped.
Result — position error vs the reference SGP4 library:
This makes a TLE-loaded orbit a faithful SGP4 baseline. Note it is running
SGP4, not independently reproducing it.
2. Physics-based propagation and drag fitting for TLEs (
ssapy.tle_drag)The alternative to SGP4: take a TLE as a starting point and propagate it through
SSAPy's numerical force model (gravity + Sun/Moon + atmospheric drag). This is a
genuinely different, higher-fidelity model — it does not reproduce SGP4 and
should be checked against real tracking data, not against SGP4.
A TLE doesn't give you a physical drag coefficient (its
B*is tied to SGP4'sinternal atmosphere), so this module estimates one:
fit_drag— solves for the satellite's drag coefficient (Cd·A/m) so thenumerical propagation matches a reference orbit track.
fit_orbit_drag— a full orbit-determination fit that solves for thesatellite's position, velocity, and drag together. This matters because if
the starting position is even slightly off and you only fit drag, the position
error gets absorbed into the drag number and corrupts it. Fitting everything at
once separates them.
Demonstration on a known test case (starting position deliberately off by ~210 m
and 0.18 m/s; true drag coefficient 5e-3):
Helpers included: convert
B*to a starting drag estimate, build the numericalpropagator, and (optionally) return the fit's uncertainty covariance for
downstream filtering.
fit_dragwas also sped up to integrate an orbit onceacross all requested times instead of re-integrating for each one.
3. Time-accurate TEME→GCRF conversion
SGP4 produces coordinates in the TEME frame; SSAPy converts them to the standard
GCRF/GCRS frame. The propagator was computing that rotation once, at the start
time, and reusing it for the whole arc. Because the rotation drifts slowly over
time, this leaves a small error that grows — and grows with orbit size.
Fix: compute the rotation at each output time. Checked against Astropy's
independent implementation:
Also fixes the underlying conversion so it accepts an array of times (it silently
failed on arrays before); single-time results are bit-for-bit identical.
Runs offline
No new runtime network dependencies. Earth-orientation data comes from Astropy's
bundled table (no downloads, safely clamps out-of-range dates); the numerical
model uses SSAPy's bundled ephemeris/gravity data; the fits are plain
NumPy/SciPy. The drag model stays Harris-Priester, which needs no external
space-weather data.
Documentation
doi:10.21105/joss.08147) as the primary citation, with BibTeX, and point the
JOSS badge at the DOI.
Testing
tests/test_tle_drag.py(8 tests — exact-SGP4 match, drag fit, jointorbit+drag fit) and
tests/test_frame_perstep.py(3 tests — array support andaccuracy vs Astropy).
test_frame.py,test_orbit.py,test_io.py: 27 pass, noregressions.
examples/tle_drag_paths.pydemonstrates all three features end to end.Possible follow-ups (not in this PR)
finite-difference).
drag option — deferred to keep SSAPy fully offline for now.