Skip to content

Feature/tle propagation - #115

Merged
SuperdoerTrav merged 6 commits into
mainfrom
feature/tle-propagation
Jul 14, 2026
Merged

Feature/tle propagation#115
SuperdoerTrav merged 6 commits into
mainfrom
feature/tle-propagation

Conversation

@SuperdoerTrav

Copy link
Copy Markdown
Collaborator

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:

  1. TLEs now propagate exactly like standard SGP4. Load a TLE, get the same
    answer the reference SGP4 library gives — including the drag term.
  2. A new physics-based option for TLEs, with orbit fitting. Instead of SGP4,
    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.
  3. A more accurate coordinate-frame conversion in the SGP4 propagator.

1. Faithful SGP4 from a TLE

A TLE carries a drag term (B*) and mean-motion rates. SSAPy was discarding
them 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.fromTLETuple now stores the parsed SGP4 record; the SGP4
propagator 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:

object before after
ISS (low Earth orbit, ~420 km) 7153 m at 24 h ~1e-9 m
geostationary ~20 m at 24 h ~1e-9 m

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's
internal atmosphere), so this module estimates one:

  • fit_drag — solves for the satellite's drag coefficient (Cd·A/m) so the
    numerical propagation matches a reference orbit track.
  • fit_orbit_drag — a full orbit-determination fit that solves for the
    satellite'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):

approach recovered drag coefficient track fit
fit drag only (position held fixed) 0.327 — off by +6400% stuck at ~1.7 km error
fit position + drag together 5.0e-3 — essentially exact ~1e-7 m error

Helpers included: convert B* to a starting drag estimate, build the numerical
propagator, and (optionally) return the fit's uncertainty covariance for
downstream filtering. fit_drag was also sped up to integrate an orbit once
across 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:

object old (single time) at 12 h new (per time step)
ISS 2.2 m ~0.01 m
geostationary 13.5 m (26.5 m at 24 h) ~0.05 m

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.

Behavior change for existing users: SGP4Propagator() now does the
per-time-step conversion by default. Output shifts by up to a few tens of
meters at geostationary altitude compared to before. This is the more correct
behavior. To get the old single-time behavior, pass the epoch:
SGP4Propagator(t=<epoch>).


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

  • Cite the published JOSS paper (Meyers et al. 2025, JOSS 10(111), 8147,
    doi:10.21105/joss.08147) as the primary citation, with BibTeX, and point the
    JOSS badge at the DOI.
  • Add a dedicated SSAPy-Toolkit section describing the companion project.
  • Fix a broken link in the README (it pointed at a bib file that doesn't exist).

Testing

  • New: tests/test_tle_drag.py (8 tests — exact-SGP4 match, drag fit, joint
    orbit+drag fit) and tests/test_frame_perstep.py (3 tests — array support and
    accuracy vs Astropy).
  • Existing test_frame.py, test_orbit.py, test_io.py: 27 pass, no
    regressions.
  • examples/tle_drag_paths.py demonstrates all three features end to end.

Possible follow-ups (not in this PR)

  • Faster/more-robust Jacobian for the orbit-determination fit (currently
    finite-difference).
  • Optional higher-fidelity atmosphere model (NRLMSISE-00 / JB2008) as a drop-in
    drag option — deferred to keep SSAPy fully offline for now.

dev 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-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 22.98137% with 124 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.81%. Comparing base (ddc4cf9) to head (a7960ee).

Files with missing lines Patch % Lines
ssapy/tle_drag.py 8.27% 122 Missing ⚠️
ssapy/orbit.py 81.82% 2 Missing ⚠️
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     
Flag Coverage Δ
unittests 58.81% <22.98%> (-1.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
ssapy/compute.py 57.79% <100.00%> (+0.54%) ⬆️
ssapy/propagator.py 86.11% <100.00%> (+0.13%) ⬆️
ssapy/utils.py 53.17% <100.00%> (+0.12%) ⬆️
ssapy/orbit.py 84.89% <81.82%> (-0.04%) ⬇️
ssapy/tle_drag.py 8.27% <8.27%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@SuperdoerTrav
SuperdoerTrav merged commit 25e1fc7 into main Jul 14, 2026
5 checks passed
@SuperdoerTrav
SuperdoerTrav deleted the feature/tle-propagation branch July 14, 2026 20:25
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).

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants