Skip to content

Fix/bug review - #152

Merged
SuperdoerTrav merged 20 commits into
mainfrom
fix/bug-review
Oct 9, 2026
Merged

SuperdoerTrav merged 20 commits into
mainfrom
fix/bug-review

Conversation

@SuperdoerTrav

Copy link
Copy Markdown
Collaborator

No description provided.

Five helpers in ssapy.utils returned wrong angles:

- dms_to_dd took the sign from the degree field only, so "-00:30:00"
  returned +0.5 deg. The sign now belongs to the whole angle.
- dd_to_hms used |dd| for negative input (-15 deg -> "1:0:0" instead of
  23h) and printed to stdout; 359.99999999 deg gave "24:0:00". It now wraps
  into [0, 360) and rolls 24h over to 0h.
- rightascension_to_hourangle formatted LST - RA in degrees as D:M:S, so a
  2 h hour angle came back as "30:0:0", and converted numeric local times
  inconsistently with numeric right ascensions. It now returns
  (LST - RA) mod 24 h as HH:MM:SS with both numeric arguments in degrees,
  as documented.
- equatorial_to_horizontal took azimuth from arccos, which cannot tell east
  from west, then applied a southern-hemisphere pi - az patch; every object
  west of the meridian came out east. It now uses the atan2 form
  (north = 0, east = 90 deg) and warns instead of printing when both an
  hour angle and a right ascension are given.
- horizontal_to_equatorial negated the zenith angle in the southern
  hemisphere and mirrored the hour angle on the signs of latitude and
  declination. It now uses atan2, which also stays well conditioned on the
  meridian.
- equatorial_to_ecliptic and ecliptic_to_equatorial took longitude and RA
  from arctan of a ratio: RA 120 deg mapped to ecliptic longitude 300 deg
  (should be 120.04 deg) and RA 200 deg to 26 deg (206 deg); the inverse
  returned -60 deg for RA 120 deg. Both use arctan2.

The same horizon bugs were fixed in SSAPy-Toolkit; these are the SSAPy
copies.

New tests: exact sexagesimal cases; dd_to_hms against astropy Angle at
seven angles; hour angles from documented examples; the horizon transforms
against the east/north/up rotation at 16 geometries in both hemispheres
(1e-9 deg); and the ecliptic transforms against the obliquity rotation in
all four quadrants (1e-9 deg). Three assertions in test_utils.py that
pinned the old behaviour (the 30:0:0-style hour angle, the stdout message,
and the printed hour-angle notice) now check the corrected behaviour.
sunPos(fast=True) implements Montenbruck & Gill section 3.3.2, which holds
Omega + omega = 282.9400 deg fixed. In the J2000 frame the Earth-Moon
barycentre's longitude of perihelion advances 0.32327364 deg per Julian
century (JPL approximate positions, Table 1), so the series' longitude
drifted by 11.6 arcsec per year: 0.2 arcmin at J2000, 5.3 arcmin in 2026,
and 11.6 arcmin at worst over 1980-2060 against astropy's geometric Sun.
AccelSolRad and the eclipse geometry use this path. With the advance the
worst case over 1980-2060 is 0.58 arcmin.

New test: 200 epochs over 1980-2060 against astropy's geometric
geocentric Sun stay within 1 arcmin in direction and 3e-4 in distance;
it fails on the previous series.
lb_to_tan projects positions orthographically, x = rahat . unit and
y = dechat . unit with fixed axes, so their rates are exactly the
projections of the sphere velocity onto those axes. It then stretched the
radial part by 1/cos(rho), the derivative of a gnomonic projection. Off the
tangent point the returned rates disagreed with the motion of the projected
point: 0.01175 vs 0.01073 for a point 0.33 rad from centre (9.5%). The
stretch is removed. The existing test checked only the tangent point, where
the factor is 1.

