# Changelog
Only recent releases are listed. Older entries are in this file's git history (`git show vX.Y.Z:CHANGELOG.md`) and on the [GitHub Releases](https://github.com/ssmichael1/satkit/releases) page.
## Unreleased
## 0.21.0 - 2026-08-30
### Added
- **Experimental:** ECOM (Empirical CODE Orbit Model) solar-radiation-pressure model — reduced/ECOM1/ECOM2 coefficients in the DYB frame, Rust (`EcomParams`, `SatProperties::srp_ecom`) and Python (`ecomparams`, `satproperties(ecom=...)`), with a GPS SP3 fit/prediction tutorial; the interface may change in a minor release ([#131](https://github.com/ssmichael1/satkit/pull/131))
- Static data files are downloaded from a manifest pinned to the release (`data/manifest.json`, SHA-256 verified), trying GitHub release assets, then the origin servers (JPL, IERS), then the GCS bucket; `SATKIT_DATA_URL` overrides the source for mirrors ([#137](https://github.com/ssmichael1/satkit/pull/137))
- GMAT regression corpus: 17 seven-day reference trajectories (LEO to cislunar; `j2`/`full`/`gr` force models) replayed and gated under `cargo test` and `pytest`; regenerate with `tests/gmat/generate.py` ([#127](https://github.com/ssmichael1/satkit/pull/127))
- EOP coverage is visible and enforceable: `earth_orientation_params::{coverage, status}` (Python `frametransform.eop_coverage()` / `eop_status()`), one-time warnings past the table end or with no table, and `PropSettings::require_eop_coverage` ([#133](https://github.com/ssmichael1/satkit/pull/133))
- `frametransform::ierstable::preload()`; a missing `tab5.2*.txt` now raises `RuntimeError` in Python instead of `PanicException` ([#133](https://github.com/ssmichael1/satkit/pull/133))
- Python test pinning the EME2000 frame bias (23.1 mas) against IERS 2010 values ([#132](https://github.com/ssmichael1/satkit/pull/132))
### Changed
- **Breaking (Python packaging):** core data (IERS tables, gravity models to degree 70) is compiled in and the JPL ephemeris downloads on first use (SHA-256 verified) into the platform user-data directory, so `satkit-data` is no longer a dependency (optional: `pip install satkit[data]`); `SATKIT_OFFLINE=1` / `utils.set_offline()` forbid network access, and read-only or missing data locations are typed errors ([#139](https://github.com/ssmichael1/satkit/pull/139))
- Propagator's GCRF→ITRF table now uses the full IAU 2006/2000A chain instead of the ~1″ IAU-76 approximation; removes an inclination-dependent drift of ~50 m over 7 days at LEO versus GMAT ([#127](https://github.com/ssmichael1/satkit/pull/127))
- Relativistic correction now includes geodesic precession and Lense–Thirring (all three IERS 2010 Eq. 10.12 terms); shifts results by ≤ 1 m over 7 days at 200,000 km, cm at LEO. **Breaking (Rust):** `Precomputed::interp` returns the named struct `InterpSample` instead of a tuple ([#129](https://github.com/ssmichael1/satkit/pull/129))
- **Breaking:** gravity degree/order above 40 is rejected (`Error::InvalidGravityDegree`, Python `ValueError`) instead of being silently evaluated at 40 ([#130](https://github.com/ssmichael1/satkit/pull/130))
- `Precomputed` table size is capped (`Error::PrecomputeTooLarge`) and non-finite padding rejected, instead of allocating gigabytes ([#130](https://github.com/ssmichael1/satkit/pull/130))
- A propagation with no EOP table loaded fails with `Error::EopUnavailable` instead of running with zero polar motion and UT1−UTC ([#133](https://github.com/ssmichael1/satkit/pull/133))
- Data downloads use `https://celestrak.org` and validate manifest paths and URLs ([#130](https://github.com/ssmichael1/satkit/pull/130))
- numeris 0.5.14 → 0.5.18: the adaptive Runge–Kutta integrators no longer abort at shadow-boundary kinks on eclipsing arcs ([#128](https://github.com/ssmichael1/satkit/pull/128)) ([#135](https://github.com/ssmichael1/satkit/pull/135))
- **Cannonball SRP acts along the satellite→Sun line** rather than the
geocentric Sun direction (a ~1e-4 rad difference at LEO). `test_gps`
residual 1.7997 → 1.7868 m.
### Fixed
- SP3 epochs are read as GPS time, not UTC (18 s error) in `test_gps`, `sp3file.py` and the validation script — `test_gps` residual 1.80 → 1.21 m; cannonball SRP now acts along the satellite→Sun line; `jgm3`/`itugrace16` documented as zero-tide models ([#131](https://github.com/ssmichael1/satkit/pull/131))
- `import satkit` no longer fails when the optional `satkit_data` bundle is installed as a namespace package (the conda layout, no `__init__.py`): its `data/` directory is discovered via `__path__` ([#140](https://github.com/ssmichael1/satkit/pull/140))
- References page rebuilt as a full bibliography and every guide, API page and tutorial now cites its primary source; wrong SGP4 (AIAA 2006-6753), JGM-3 DOI and box-wing citations corrected; drag gate, Lambert multi-revolution, leap-second and GR descriptions brought in line with the code; RK stage counts corrected (RKV98 is 21 stages) ([#136](https://github.com/ssmichael1/satkit/pull/136))
- Frame-bias docs: `EME2000` is 23.1 mas from GCRF (docs said 17); GMAT's `EarthMJ2000Eq` is an IAU-76 realization ~44 mas from ICRF, not the constant bias ([#132](https://github.com/ssmichael1/satkit/pull/132))
- Doc fixes: Gauss–Jackson dense-output note, dual licence in crate docs, CONTRIBUTING versions/paths, gravity degree limit in `docs/index.md`, leap-second table described as compiled in ([#130](https://github.com/ssmichael1/satkit/pull/130))
### CI
- Published wheels are import-tested by cibuildwheel; the NOAA network test is `#[ignore]`d and run explicitly; `doc = false` on the extension crate so `cargo doc --workspace` builds ([#130](https://github.com/ssmichael1/satkit/pull/130))
## 0.20.4 - 2026-08-28
### Added
- **`scale=` keyword on the `time(...)` constructor** (Python): Gregorian
date/time components passed to `satkit.time(year, month, day[, hour, minute,
second])` can now be interpreted in an explicit time scale, e.g.
`satkit.time(2020, 1, 1, scale=satkit.timescale.TAI)`, mirroring the existing
`time.from_mjd(..., scale=...)` / `time.from_jd(..., scale=...)` API. The
keyword defaults to `satkit.timescale.UTC`, so all existing calls are
unchanged. Backed by a new scale-aware core constructor
`Instant::from_datetime_with_scale`.
- **`SGP4InitArgs::from_mean_elements`** — a constructor that performs the
rev/day → rad/min and degree → radian conversions from catalog units. Both
the `TLE` and CCSDS `OMM` SGP4 sources now build their init args through it,
so the conversion factors are defined in one place.
- **`time` and `duration` are now hashable.** Both define `__eq__` but
previously lacked `__hash__`, which made them unhashable (unusable as `dict`
keys or `set` members). The hash is derived from the underlying microsecond
count, so it is consistent with equality.
- **Equality and `repr` on more value types.** `TLE`, `kepler`, and `itrfcoord`
now implement `__eq__`; `TLE`, `kepler`, `satstate`, and `propsettings` now
implement `__repr__` (delegating to their `str` form). The three float-backed
types that gained `__eq__` are intentionally left unhashable — a failed
`hash()` is clearer than silently-wrong float-keyed lookups.
- **`mypy.stubtest` in CI.** The `python bindings test` job now verifies that
the hand-written `.pyi` type stubs match the compiled PyO3 bindings. Two CLI
flags (`--ignore-positional-only`, `--ignore-disjoint-bases`) plus
`python/stubtest_allowlist.txt` suppress systematic PyO3 idioms (constructors,
final classes, native submodules); everything else must agree.
### Fixed
- **Canonical physical constants corrected and documented.** The lunar
gravitational parameter now uses the JPL DE440 value
(`4.902800118e12 m³/s²`) instead of the erroneous `4.9048695e12`; duplicate
lunar-GM literals in point-gravity tests now reference the shared constant.
The solar GM, WGS 84 flattening and rotation rate, nominal solar radius,
DE440 Earth–Moon mass ratio, and derived geosynchronous radius were updated
to their canonical values. `JGM3_J2` now uses the conventional positive `J2`
sign. Every constant in `consts.rs` now identifies its authoritative source.
- **Relativity documentation now describes the Schwarzschild correction
accurately.** Removed misleading fixed position-drift-per-day estimates and
clarified that, for a state satisfying the Newtonian circular-orbit
relation, the post-Newtonian correction points radially outward while the
much larger Newtonian acceleration points inward. The corresponding test
name and Python type-stub documentation were corrected as well.
- **Type stubs now type-check and match the runtime.** The `.pyi` files had
never been checked and contained illegal overload-implementation blocks, a
`time.__add__` overload group split by other methods, and `time`-as-annotation
shadowing. Filled in stub gaps (`itrfcoord.height`, `time.add_utc_days`,
`weekday.Invalid`) and removed/fixed a phantom `time.as_gregorian(scale=)`
parameter and a mis-declared `sgp4_opsmode.improved` property.
- **Panic hardening: malformed and edge-case input now returns errors instead
of panicking** (PR #124), following a codebase-wide audit:
- *TLE parsing*: day-of-year is range-checked, negative satellite numbers
are rejected, and non-finite implied-exponent fields (bstar, nddot) error
at parse time instead of overflowing later in epoch math, `Display`, or
`to_2line`. `Display` also falls back to the raw satellite number rather
than unwrapping a failed alpha5 conversion.
- *Time*: `from_rfc3339` no longer panics on non-ASCII input while scanning
for a timezone offset; `from_datetime` errors on extreme years (new
`InstantError::InvalidYear`); MJD conversions, `from_gps_week_and_second`,
and leap-second folding saturate at the i64 boundaries; chrono `DateTime`
conversion saturates to `MIN_UTC`/`MAX_UTC`; `strftime` `%B`/`%b` handle
out-of-range months.
- *JPL ephemerides*: querying exactly at the file's end epoch (`jd_stop`)
now clamps to the last Chebyshev record instead of indexing past it, and
the parser validates header fields and declared sizes (with checked
arithmetic, before allocating) so a corrupt or crafted file errors
cleanly. Unpopulated bodies error instead of underflowing.
- *Gravity models*: `Gravity::parse` rejects `.gfc` lines with order >
degree (previously a panic for large orders and **silent coefficient
aliasing** for moderate ones), and the evaluators clamp the requested
degree to the loaded model's table so a custom low-degree model cannot be
indexed out of bounds. A spec-conformant bare `end_of_head` line now
terminates the header instead of silently yielding an all-zero model.
- *Orbit propagation*: the force model falls back to direct computation
when an adaptive integrator probes outside the precomputed interp table
(previously an unwrap panic for high-altitude states);
`propagate::<C>` with unsupported column counts, zero/negative/non-finite
`Precomputed` steps, and invalid thrust frames constructed via pub
fields/`Deserialize` all surface as errors instead of panics.
- *Utilities*: `datadir()` no longer panics (and permanently poisons its
singleton mutex) if a candidate directory cannot be stat-ed; download
helpers return an error for paths/URLs with no valid file name.
- **Python bindings: invalid input raises clean exceptions instead of
`PanicException`**, and non-contiguous numpy input now works:
- Wrong-size/shape arrays to `propagate`, frame transforms,
`kepler.from_pv`, `satstate.add_maneuver`, and `thrust.constant` raise
`ValueError`/`RuntimeError` (via shape validation in `py_to_smatrix`).
- Strided views and Fortran-order arrays are now accepted by `itrfcoord`,
`gravity`, `gravity_and_partials`, and the `satstate`
covariance/uncertainty setters (previously `as_slice().unwrap()` panics).
- `TLE.from_lines`/`from_url` raise on input containing no valid TLEs;
`TLE.from_file` handles non-UTF-8 files; `sgp4` rejects empty TLE
lists/time arrays; `kepler(...)` rejects non-numeric positional args;
`quaternion.from_axis_angle` validates axis length;
`planets.heliocentric_pos` raises for Sun/Moon and out-of-range times
(previously a panic with the GIL released); `time` arithmetic with ints
too large for f64 raises `OverflowError`; `datetime.timestamp()` failures
propagate; `utils.datadir()` handles non-UTF-8 paths.
### Changed
- **BREAKING: `sgp4::Error::SatRecInit` now carries a typed `SGP4Error`
instead of a raw `i32`.** The raw Vallado init error code is mapped to the
corresponding `SGP4Error` variant (eccentricity, mean motion, perturbed
eccentricity, semi-latus rectum, orbit decay) at construction, so the
`Display` output is now a description rather than a bare number.
Additionally, the `SGP4Error` enum has moved from `sgp4::sgp4_impl` to
`sgp4::error`; it remains re-exported at `satkit::sgp4::SGP4Error`, so the
public path is unchanged.
- **`LambertError` renamed to `lambert::Error`** to match the per-module
`module::Error` convention used everywhere else in the crate. The old name
remains as a `#[deprecated]` type alias, so existing code compiles with a
deprecation warning; update `satkit::lambert::LambertError` →
`satkit::lambert::Error`. A `lambert::Result<T>` alias was also added.
- **`itrfcoord(...)`, `sgp4(...)`, and `satstate.propagate(...)` now reject
unknown keyword arguments** instead of silently ignoring them. Previously a
typo such as `itrfcoord(..., alttiude=100)` was dropped, leaving the ground
station at 0 m altitude; it now raises `ValueError`. Callers passing only
documented keywords are unaffected.
- **Renamed keyword arguments on several bindings** so the accepted Python
keyword matches its documentation (the stubs previously advertised names the
runtime rejected). Positional calls are unaffected; only callers passing these
by the *old* keyword must update:
- `duration.from_hours` / `from_minutes` / `from_seconds`: `d` → `hours` / `minutes` / `seconds`
- `time.from_string`: `s` → `string`; `time.from_unixtime`: `t` → `unixtime`
- `time.from_rfc3339`: `s` → `rfc3339`; `time.from_datetime`: `tm` → `dt`
- `time.strftime`: `fmt` → `format`; `time.strptime`: `(s, fmt)` → `(date_string, format)`
- `kepler.from_pv`: `(r, v)` → `(pos, vel)`
## 0.20.3 - 2026-08-21
Released with the panic-hardening audit (PR #124), the `lambert::Error` /
typed `sgp4::Error::SatRecInit` API cleanups (PR #123) and the stubtest CI
check (PR #122). Those entries were recorded under 0.20.4 above when 0.20.4
followed a week later; see that section for details.
## 0.20.2 - 2026-07-03
### Fixed
- **`Kepler` mean→eccentric anomaly conversion was inaccurate for
high-eccentricity orbits with unwrapped mean anomalies** (e.g.
`Kepler::propagate` beyond one revolution at e ≳ 0.85): the naive
`E₀ = M ± e` Newton starting guess lands in a near-flat region of Kepler's
equation, the iteration turns chaotic, and the (0.20.0-introduced)
iteration cap could exit with a badly wrong anomaly — up to ~0.24 rad of
true-anomaly error observed. `mean2eccentric` now range-reduces M to
[0, 2π) and uses Danby's initial guess, converging in <10 iterations for
all e < 1. Found by the new property-based test suite in CI
(`kepler_period_closure`, fresh random seed); the counterexample is pinned
as a permanent regression test.
## 0.20.1 - 2026-07-03
### Security / dependencies
- **Resolved all 7 outstanding RustSec advisories** (surfaced by the new
`cargo audit` CI gate): `quick-xml` 0.38 → 0.41 (two high-severity DoS
advisories in XML parsing — relevant since OMM XML is untrusted input),
`pyo3`/`numpy` 0.28 → 0.29 (PyList/PyTuple iterator out-of-bounds read,
`new_closure` Sync bound), plus compatible `cargo update` bumps covering
the `rustls-webpki`, `anyhow`, and `rand` advisories.
### Testing
- **New property-based test suite** (`tests/properties.rs`, proptest): 12
properties over randomly generated domains with automatic shrinking —
Kepler `from_pv ∘ to_pv` round-trip (including the exactly-circular /
equatorial degeneracies), anomaly-conversion round-trip and termination,
one-period orbit closure, Vincenty finiteness/non-negativity over all
coordinate pairs, geodetic round-trip, MJD/unixtime round-trips in all
data-independent time scales, Instant±Duration arithmetic, TLE
format ∘ parse round-trip at field precision, TLE parser never-panics,
and quaternion rigidity + Euler round-trip. Runs in ordinary `cargo test`
(no data files needed); failures persist in `proptest-regressions/`.
- **`TimeScale` now derives `Clone`, `Copy`, and `Hash`** — previously each
use of a scale value moved it, so loops/reuse required re-naming the
variant (a long-standing ergonomic wart the new property tests
immediately hit).
### CI
- **New gates:** `cargo clippy --workspace --all-targets -D warnings` (the
outstanding warnings were fixed or annotated), `cargo audit` (RustSec
advisory scan), and an **sdist-install guard** that builds the sdist and
`pip install`s from it — the check that would have caught the 0.20.0
source-unbuildable sdist before it shipped.
- **Weekly scheduled `cargo audit`** (`audit.yml`) — RustSec advisories are
published on their own schedule; the cron run surfaces new ones even when
nothing is being pushed.
### Fixed
- **PyPI sdist was unbuildable from source.** `MANIFEST.in` omitted the
`benches/` directory while `Cargo.toml` declares the `hotpaths` bench
target, so `cargo` failed to parse the manifest when installing from the
sdist ("can't find `hotpaths` bench"). Wheels were unaffected; this broke
source builds only (present since the benches landed in 0.19.0). The
sdist now includes `benches/`.