New test: at 0, 0.05, 0.33 and 0.8 rad from the tangent point the rates
match a central difference of lb_to_tan's own projection to 1e-8 relative;
the off-centre cases fail on the previous code.
Leapfrog4Propagator stored each step's epoch as t + h1 + h0 + h1 with
the Yoshida weights, three additions that each round at the ulp of t
(2.4e-7 s at GPS 1.4e9). The rounding is the same every step, so the
stored epochs drifted from the integrated states. At t0 = 0 the method is
fourth order (97, 6.1, 0.38, 0.024 m over 6000 s for h = 40, 20, 10, 5 s);
at GPS 1.4e9 it stalled at 1.47 m (h = 10 s) and 2.17 m (h = 5 s).
Substep epochs are now offsets from t and t advances by h, which restores
the t0 = 0 errors at real epochs.

New test: against the Keplerian propagator at t0 = 0, 1.4e9 and 3.8e9,
halving h from 20 to 10 to 5 s reduces the error by more than 12x each time
and leaves less than 0.05 m at h = 5 s; the GPS-epoch cases fail on the
previous code.
make_tle documents that dynamic TLE terms are ignored, but it wrote B* as
'99999-0' in columns 54-61, which SGP4 reads as 0.99999 per Earth radius.
Propagating a make_tle TLE for a 7000 km orbit with sgp4 for 3 days drifted
11,890 km from the same TLE with B* = 0. By default all three drag terms
(ndot/2, nddot/6, B*) are now zero.

The placeholder happened to match the Aerospace GEO test TLEs, whose B*
field is also 99999, which is why test_sgp4 matched direct sgp4 through
SGP4Propagator(truncate=True). make_tle now takes drag_fields (line-1
columns 34-61), and both Orbit.tle and the truncate path pass the columns
of the TLE the orbit came from, so truncation changes only the precision
of the mean elements; test_sgp4 still matches to 1e-15.

New tests: for a plain make_tle TLE sgp4 reads B*, ndot and nddot as 0 and
the epoch to 1 ms, with valid checksums; for an orbit read from the
documented ISS TLE, Orbit.tle keeps its B* (-1.1606e-5), ndot and nddot
exactly as sgp4 reads them. The first fails on the previous code.
radecRate's docstring listed six return values (ra, raRate, dec, decRate,
slantRange, slantRangeRate), but it returns only raRate, decRate and
slantRangeRate. The docstring now says so and points to radec(..., rate=True)
for the rest. Behaviour is unchanged; the rates themselves match central
differences of radec (raRate is d(RA)/dt cos(dec), as documented).
GaussTwoPosOrbitSolver's fixed-point iteration converges only for short
arcs, and when it diverged it returned the last iterate silently. Against
Keplerian truth it recovered a 38 deg LEO arc to 6.5e-12 m/s but returned
velocities 2.7 km/s off for a quarter GEO orbit, 34 km/s off for a 134 deg
MEO arc, and 7e18 m/s off for a 270 deg LEO arc, all without any signal.
It now issues a RuntimeWarning naming SheferTwoPosOrbitSolver when the
last update exceeds 1e-10 of eta, and the class docstring states the
short-arc limitation. (The existing branch-coverage test runs one iteration
with eps = 0 and still passes; it now warns.) The near-zero series for X(x)
also had 64/35 x where the expansion has 64/35 x^2.

New tests: Shefer's method recovers the departure velocity to 1e-9 m/s on
five arcs from 38 to 270 deg, LEO to GTO (measured <= 6e-12 m/s); Gauss
recovers the 38 deg arc to 1e-9 m/s without warning and warns on the
quarter GEO orbit. The warning check fails on the previous code.

DanchickTwoPosOrbitSolver raises 'Invalid x' on the 270 deg arc; it fails
loudly, so it is left as is.
EarthOrientation, which Body('earth') and therefore AccelHarmonic use for
the GCRF-to-ITRF rotation, cached the IAU 1976/1980 precession-nutation
matrix and UT1 - TT for 30 days and only updated Earth rotation per call.
Over a propagation the frame drifted 0.06 arcsec after 1 day, 1.0 arcsec
after 7 days and 5.1 arcsec after 29 days: 172 m at 7000 km and 1 km at
GEO, and a 2.1e-7 m/s^2 error in a 20x20 harmonic acceleration at LEO.
The default threshold is now 1 hour, which bounds the drift at 0.003
arcsec; the cost is one erfa.pnm80 call per simulated hour.

New test: an EarthOrientation held from 2026-10-08 agrees with a freshly
built one to 0.005 arcsec 1, 7 and 29 days later, and with astropy's
GCRS -> ITRS rotation to 0.5 arcsec (IAU 1980 vs 2000A model difference,
measured 0.27 arcsec). All three cases fail on the previous default.

AccelDrag has the same 30-day cache for its true-of-date frame; a 5 arcsec
error moves the density lookup by under 200 m, well inside the
Harris-Priester model's own error, so it is left unchanged.
RKPropagator (RK4, RK8, RK78, Leapfrog, Leapfrog4) answers queries
between its step boundaries from an interpolant through the cached states.
That was a not-a-knot cubic spline fit to positions and velocities
independently, so its error was O(h^4) whatever the integrator's order:
an RK8 arc accurate to 4e-8 m at its steps over 6000 s in LEO was only
2.5e-2 m accurate between them at h = 40 s (1.8e-3 m at h = 20 s).

The interpolant is now piecewise quintic Hermite: position, velocity and
acceleration at both ends of each step, with velocity taken as the
derivative of the position quintic. Accelerations are evaluated once per
step boundary with the propagator's own force model and reused as the
cache grows (one extra evaluation per step, against 13 per RK8 step). If
the acceleration cannot be evaluated the cubic spline is used as before.
Off-grid RK8 errors become 1.1e-7 m (h = 40 s) and 1.6e-8 m (h = 20 s);
RK4 and Leapfrog4 off-grid errors now equal their on-grid errors.

New test: off-grid RK8 positions and velocities agree with the Keplerian
propagator to 1e-6 m and 1e-6 m/s at h = 40 and 20 s; both fail on the
previous interpolant.
VolumeDistancePrior.__init__(scale=RGEO) stored RGEO regardless of the
argument, so every instance peaked at 2 RGEO (84,300 km) whatever scale
was requested. It now stores the given scale.

New test: with scale = 1000 km the log prior is -1e-9 at its 2000 km peak
and matches 2 ln(d/s) - d/s - (2 ln 2 - 2 + 1e-9) at 500 km to 1e-12; it
fails on the previous code.
With 'pmra'/'pmdec' columns, circular_guess took row 0 of the whole arc
and ignored indices. It now uses the first of indices (a one-row arc still
works with the default), and the docstring documents the (state, epoch)
return value it already had.

New test: given two observations with rates and indices=[1, 0], the
returned epoch is the second observation's time; it fails on the previous
code.
AccelDrag evaluates Harris-Priester density in the true-of-date frame
but passed it the Sun's right ascension and declination from GCRF, which
put the diurnal bulge 0.37 deg off in right ascension in 2026 and growing
at 50 arcsec per year. The Sun vector is now rotated into the same frame.
Its precession-nutation cache also defaulted to 30 days; it now defaults
to 1 hour, like EarthOrientation.

New test: the Sun right ascension and declination AccelDrag hands the
density model agree with astropy's true-equator, true-equinox Sun to
0.05 deg (fast sunPos is good to 0.6 arcmin); the right ascension fails
on the previous code.
DanchickTwoPosOrbitSolver chose its iteration from the sign of cos(2f)
alone. Long-way arcs beyond 270 deg also have cos(2f) >= 0 but negative
kappa, so they went to the eta iteration, which left its domain; and on
fast perigee passages the x iteration's plain Newton step jumped out of
0 < x < 1. Both raised 'Invalid x': on 19 of 100 test arcs (2-98% of a
period on LEO, eccentric MEO, GEO and GTO orbits).

The eta iteration is now used for short-way arcs with cos(2f) >= 0 and
the x iteration otherwise; if the preferred one fails the other is tried,
the x iteration halves any step that would leave (0, 1), and a failure of
both raises a RuntimeError naming each. All 100 arcs now solve, with the
departure velocity within 3.6e-9 m/s of Keplerian truth (Shefer:
1.1e-9 m/s).

New test: the 100 arcs, both solvers, 1e-7 m/s; the Danchick cases fail
on the previous code. The branch-coverage test that forced a
domain-leaving step now expects the combined RuntimeError.
VENUS_MASS was 4.687e24 kg, a transposition of 4.867e24: VENUS_MU / G is
4.8673e24 kg with CODATA 2018 G, a 3.7% difference. It is now 4.8675e24 kg.

New test: every tabulated mass (Sun, Moon, eight planets) equals its
tabulated GM / G to 0.2%; all others already agree to 0.05%. The Venus
case fails on the previous value.
AccelHarmonic printed 'WARNING::...' to stdout when the requested degree
or order exceeded the model's, and AccelDrag printed its inputs before
raising on a non-finite density. The clamps now issue UserWarnings (so
they can be filtered or turned into errors), and the drag inputs are part
of the ValueError message. The clamp test now checks the warnings instead
of captured stdout. Behaviour is otherwise unchanged.
AccelHarmonic passed the body's central GM to the expansion, but the
coefficients are normalised to the gravity model's own GM (as they are to
its reference radius, which it already used). For the Moon the body GM is
DE200's 4902.79989 km^3/s^2 against GRGM1200A's 4902.80012, so lunar
harmonic accelerations were 2.2e-7 low (1e-10 m/s^2 at 1900 km); for
EGM2008 Earth accelerations were 7.5e-10 high. HarmonicCoefficients.MG is
now used when present, falling back to the body GM.

New test: 20x20 accelerations from EGM96, EGM2008 and GRGM1200A agree with
an independent potential, built from the coefficient files parsed in the
test and scipy's Legendre functions and differenced numerically, to 1e-8
relative (measured 1.3e-9 to 2.7e-9) at two points each. The lunar case
fails on the previous code.
MoonOrientation, MoonPosition, SunPosition and PlanetPosition opened
ssapy/data/<file> directly, and find_file returned whatever file it found.
In a clone without 'git lfs pull' those are 130-byte LFS pointers, so
jplephem failed with an opaque error about a malformed kernel.

find_file now skips LFS pointer files (git-lfs spec v1 header), then
searches the optional llnl-ssapy-data package by file name, and if only a
pointer exists raises a FileNotFoundError that says so and how to fetch
the data. The four ephemeris providers use find_file. With the data
present (any release install) nothing changes; once SSAPy-Data ships these
files, SSAPy finds them without further code changes.

New tests: a pointer-only data directory raises FileNotFoundError naming
the LFS pointer; with a pointer locally and a real file of the same name
in llnl-ssapy-data, that file is returned. Both fail on the previous code.
SSAPy stored 21 data files (ephemerides, gravity models, textures; 308 MB)
in ssapy/data through Git LFS. They now come from the required
llnl-ssapy-data >= 0.2.0 package, which carries them under ssapy/, and the
repository no longer uses Git LFS:

- ssapy/data and .gitattributes are removed; MANIFEST.in, setup.py and
  pyproject.toml no longer package data, and the sdist drops from ~300 MB
  to 441 kB.
- llnl-ssapy-data>=0.2.0 is a dependency (pyproject.toml, requirements.txt).
- ssapy.datadir is the ssapy/ tree of llnl-ssapy-data, so find_file and
  every loader (and SSAPy-Toolkit's find_file calls for earth.png, moon.png
  and Earth_graphics) resolve there. A missing file names the package to
  install.
- The planetary ephemeris moves from JPL DE430 to DE440's short kernel,
  de440s.bsp (1849-12-26 to 2150-01-22). SSAPy's de430.bsp (119.7 MB)
  exceeds GitHub's 100 MB file limit, and DE440 matches the DE440 lunar
  orientation kernel SSAPy already uses. Over 1975-2050, geocentric
  positions change by at most 10 m for the Moon and 0.5 km for the Sun
  (103 m and 2.9 km over 1900-2150). A full de440.bsp (1550-2650) in the
  working directory is used instead when present
  (body.PLANETARY_EPHEMERIS_FILES).
- CI and the publish workflow no longer install or pull Git LFS, so CI now
  runs the data-dependent tests it used to skip on LFS pointers.
- The tests' data checks use find_file instead of inspecting LFS pointers;
  skills.md describes the new layout.

New tests: datadir is ssapy/ inside llnl-ssapy-data, the orientation,
gravity and texture files resolve there, the ephemeris resolves to
de440s.bsp, and a missing file's error names llnl-ssapy-data.

Requires llnl-ssapy-data 0.2.0 to be published first; its wheel is 184 MB,
above PyPI's default 100 MB limit, so that release needs a PyPI file-size
increase.
llnl-ssapy-data 0.2.0 ships two short planetary kernels: DE440's
de440s.bsp (1849-12-26 to 2150-01-22, the default) and a 1900-2150
excerpt of DE430 (de430_1900_2150.bsp) for reproducing SSAPy <= 1.1.10
results. The new ssapy.ephemeris module selects between them and covers
epochs outside them:

- set_planetary_ephemeris("de440" | "de430") or SSAPY_EPHEMERIS chooses
  the ephemeris for Sun, Moon and planet positions created afterwards.
  In 2026 the two differ by under 10 m for the Moon and 0.5 km for the Sun.
- Each epoch is evaluated with the first kernel of the selected
  ephemeris that covers it: the shipped short kernel, then the full-span
  kernel of the same solution (de440.bsp or de430.bsp, 1550-2650,
  120 MB), then for DE440 the two halves of DE441 (-13200 to 1969 and 1969
  to 17191, 1.65 GB each). Shipped and full kernels of one solution hold
  the same records, so positions are continuous where SSAPy switches.
- Kernels not found locally are downloaded from NAIF into
  ~/.cache/ssapy (SSAPY_DATA_CACHE), with an EphemerisDownloadWarning
  first, and kept only if their size and SHA-256 match the values pinned
  here (computed from NAIF's files on 2026-10-09; de430.bsp's equals
  SSAPy's LFS copy). Using DE441 for a DE440 request also raises an
  EphemerisRangeWarning, since DE441 is a different solution (no lunar
  core-mantle damping).
- Without network access, or with SSAPY_EPHEMERIS_DOWNLOAD=0, the error
  names the kernel, its URL and where to put it; ssapy.ephemeris.fetch()
  prefetches on a networked node for jobs that have none.
- Epochs inside the shipped kernel take a single-kernel fast path: 159 us
  per scalar Moon position against 135 us for direct jplephem.

The lunar orientation kernel (moon_pa_de440_200625.bpc) covers
1549-12-31 to 2650-01-25 only, so lunar-frame quantities still fail
outside that span.

New tests (offline, with a local file standing in for NAIF): DE440 and
DE430 Moon and Sun positions agree to 20 m and 1 km in 2026; the
environment variable selects DE430; epochs inside the shipped span never
download; an epoch outside downloads once with a warning, is cached, and
matches direct evaluation of the long kernel to 1e-6 m; a different
long-span solution warns; offline and disabled downloads fail with a
clear error and leave no partial file; a corrupt download is rejected;
epochs in 1700, 2500, 1000 and 3000 route to de440.bsp, de440.bsp,
DE441 part 1 and DE441 part 2. An opt-in test (SSAPY_TEST_NETWORK=1)
downloads de440.bsp from NAIF and evaluates the Moon in 1700; it passed
here. The installation docs describe selection, downloads and
prefetching; git-lfs is no longer a prerequisite.
@SuperdoerTrav
SuperdoerTrav merged commit 40f07fb into main Oct 9, 2026
0 of 4 checks passed
@SuperdoerTrav
SuperdoerTrav deleted the fix/bug-review branch October 9, 2026 18:23
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.

1 participant