satkit 0.20.2

Satellite Toolkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
# Changelog


## 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/`.


## 0.20.0 - 2026-07-03

A large correctness, robustness, and cleanup pass. This is a pre-1.0 release, so
the breaking changes below ride in a minor bump.

### Breaking changes

- **Pickle formats for `satstate`, `satproperties`, and `TLE` changed.** All
  three now carry a leading version byte and explicit counts: `satstate` gained
  a covariance flag and maneuver count (replacing a byte-length heuristic that
  mis-read a state with ≥9 maneuvers and no covariance as *having* a covariance,
  silently dropping every maneuver), `satproperties` gained a thrust-arc count,
  and `TLE` now also serializes `element_num` and `ephem_type`. The
  maneuver/thrust frame is encoded with an exhaustive match — previously a
  `_ => 0` catch-all silently turned NTW/LVLH burns into GCRF on unpickle — and
  all byte reads are bounds-checked and alignment-safe. **Pickles written by
  ≤0.19 are not readable by this release** (they were the source of the bugs);
  loading one reports an unsupported-version error. Re-generate them.
- **`ContinuousThrust::new` now validates the frame and returns `Result`.**
  Constructing a thrust in an Earth-fixed / inertial-chain frame returns
  `Error::UnsupportedThrustFrame` at construction (mirroring the maneuver-frame
  validation in `SatState::propagate`) instead of panicking deep inside the
  force evaluation during propagation.
- **Unused UKF advertisement removed.** The numeris `estimate` feature was
  enabled but nothing was re-exported — no filter was reachable from Rust or
  Python despite the crate docs advertising a "UKF implementation". The feature
  flag and the doc claim are removed; estimation may return as a designed
  feature in a future release.
- **`quaternion.slerp` no longer takes an `epsilon` argument** (Python). The
  parameter was documented but silently ignored (numeris uses a fixed internal
  threshold); passing it now raises `TypeError`.
- **`From<i32>` for `TimeScale` and `Weekday` replaced by `TryFrom<i32>`.** The
  infallible conversion silently mapped any out-of-range integer to `Invalid`;
  the new `TryFrom` returns a typed error (`InvalidTimeScale` / `InvalidWeekday`,
  re-exported at the crate root). (`From<i32>` could not simply gain a `TryFrom`
  alongside it — the std blanket impl already provides an infallible one.)
- **`earthgravity::gravhash()` and `accel_jgm3()` removed.** `accel` /
  `accel_and_partials` now dispatch through `GravityModel::get()`, so only the
  requested model is loaded instead of eagerly parsing all four `.gfc` files
  (one missing file used to panic the whole map).
- **`GaussJackson8::interpolate` / `interpolate_batch` now take
  `&GJDenseOutput` instead of `&GJSolution`.** This removed a full deep-copy of
  the stored trajectory on every single-point interpolation query.
- **Many `frametransform` module error variants are now typed**, and `Frame`,
  `GravityModel`, `PropSettings`, and `ContinuousThrust` gained `serde`
  derives (additive).

### Fixed (correctness)

- **Python `frametransform.to_gcrf` / `from_gcrf` returned the transposed
  (inverse) rotation matrix.** numeris matrices are column-major but the
  binding flattened and reshaped them row-major, so `to_gcrf` actually
  returned the GCRF→frame rotation and vice versa. Callers that only composed
  `to_gcrf` with `from_gcrf` outputs were self-consistent, but any use of the
  matrix directly against numpy vectors was silently wrong. Caught by the new
  `rotation_with_state` cross-check test.
- **`spaceweather::update()` was a silent no-op.** It downloaded
  `sw19571001.txt` while the loader reads `SW-All.csv`, so the public refresh
  entry point re-loaded stale data forever. Now downloads `SW-All.csv`.
- **`propsettings(enable_interp=...)` was unusable** — the kwarg lookup key was
  misspelled `enable_iterp`, so both spellings raised. Fixed.
- **Lambert solver could not return hyperbolic solutions.** The Householder
  iterate was clamped to `(-0.999, 0.999)` even for the zero-revolution case,
  so short-time-of-flight / high-energy transfers walked to the clamp and
  reported `ConvergenceFailed`. The zero-rev case now clamps only the lower
  bound; multi-rev keeps the elliptic clamp.
- **`Instant::from_string` silently mis-parsed `MM/DD/YYYY` / `DD-MM-YYYY`
  dates.** The tokenizer discards punctuation, leaving ~115 lines of unreachable
  separator-matching code while numeric fields were actually consumed
  positionally (so `"12/25/2023"` became year 12). Removed the dead code and
  documented that only ISO-ordered and month-name strings parse; use `strptime`
  with an explicit format for locale-ordered numeric dates.
- **SGP4 failures returned position `(0, 0, 0)` that looked valid.** A per-time
  propagation error (e.g. decay) left the pos/vel columns zeroed — Earth's
  center — with only the error code set. Failed columns are now `NaN`, and
  `TLE::fit_from_states` rejects any trial step whose propagation failed rather
  than folding zeros into the least-squares residuals.
- **SGP4 port: recovered RAAN mean element was lost.** `satrec.om` was assigned
  twice (`nodem` then `argpm`); added a distinct `SatRec::om_node` field
  (Vallado's `satrec.Om`). Propagated state is unchanged — these are diagnostic
  fields the propagator never reads.
- **Solar-cycle-forecast fallback was dead code / `-1` sentinels could reach the
  density model.** `spaceweather::get()` extrapolates the last record forever,
  so NRLMSISE-00's forecast fallback was unreachable and trailing
  monthly-predicted rows (fields parsed as `-1`) could feed the model. The
  density path now guards against sentinel values and falls back to the forecast.

### Robustness

- **`Kepler::mean2eccentric` could loop forever** for eccentricity ≥ 1 (the
  Newton step goes non-finite); it is now capped at 30 iterations.
- **`Kepler::from_pv` produced `NaN` for exactly circular or equatorial
  orbits.** Added the standard Vallado special cases (true longitude, argument
  of latitude, longitude of periapsis) and a new `kepler::Error::Degenerate`
  for zero-angular-momentum (rectilinear) states.
- **Vincenty geodesics returned `NaN` for coincident points** (and divided by
  zero on the equatorial line). Coincident points now return `(0, 0, 0)` and
  the equatorial line is handled. Near-antipodal accuracy is a known Vincenty
  limitation and is now documented (result stays finite).
- **TLE parsing panicked on non-ASCII input and silently dropped CRLF lines.**
  `load_2line` rejects non-ASCII up front (making the byte-slicing safe), and
  `from_lines` trims trailing whitespace and accepts ≥69-char lines so
  CRLF-terminated files parse.
- **`Instant::strptime` panicked on 10+-digit fractional seconds** (i32
  overflow in the error path); it now truncates to microseconds.
- **An `ImpulsiveManeuver` in an unsupported frame panicked mid-propagation.**
  `SatState::propagate` now validates maneuver frames up front and returns
  `Error::UnsupportedManeuverFrame`.
- **`jplephem` used an alignment-unsound `Vec<u8>` → `*const f64` cast** when
  loading Chebyshev coefficients (technically UB). Replaced with a byte-wise
  `memcpy`; behavior is unchanged on little-endian hosts.
- **Malformed pickle bytes raise `ValueError` instead of a Rust panic** for
  `TLE` and `propresult` (bounds-checked reads + mapped serde errors).
- **`propsettings` and `thrust` are now picklable** (via `serde`), so they can
  cross `multiprocessing` process boundaries. (`propsettings` was previously
  unpicklable, which broke `multiprocessing`-based propagation.)
- **Gauss-Jackson 8 over a span shorter than 8 steps now returns a descriptive
  `Error::GJIntervalTooShort`** instead of the integrator's generic "step not
  finite".
- **`Instant::as_mjd_with_scale(TimeScale::Invalid)` returns `NaN`** instead of
  `0.0` (a valid MJD, 1858-11-17), so misuse poisons downstream math visibly.

### Data-file robustness

- **Downloads are now atomic.** `download_file` / `download_if_not_exist` stream
  to a sibling `.part` file and rename it into place on success, so an
  interrupted transfer (network drop, Ctrl-C) can no longer leave a truncated
  file that later runs trust as complete.
- **Data-file parsers no longer panic on truncated/corrupt input.** Added
  field-count / bounds guards to the space-weather CSV parser (skips blank
  lines, errors on short rows), the Earth-gravity `.gfc` parser (the field
  guard now matches the indices it reads), the JPL ephemeris header/constant
  reads (single header-size check plus bounds on dynamic offsets; also fixed a
  non-char-boundary slice), and the IERS table parser (propagates bad numeric
  tokens instead of `unwrap`-panicking, and bounds-checks rows/columns).
- **A missing JPL ephemeris file surfaces as an error, not a panic.** The
  public `jplephem` query functions map a cached load failure to
  `Error::LoadFailed` instead of unwrapping the singleton. The gravity-model and
  IERS-table singletons (which back non-`Result` hot paths) now panic with an
  actionable message naming `SATKIT_DATA` / `update_datafiles` rather than an
  opaque `unwrap`.

### Architecture / cleanups

- **New `frametransform::rotation_with_state(from, to, t, pos, vel)`** — a
  single front door that handles *all* frames, both the time-parameterised
  Earth chain and the orbit-dependent frames (LVLH/RTN/NTW). Pure Earth-frame
  pairs delegate to `rotation`'s shortest path through the frame graph (it does
  **not** always pivot through GCRF); only pairs involving an orbit frame
  compose through GCRF. The existing `rotation` and `to_gcrf`/`from_gcrf`
  remain as the low-level pieces. **Exposed in Python** as
  `satkit.frametransform.rotation_with_state(from_frame, to_frame, tm, pos, vel)`
  (accepts `satkit.time` or `datetime.datetime`), with stubs and documentation.
- **New `utils::RefreshableSingleton`** (`RwLock<Option<T>> + Once`) unifies the
  lazy-load/refresh scaffolding that was copy-pasted across the EOP,
  space-weather, and solar-cycle-forecast modules. Space weather no longer
  re-attempts a blocking load on *every* `get()` (which, offline, meant an HTTP
  attempt per ODE step during a drag propagation).
- **`SatState::qgcrf2lvlh` now derives from `frametransform::gcrf_to_lvlh`** so
  the LVLH axis convention lives in one place.
- **Leap-second folding factored into a single `add_leapseconds` helper** (was
  duplicated across `from_unixtime`, `from_mjd_with_scale`, `from_datetime`,
  `now`).
- **Dead code removed:** the commented-out `pyukf` module, an unregistered
  `PyQuaternionVec`, two dead `pyinstant` helpers, and unused SGP4-port locals;
  the duplicated SGP4 result-packing block and `time.from_datetime` now share a
  single implementation.

### New Python bindings

- **New `satkit.spaceweather` submodule** — `get(time)` returns the full daily
  space-weather record (Kp/Ap arrays, F10.7 observed/adjusted and 81-day
  averages, sunspot number, …) that the NRLMSISE-00 density model consumes;
  `predicted_f107(time)` exposes the NOAA/SWPC solar-cycle forecast used for
  future-epoch densities; `update()` refreshes the data. Previously none of
  this data was reachable from Python.
- **TLE catalog metadata** — `intl_desig`, `desig_year`, `desig_launch`,
  `desig_piece`, `ephem_type`, `element_num`, and `rev_num` now have Python
  getters/setters (they were pickled but unreadable).
- **`time` epoch constants** — `time.J2000`, `time.GPS_EPOCH`,
  `time.MJD_EPOCH`, `time.UNIX_EPOCH`.
- **Quaternion completion** — `from_euler` (inverse of `as_euler`),
  `identity`, `norm`, `normalize`, `inverse`, and `dot`.
- **`satstate.maneuvers`** — returns the scheduled impulsive maneuvers (time,
  delta-v, frame), not just a count.
- **`itrfcoord.distance_to(other)`** — scalar geodesic distance companion to
  `geodesic_distance`.
- **`kepler.semiparameter`**, **`duration.from_milliseconds`**,
  **`duration.microseconds`**, and **`jplephem.consts(name)`** (DE-file
  constants: AU, EMRAT, GM values).

### Python binding fixes

- **`sgp4` with a list of TLEs silently ignored the `gravconst` / `opsmode`
  kwargs** — the list path used the default configuration regardless. Now
  honored on all input paths.
- **The `sgp4` docstring example called `TLE.single_from_lines`**, a method
  that does not exist; corrected to `TLE.from_lines(lines)[0]`.
- **`nrlmsise00`** — docstring said the time kwarg was `tm` (it is `time`); the
  function now also accepts `datetime.datetime`, rejects misspelled kwargs, and
  has a type stub (it was in `__all__` with no stub).
- **`propagate` docstring** no longer documents the removed `output_dense`
  kwarg (interpolation is controlled by `propsettings.enable_interp`) and the
  force-model list now correctly includes solid Earth tides and the
  general-relativistic correction.
- **OMM-dict input validates `MEAN_ELEMENT_THEORY` / `TIME_SYSTEM`** when
  present, matching the Rust-side OMM parser (dicts from `json.load` /
  `xmltodict` are the supported route for local OMM files).

### Python type stubs

- **Time inputs accept `datetime.datetime` everywhere they accept
  `satkit.time`, and the stubs now say so.** Introduced `TimeScalar`,
  `TimeArrayLike`, and `TimeInput` type aliases (in `satkit.pyi`, re-exported to
  the submodule stubs) and applied them across `frametransform`, `sun`, `moon`,
  `planets`, and `jplephem`, fixing signatures that previously omitted
  `datetime.datetime`. `PyInstant`-direct parameters (e.g.
  `satstate(time=...)`, `sun.rise_set`) correctly still accept only
  `satkit.time`.
- **Scalar-vs-array output typing is now explicit via overloads.** Functions
  that return a scalar for a scalar time and a sequence for an array of times
  (`moon.phase`, `moon.illumination`, `moon.phase_name`) are now proper
  `@overload`s (scalar → `float` / `moonphase`, array → `list[...]`), and the
  sidereal-time and quaternion helpers' array overloads were corrected to
  `list[float]` / `list[quaternion]` (they return Python lists, not numpy
  arrays).
- **Added the missing `frametransform.eqeq` (equation of the equinoxes) stub.**

### Documentation

- **Frame-transform docs clarified** — the `frametransform` API page now has a
  "Which function do I call?" table distinguishing `rotation` (Earth frames),
  `to_gcrf`/`from_gcrf` (orbit frames), and the new `rotation_with_state` (all
  frames), with an example of the mixed Earth→orbit case.
- **Coordinate Frames tutorial expanded** — a new "Orbit-local frames in one
  call" section with a runnable `rotation_with_state` example (TEME→RTN), and
  the "Approximate vs Full" section now explains the computation the `_approx`
  variants skip (the ~2,900-term IERS CIP series plus EOP interpolation) with a
  live timing cell measuring the speedup.
- **mkdocs navigation** — added a sticky top-level menu bar (`navigation.tabs`),
  clickable section overview pages (`navigation.indexes`, removing duplicated
  section titles), collapsed subsections by default, and normalized the API
  Reference entry capitalization to Title Case.
- **Pinned `mkdocs<2.0`** in `docs/requirements.txt` — MkDocs 2.0 is
  incompatible with Material for MkDocs, so an unpinned fresh install could
  break the docs build.
- **Equations render with KaTeX instead of MathJax** (matching numeris) —
  faster page loads, same delimiters (`\(..\)`/`\[..\]` from arithmatex and
  `$..$`/`$$..$$` in tutorial notebooks).
- **New "Loading local OMM files" tutorial section** — the supported offline
  route is stdlib `json` (or `xmltodict` for CCSDS XML) → dictionary →
  `sk.sgp4`; satkit validates `MEAN_ELEMENT_THEORY`/`TIME_SYSTEM` and consumes
  the SGP4-relevant fields.

### Dependencies

- **`numeris` updated to 0.5.14** (root and Python crate; the Python crate now
  declares the `ode` feature explicitly).


## 0.19.0 - 2026-06-23

### Fixed

- **`time.strftime` docstring example corrected.** The example mistakenly called `.strptime(...)` on a `time` instance, which raised `TypeError: missing 1 required positional argument: 'fmt'` when copied verbatim — `strptime` is a static parse method (`satkit.time.strptime(str, fmt)`), while `strftime` formats an instance. Docs-only; no code change. Resolves [#120](https://github.com/ssmichael1/satkit/issues/120).

### Python bindings release the GIL during long computations

- **`propagate`, `TLE.fit_from_states`, `sgp4` (all three input forms), and every array-valued frame-transform / ephemeris helper now release the GIL** (`Python::detach`) while the Rust computation runs, so other Python threads make progress during multi-second propagations and large batch transforms. Verified by a concurrency smoke test: a spin thread ran freely during a 100k-time `qgcrf2itrf` call.
- **`sgp4` batch (list) path restructured** to extract TLE/OMM sources with the GIL held, run the SGP4 computation detached, then write the TLEs back so their cached SGP4 init state is preserved. Single-TLE and OMM-dict paths use the same clone-and-write-back approach. Results are unchanged.
- The shared multi-time helpers in `pyutils` (`py_vec3_of_time_arr`, `py_vec3_of_time_result_arr`, `py_func_of_time_arr`, `py_quat_from_time_arr`, `tuple_func_of_time_arr`) all compute detached; the two vec3 helpers also lost their per-element `unsafe` pointer copies in favor of a plain `Vec` + reshape.

### Python bindings cleanups

- **Unknown keyword arguments now raise `ValueError` consistently.** New shared `reject_unused_kwargs` helper replaces three hand-rolled "extraneous kwargs" folds (`propagate`, `satproperties`, `propsettings` — the latter previously raised `RuntimeError`). **`duration(...)` now validates its kwargs too**: previously a typo like `duration(day=1)` was silently ignored and returned a zero duration.
- **Pickle implementations for `quaternion`, `itrfcoord`, and `kepler` share `pack_f64s`/`unpack_f64s` helpers.** Wire format is byte-identical to prior releases — existing pickles load unchanged.

### Criterion benchmark suite

- **New `benches/hotpaths.rs`** (`cargo bench`) covering the per-call costs that dominate real workloads: high-precision propagation (LEO+drag RKV98, GEO RKV98, GEO Gauss-Jackson 8, LEO with STM), SGP4 (cold init + cached, 1 day at 1-minute cadence), frame transforms (`qgcrf2itrf` exact/approx, `qteme2itrf`, `gmst`), spherical-harmonic gravity (degree 4/16, with partials), JPL ephemeris lookups, and NRLMSISE-00 density. Establishes the baseline for future optimization work — micro-optimizations in the force model and EOP path should be measured against this suite.

### Rust cleanups

- New `earth_orientation_params::get_or_zero` / `eop_from_mjd_utc_or_zero` helpers replace the `.unwrap_or([0.0; 6])` pattern repeated across the frame transforms and time module.
- Removed the dead legacy `finals2000A.all` EOP loader and its three unused error variants (`LegacyFileMissing`, `LegacyOpenFailed`, `LegacyFieldParse`) — EOP data has loaded from `EOP-All.csv` since the Celestrak migration.
- `lpephem::moon::phase` angle wrap simplified to `rem_euclid` (the `> 2π` branch was unreachable); `2.0 * PI` literals replaced with `std::f64::consts::TAU`.


## 0.18.1 - 2026-06-02

### High-precision propagator: force model unified across all integrators

- **A single `force_model()` replaces four duplicated force closures.** The RKV `ydot`, the RODAS4 `ydot_vec` and `jac_fn`, and the Gauss-Jackson 8 `accel_fn` each carried a near-identical copy of the acceleration physics (Earth gravity, Sun/Moon third-body, solid Earth tides, GR Schwarzschild, SRP, drag, thrust) — ~200 lines duplicated four ways. Adding a force term (as 0.18.0's tides and GR did) meant editing every copy in lockstep and risked them drifting apart. The physics now lives in one `force_model()` function in `orbitprop::propagator`; each integrator closure is a thin adapter that unpacks its own state layout (`Matrix<6, C>` / `Vector<f64, 6>` / separate `(r, v)`), calls `force_model()`, and packs the result. **No behavior change** — end states are bit-identical and the full Rust suite (including the ESA SP3 GPS regression) passes unchanged.
- **`ForceEval` mode selector** (`Accel` / `AccelAndPartials` / `PartialsOnly`) keeps each integrator's cost identical to the prior hand-specialized code. Every call site passes a compile-time-constant mode and `force_model` is `#[inline]`, so the compiler constant-folds the partials/accel branches away and regenerates the original specialization — no extra call, no runtime dispatch on the enum. The RODAS4 Jacobian path (`PartialsOnly`) still skips the tide/GR/SRP/thrust terms — they contribute no partials — so it does not regress.
- **Drag altitude gate uses `norm_squared()`** against a precomputed squared limit (`DRAG_RADIUS_LIMIT_M`) instead of `norm()`, removing a square root from every force evaluation regardless of whether drag is active.

### Earth gravity: stored coefficient table capped at the evaluation degree

- **`Gravity::parse` caps the coefficient table at degree 44** (`MAX_COEFF_DIM`) rather than storing the file's full resolution. The evaluator dispatches at degree ≤ 40 and the Cunningham recursion / divisor tables are 44×44, so higher-degree coefficients were never used — yet the default EGM96 model (file degree 360) was holding a 361×361 `DMatrix` (~1 MB). Capping it drops that to ~15 KB and, more importantly, shrinks the column-major stride from 361 to 44 so the strided S-coefficient reads `coeffs[(m-1, n)]` stay resident in L1 instead of scattering across ~3 KB jumps. **Results are bit-identical** (coefficients for n,m ≤ 43 are untouched, and computation never exceeds degree 40); a microbenchmark on EGM96 shows ~5–15% faster `accel` / `accel_and_partials` and, notably, eliminates the cache-miss timing variance in the hot loop. The cap is coupled to the pre-existing degree-40 dispatch and 44×44 divisor-table limits, and is documented as such so a future degree bump touches all three together.

### Fixed

- **`update_datafiles(overwrite=true)` now actually re-downloads EOP/SW files.** The `If-Modified-Since` conditional-GET added in #99 was only ever reached on the `overwrite_if_exists=true` path (the `overwrite=false` path short-circuits on file existence). Because the request used the local file's mtime — set to *download* time, not the server's `Last-Modified` — the server returned `304 Not Modified` and the daily-updated `EOP-All.csv` / `SW-All.csv` were never refreshed. Removed the conditional-GET block so `download_file` unconditionally fetches when called; `overwrite=true` again forces a fresh copy. Resolves [#115](https://github.com/ssmichael1/satkit/issues/115). (The `%a` `strftime` specifier added alongside #99 is retained.)


## 0.18.0 - 2026-05-25

### Solid Earth tides (IERS 2010 §6.2 Step 1) in the high-precision propagator

- **New `orbitprop::tides` module** implementing the IERS Conventions 2010 §6.2.1 Step 1 frequency-independent solid Earth tide model — Love-number response of the Earth's gravity field to lunar and solar attraction. Exposes `TideModel` (`None` / `SolidStep1` / `SolidFull`), `TideDeltas` (ΔC̄ₙₘ, ΔS̄ₙₘ for n=2,3,4), `solid_tide_deltas()` and `tide_accel()`. Resolves [#16](https://github.com/ssmichael1/satkit/issues/16).
- **Enabled by default.** `PropSettings::default()` now ships `tide_model: TideModel::SolidStep1`. Consistent with the project's existing "high-fidelity by default" stance (sun gravity, moon gravity, space weather are all on by default). The ~5% per-ydot overhead is dominated by Sun/Moon ITRF rotations rather than the tide math itself. **Behavior change for callers holding state-vector regression tests**: end-state positions will shift by ~tens of cm to several meters depending on regime and arc length. Pass `tide_model=TideModel::None` to reproduce pre-0.18 numerics.
- **Step 2 (frequency-dependent corrections from Tables 6.5a/b/c) deferred.** Selecting `TideModel::SolidFull` currently falls through to Step 1 behavior; the 71-constituent frequency-dependent correction is the next planned increment. ~99% of the solid-tide signal lives in Step 1, so the deferral has minimal accuracy cost for most use cases.
- **Wired into every integrator force closure** — RKV (`ydot`), RODAS4 (`ydot_vec`), and Gauss-Jackson 8 (`accel_fn`). The tide acceleration is computed in ITRF (where IERS §6.2 formulas are written) and rotated back to GCRF via the existing precomputed `qgcrf2itrf` quaternion. Tide partials are skipped from the STM update — ∂a_tide/∂r is ≲1e-12 of the J2 partial and well below filter-noise thresholds.
- **Validated against ESA's `ESA0OPSFIN` SP3 GPS truth file.** Max per-axis residual over a 1-day GPS-20 arc drops from 6.42 m (no tides) to 5.71 m (Step 1 tides) at degree-4 gravity — an 11% improvement. The `test_gps` regression threshold tightened from 8 m to 6.5 m to reflect the gain, and a new strict assertion enforces *with-tides residual < without-tides residual* so a future regression that breaks the wiring fails loudly.
- **Python bindings.** New `satkit.tidemodel` enum (`none` / `solid_step1` / `solid_full`) and `satkit.propsettings.tide_model` field, plumbed through both the kwarg constructor and getter/setter. `satkit.pyi` updated with full docstrings and the new constructor argument. Four new tests in `TestSolidTides` cover enum members, default value, kwarg/setter round-trip, and the with-vs-without functional difference.

### General-relativistic Schwarzschild correction in the high-precision propagator

- **New `orbitprop::relativity` module** implementing the IERS Conventions 2010 §10.3 Eq. 10.12 Schwarzschild (post-Newtonian, β = γ = 1) acceleration. Single `gr_schwarzschild_accel(pos_gcrf, vel_gcrf, mu_e) -> Vector3` function — ~10 flops, trivial computational cost. Resolves [#109](https://github.com/ssmichael1/satkit/issues/109).
- **Enabled by default.** `PropSettings::default()` now ships `use_relativistic_correction: true`. Schwarzschild is universal physics, not a model choice — the flag exists for reproducibility against pre-0.18 numerics, not because the term is optional in practice.
- **~1 m/day position drift at GPS altitude** if omitted; ~3 m/day at GEO. The empirical NTW acceleration fit in the high-precision propagation tutorial (`docs/tutorials/High Precision Propagation.ipynb`) was previously absorbing this signal — explicit GR modeling shifts the empirical from "catch-all for ~0.17 m of un-modeled physics" to "smaller catch-all for the rest (higher-order SRP, ocean tides, …)."
- **Lense-Thirring and de Sitter currently omitted** (sub-cm-class effects at LEO/MEO). Reserved for future expansion behind the same `use_relativistic_correction` flag.
- **Wired into every integrator force closure** (RKV, RODAS4, GJ8). GR partials are ~1e-15/m vs J2's ~1e-7/m and are skipped in the STM update — same precision tradeoff as the tide block.
- **GPS regression test threshold tightened to 2.5 m** (was 6.5 m with tides only, 8 m before tides). Hardcoded `v0` + `Cr*A/m` refitted against ESA SP3 truth using the updated force model. The previous "with-tides residual < without-tides residual" strict assertion has been dropped from this test — once the IC is fit to the full force model, the initial state absorbs enough of the constant-shift part of the tide signal that toggling tides off can produce a *smaller* residual on this particular arc. The independent `test_solid_tides_perturb_orbit` test still guards that tides change the propagation.
- **Python bindings.** New `propsettings.use_relativistic_correction: bool` field exposed via kwarg constructor and getter/setter. `.pyi` stub updated with docstrings. Three new tests in `TestRelativisticCorrection` cover the default, setter/kwarg round-trip, and a with-vs-without propagation diff at GPS altitude.

### Bytes/path init for every data-file subsystem (embedded / non-filesystem support)

- **New `init_from_bytes(&[u8])` and `init_from_path(&Path)` on each of the six data-file subsystems** — `jplephem`, `earthgravity`, `frametransform::ierstable`, `spaceweather`, `solar_cycle_forecast`, `earth_orientation_params`. Lets callers populate satkit's runtime state from a database row, an `importlib.resources` buffer, a memory-mapped file, or any other non-filesystem source, instead of forcing every data file through `datadir()`. Motivated by two concrete use cases: (a) embedded contexts where the ephemeris and EOP live in SQLite/blob storage rather than on disk, and (b) conda-forge packaging where the data file is bundled as a Python package resource and accessed via `pkgutil` / `importlib.resources` returning bytes. The bytes APIs take borrowed `&[u8]` so callers don't pay a double allocation.
- **Two semantics, picked per subsystem.** *Static* subsystems (`jplephem`, `earthgravity`, `frametransform::ierstable`) wrap a `OnceLock` and return `Err(AlreadyInitialized)` if init is called after the lazy default load has already won — the data is mathematical constants and replacing it mid-process makes no sense. *Refreshable* subsystems (`spaceweather`, `solar_cycle_forecast`, `earth_orientation_params`) wrap a `RwLock<Option<T>>` and always succeed, replacing any previously loaded data — CelesTrak / NOAA / IERS publish daily-to-monthly updates and refresh-in-place is the intended model.
- **`earth_orientation_params` drops `AtomicPtr` + intentional-leak for safe `RwLock<Option<...>>`.** The old EOP loader stored a `Vec<EOPEntry>` behind an `AtomicPtr` and leaked the previous allocation on every `update()` since other threads might still hold the lock-free reference. The new pattern matches `spaceweather` and `solar_cycle_forecast` — one `RwLock`, one `std::sync::Once` for the silent default-load attempt, no `unsafe` in the read path. EOP reads are cheap enough that the lock cost is unmeasurable next to the binary-search-plus-interpolation work; benchmarks would not detect the change.
- **`spaceweather` consolidates `OnceLock<RwLock<Result<_>>>` to a const-init `RwLock<Option<_>>`.** Same destination as the EOP cleanup — one mechanism across all three refreshable subsystems instead of two-and-a-half. The `Result` that used to live inside the lock was capturing initial-load failures for later readers to encounter; with the new init API, load failures bubble up to whoever called `init_from_*`/`update`, and `get()` simply returns `NoRecordForDate` if the singleton stays empty.
- **Parsers factored out for testability.** Each subsystem's loader is now split into a pure parser (`parse_csv(&str)` / `JPLEphem::parse(&[u8])` / `Gravity::parse(&str)` / etc.) plus thin `from_file` / `from_path` / `from_bytes` wrappers around it. The byte-buffer entry point is the natural primitive; everything else is composition on top.
- **Rust-only.** No Python bindings — embedded users live in Rust and notebook/script users have `satkit.utils.update_datafiles()` already.
- **Integration tests in `tests/`.** Each subsystem gets a separate-binary integration test that verifies bytes-init populates the singleton, the relevant query function reads from the just-installed data, and the static-vs-refreshable second-init semantics work as specified. Six new test files; all run cleanly even when the underlying data files are absent (they skip with a notice rather than failing).

### JPL ephemeris file selection is now configurable; DE405–DE441 all supported

- **`SATKIT_JPLEPHEM_FILE` env var.** Either an absolute path, or a basename to resolve under `datadir()`. Lets a user point satkit at a specific ephemeris file without dropping it at the legacy filename.
- **Autodetect from `datadir()`.** When the env var isn't set, scan the data directory for any file matching `linux_p<start>p<stop>.4XX` *or* `lnxp<start>p<stop>.4XX` (DE421 and earlier used the older `lnxp` prefix; DE430 and later use `linux_p`). The highest DE-version suffix wins.
- **Legacy fallback unchanged.** With neither the env var nor any matching file present, the singleton falls back to `linux_p1550p2650.440` under `datadir()` and triggers the existing GCS auto-download. Existing installs are not affected.
- **Auto-download is now scoped to the legacy default name only.** Previously, any filename passed through `from_file` would trigger a GCS request if the file was missing — guaranteed to 404 for anything other than the DE440 full-span binary. The new behaviour: download only for the legacy name; for any other resolved path, the file must already exist (or be supplied via `init_from_*`).
- **Parser is fully header-driven.** `de_version`, `n_con`, the interpolation-pointer table `ipt[15][3]`, the kernel size, and the JD span are all read from the file header rather than hard-coded. DE405 / DE421 / DE430 / DE440 / DE441 all parse through the same code path. The one special branch in the parser (`if de_version > 430 && n_con != 400`) handles the extended constants block introduced in DE440; DE421-and-earlier files take the older path.
- **DE421 end-to-end verification.** A file-gated integration test loads `lnxp1900p2053.421` via `init_from_path` and walks the official `testpo.421` truth values — 380 position vectors match to <1e-10 relative error, confirming the smaller-`n_con` layout is parsed correctly. The DE421 binary is ~13 MB vs the full DE440 at ~98 MB, making it a viable default for distribution channels (e.g. conda-forge) where the 100 MB blob is a friction point.
- **Out-of-range queries are explicit.** Calling `geocentric_state` / `barycentric_state` outside the loaded file's `[jd_start, jd_stop]` window returns `Error::InvalidJulianDate(jd)` instead of returning silently wrong values. This was already the existing behaviour but is now load-bearing: a DE421 install (range 1899–2053) will start rejecting queries past 2053, and that's the correct failure mode.


## 0.17.0 - 2026-05-22

### Frame-enum dispatch: `rotation` / `rotation_approx` / `transform_state`

- **New `frametransform::rotation(from, to, t)` and `transform_state(from, to, t, pos, vel)`** plus their `_approx` companions. Single-call frame-to-frame dispatch — `rotation(Frame::ITRF, Frame::GCRF, &t)` replaces having to know which of `qitrf2gcrf` / `qteme2itrf` / `qcirs2gcrs` / … is the right function for a given pair. Matches the convention every modern peer library (SPICE `pxform`, Orekit `Frame.getTransformTo`, Astropy `SkyCoord.transform_to`, ANISE `Almanac::transform`) has used for years; satkit's previous "named function per directional pair" pattern was the outlier.
- **Shortest-path dispatch, not always-through-GCRF.** The hand-coded match arms take the natural shortest path through the frame graph for each pair: ITRF↔TIRS pays only polar motion, ITRF↔CIRS skips the expensive precession/nutation step, and ITRF↔GCRF uses the existing amortised direct function (one EOP lookup feeds both polar motion and the dX/dY nutation correction). No transform pays full IERS 2010 reduction unnecessarily.
- **EME2000 and ICRF are now real implementations** rather than stub variants. EME2000↔GCRF uses the J2000 frame-bias matrix (IERS 2010 §5.4.4 — three small constant angles totalling ~17 milliarcsec). The matrix is pinned in tests against an independent numpy computation of the IERS reference: bit-perfect match (max element diff ~1e-16 rad, pure floating-point rounding). ICRF↔GCRF is treated as identity (the actual offset is < 0.1 arcsec and below satkit's other modelling errors). Previously both variants existed in the `Frame` enum but every call site that touched them panicked or returned `UnsupportedFrame`.
- **`rotation_approx` is honest about its narrower domain.** TIRS and CIRS are defined by the IERS 2010 reduction and have no FK5 analogue — `rotation_approx` returns `Error::ApproxNotSupportedForFrame` rather than silently falling back. Orbit-dependent frames (LVLH, RTN, NTW) return `Error::OrbitFrameRequiresState` with a pointer at the existing `to_gcrf` / `from_gcrf` helpers.
- **`transform_state` covers all non-orbit frame pairs** — identity, ITRF↔inertial, TIRS/CIRS↔inertial, within-inertial, and ITRF↔TIRS — with proper sweep-term bookkeeping. The `ω⊕ × r` sweep is evaluated in TIRS where ω⊕ is exactly along +ẑ, then composed onto the requested endpoints. Polar motion between ITRF and TIRS is treated as a static rotation: its rate is ~1.7e-9 rad/s, contributing sub-mm/s at LEO, and is neglected to match the existing `itrf_to_gcrf_state` convention. Orbit-dependent frames remain `to_gcrf` / `from_gcrf` territory.
- **Python bindings + type stubs.** `satkit.frametransform.rotation`, `rotation_approx`, `transform_state`, `transform_state_approx`. Both `rotation` and `rotation_approx` accept either a scalar time or an array of times — returning a single quaternion or a list of quaternions respectively, matching the per-pair functions' batch shape. The original per-pair functions remain; this layer is purely additive.
- **Docs.** All major tutorials updated to use the dispatch API in examples. The *first* call in each introductory page uses keyword arguments (`rotation(from_frame=ITRF, to_frame=GCRF, tm=t)`) so the source / destination direction is unambiguous at first sight; subsequent calls in the same tutorial fall back to positional form for brevity. A new "Dispatch API" section in `docs/api/frametransform.md` introduces all four entry points.
- **No `_ =>` catch-alls on `Frame`.** Both the dispatch match arms and the internal `is_earth_rotating` / `is_orbit_dependent` classifiers enumerate every variant explicitly so the compiler will flag any future `Frame` additions.
- **Direction pins.** Each TEME-involving dispatch arm is compared against the canonical `qteme2gcrf` / `qitrf2tirs` / `qteme2itrf` composition so a future change that flips a sign fails immediately — the roundtrip test alone can't catch direction errors because `rotation(b, a)` is always the conjugate of `rotation(a, b)` regardless of which is "right".

### `ITRFCoord::to_enu` / `to_ned`: parameter renamed `ref_coord` → `origin`, docstrings overhauled

- **Parameter rename.** `ITRFCoord::to_enu(&self, ref_coord)` and `to_ned(&self, ref_coord)` now take `origin` instead. The ENU/NED triad is *anchored* at this argument — calling it "origin" matches the standard local-tangent-frame terminology and makes call sites read like prose: `satellite.to_enu(&station)` ("ENU of the satellite, with the station as the origin"). Rust callers are unaffected (positional args); Python callers using `origin=` as a kwarg will need to update from `refcoord=` / `other=`. Resolves [#91](https://github.com/ssmichael1/satkit/issues/91).
- **Docstrings rewritten.** Lead with a one-line "FROM `origin` TO `self`" statement and an explicit sign convention for the Up/Down component. Example renamed from `itrf1`/`itrf2` to `station`/`satellite` so the canonical sat/ground-station case (the source of the issue) is the worked example. Mirrored across the Rust source, the PyO3 binding, and the `.pyi` stub.
- **Latent Python binding inconsistency fixed.** `to_ned`'s parameter was `other` in code but documented as `refcoord` — both now consistently `origin`.

### `satkit::Error` façade is deprecated

- **`satkit::Error` and `satkit::Result` marked `#[deprecated(since = "0.17.0")]`.** The top-level error enum was added in 0.16.x (PR #86) as a convenience for downstream apps that wanted a single result type to unify the per-module typed errors. In practice the module-scoped errors (`tle::Error`, `orbitprop::Error`, …) plus a downstream-defined `enum AppError` or `anyhow::Result` cover the use case more cleanly and don't lock satkit into a public surface that has to grow in lockstep with every new module-level error variant. Both `Error` and `Result` are still exported and functional; the deprecation just nudges callers toward the more durable pattern. Removal is planned for a future release.

### `update_datafiles` uses conditional GETs to skip unchanged files

- **`If-Modified-Since` on every refresh download.** `download_file` now formats the local file's mtime as an HTTP-date and sends it as `If-Modified-Since`. On a `304 Not Modified` response, the existing file is left untouched and the function returns `Ok(false)`. The two regularly-refreshed files (`EOP-All.csv` and `SW-All.csv`, both served by celestrak.org) had been re-downloaded on every `update_datafiles()` call regardless of whether they had changed — now they only transfer when the server reports a newer `Last-Modified`. Bandwidth-constrained users (e.g. on cellular) see ~3 MB/run drop to a pair of HEAD-sized 304s. Resolves [#97](https://github.com/ssmichael1/satkit/issues/97).
- **New `%a` format code in `Instant::strftime`** (`src/time/instantparse.rs`). Abbreviated weekday name — `Sun`, `Mon`, ..., `Sat` — parallel to the existing full-name `%A`. Added to support the RFC 7231 IMF-fixdate format (`%a, %d %b %Y %H:%M:%S GMT`) used by `If-Modified-Since`. No new dependencies.
- **Behavior of `overwrite_if_exists=false` is unchanged**: the local-existence fast path still short-circuits before any network call. The flag now effectively means "ask the server whether to re-fetch" when true, rather than "always re-fetch".

### Python type stubs: `TLE.from_lines` accepts any `Sequence[str]`

- **`TLE.from_lines` stub widened from `list[str]` to `Sequence[str]`** (`python/satkit/satkit.pyi`). The Rust binding already accepted any Python sequence at runtime — only the stub was narrow, so callers passing a `tuple[str, str]` (the natural shape for a 2-line TLE) got spurious type-checker complaints. Resolves [#93](https://github.com/ssmichael1/satkit/issues/93).
- **`@overload` for fixed-length tuples.** `tuple[str, str]` and `tuple[str, str, str]` (the 2-line and name+2-line forms) are now statically typed as returning a single `TLE`; other sequences still return `TLE | list[TLE]`. Callers writing `tle = TLE.from_lines((line1, line2))` no longer need to narrow the result.

### `ureq` is now an optional dep behind the `download` feature

- **New `download` Cargo feature** (default-on) gates `ureq`. `TLE::from_url`, `OMM::from_url`, `solar_cycle_forecast::update`, and the `satkit::utils::{download_file, download_file_async, download_to_string, download_if_not_exist, update_datafiles}` helpers all live behind it. Default builds are unchanged. Users who want a slim dependency tree can opt out via `cargo build --no-default-features --features omm-xml`, which drops ~25 transitive crates (`ureq`, `rustls`, `ring`, `rustls-webpki`, `webpki-roots`, `flate2`, etc.). Without the feature, `download_if_not_exist` will succeed if the file already exists and otherwise return an error; the other download helpers always return an error.
- **Python builds are unaffected.**

### Licensing

- **Dual-licensed under MIT OR Apache-2.0.** Previously MIT-only. Apache-2.0 adds an explicit patent grant — important now that the project is taking external contributions, since it binds contributors to a patent peace clause that bare MIT does not. Matches the Rust ecosystem convention. `LICENSE` was renamed to `LICENSE-MIT` and a new `LICENSE-APACHE` was added; `Cargo.toml` and `pyproject.toml` `license` fields updated to `"MIT OR Apache-2.0"`. Downstream users may continue to use the project under either license at their option — no action required.

### Workspace-wide `cargo fmt`

- **CI now enforces `cargo fmt`.** A new `lint` job runs `cargo fmt --all -- --check` on ubuntu before the build matrix kicks off, so formatting failures surface on a single runner instead of three. Contributors should run `cargo fmt` locally or enable format-on-save in their editor before pushing.
- **Workspace reformatted in one mechanical commit.** No functional changes; 52 files touched (+5741 / −1336). Kicked off by [@parker-research](https://github.com/parker-research) in #89.
- **`.git-blame-ignore-revs` added** to mask the bulk-format commit from `git blame`. GitHub's web blame honors it automatically. Locally, opt in with `git config blame.ignoreRevsFile .git-blame-ignore-revs`.


## 0.16.2 - 2026-04-13

### Frame Transforms

- **Batched `itrf_to_gcrf_state` / `gcrf_to_itrf_state` (Python).** Both functions now accept either a single state (length-3 `pos`/`vel` + scalar `time`) or a batch of `N` states (shape `(N, 3)` arrays + length-`N` time array/list) and return matching-shape output. Previously required a Python loop.
- **New `itrf_to_gcrf_state_approx` / `gcrf_to_itrf_state_approx` (Rust + Python).** Approximate IAU-76/FK5 variants of the full-state transforms (~1 arcsec on position, <1 m/s on velocity vs. full IERS 2010). Substantially cheaper when full precision isn't required; polar motion is neglected so the `ω⊕ × r` sweep is evaluated in ITRF directly. Scalar and batched inputs supported in Python.

### Documentation

- **Nomenclature sweep: "IAU 2006" / "IAU-2000" → "IERS 2010" / "IAU 2000A".** The full reduction chain satkit implements is the IERS 2010 Conventions, which adopt the IAU 2006 precession with the IAU 2000A nutation series — referring to it as "IAU-2006 reduction" or "IAU-2000 nutation" was imprecise. Updated across `README.md`, `docs/index.md`, crate-level `lib.rs` docs, `src/earth_orientation_params.rs` doc comments, and the **Coordinate Frames** and **Plots** tutorials.


## 0.16.1 - 2026-04-05

### Dependency Cleanup

- **`rmpfit` removed.** `TLE::fit_from_states` previously used the `rmpfit` crate (a thin wrapper around the `cmpfit` C library) for Levenberg-Marquardt. Replaced with a small local LM loop built on top of `numeris` fixed-size linear algebra (7×7 normal equations, finite-difference Jacobian, numeris `LuDecomposition` for the damped solve). SGP4 failures on perturbed or trial parameters are now handled as step rejections rather than hard errors, so fits that previously aborted mid-way (e.g. the 400 km LEO + drag test case) now converge.
- **`rmpfit::MPStatus` → `satkit::tle::TleFitResult`.** New public types `TleFitStatus` (enum) and `TleFitResult` (struct with `orig_norm`, `best_norm`, `grad_norm`, `n_iter`, `n_res_evals`) replace the rmpfit-specific return type. The Python `tle.fit_from_states` now returns a dict with keys `status`, `converged`, `orig_norm`, `best_norm`, `grad_norm`, `n_iter`, `n_res_evals`, and the Python `mpsuccess` class is replaced by `tlefitstatus` with variants `GradientConverged`, `StepConverged`, `CostConverged`, `MaxIterations`, `DampingSaturated` plus a `.converged()` helper.
- **`itertools` removed.** The crate was used for a single method (`take_while_ref`) at 5 call sites in `time/instantparse.rs`. Replaced with a local `take_while_peek` helper on `Peekable<Chars>`.
- **`json` crate removed.** The two usages (`utils/update_data.rs`, `solar_cycle_forecast.rs`) migrated to `serde_json`, which was already a dependency. Reduces crate graph by one JSON parser.
- **`serde-pickle` removed from the top-level crate.** It was only actually used in `python/src/pypropresult.rs`; the root `Cargo.toml` declaration was dead. Still a dependency of the `satkit-python` crate.

### Bug Fixes

- **Clippy**: fix `clone_on_copy` on a `Copy` error enum in `pysgp4.rs`; factor a `LambertSolution` type alias in `pylambert.rs` to silence `type_complexity` warnings. Full workspace now builds clean under `cargo clippy --all-targets`.

### Documentation

- **README.md** and **lib.rs** crate-level docs updated for 0.16: canonical `Frame::RTN` (with `RIC`/`RSW` aliases) and new `NTW` / `LVLH` frames in the maneuver list, Gauss-Jackson 8 in the integrator list, updated Python version range (3.10–3.14), corrected docs URL (<https://satkit.dev/>), bumped `numeris` example version to 0.5.7, and refreshed test counts (157 Rust + 81 Python).
- **High Precision Propagation** tutorial: dropped three unused imports (`math`, `numpy.typing`, `scipy.optimize.minimize_scalar`).
- **Two-Line Element Set**, **Orbital Mean-Element Message**, and **Optical Observations of Satellites** tutorials: replaced hand-rolled `requests.get` / `xmltodict` / hardcoded TLE-line blocks with the built-in `sk.TLE.from_url(url)` and `sk.omm_from_url(url)` helpers introduced in 0.15.1. The OMM notebook no longer depends on `requests` or `xmltodict` at all; the Optical Observations notebook now derives all sample times from `tle.epoch` so the fit is reproducible against the current TLE.


## 0.16.0 - 2026-04-05

### Gauss-Jackson 8 Integrator

- **`Integrator::GaussJackson8`**: fixed-step 8th-order multistep predictor-corrector specialised for orbit propagation. Typically 3-10x fewer force evaluations than RKV98 for smooth long-duration runs. Combined Gauss-Jackson + Summed-Adams formulation handles velocity-dependent forces (drag, SRP) natively. Per-step dense output via quintic Hermite interpolation.
- Selected with `Integrator::GaussJackson8` and a user-supplied `gj_step_seconds` (Rust) / `propsettings(integrator=satkit.integrator.gauss_jackson8, gj_step_seconds=...)` (Python). No STM support.
- Lives in a new `satkit::orbitprop::ode` submodule (astrodynamics-specific enough that it doesn't belong in `numeris`).
- **Precompute bounds fix**: `Precomputed::new_padded` now takes an explicit padding; `PropSettings::required_precompute_padding` automatically extends interp-table bounds to cover the GJ8 backward startup stencil (4 × `gj_step_seconds` on each end). Previously silently failed for `gj_step_seconds > 60`.

### Coordinate Frames: RTN canonical, NTW, LVLH maneuvers

- **Breaking: `Frame::RIC` renamed to `Frame::RTN`** as the canonical name (matches the CCSDS OEM convention). `Frame::RIC` and `Frame::RSW` remain as compile-time aliases (`pub const RIC: Self = Self::RTN`), so existing code using either name still compiles and `Frame::RIC == Frame::RTN` is `true`. Python exposes `frame.RIC` and `frame.RSW` as class-level aliases of `frame.RTN`.
- **`Frame::NTW`** (velocity-aligned: T=v̂, W=ĥ, N=T×W). Natural for prograde/retrograde burns on eccentric orbits: a pure +T delta-v of magnitude Δv adds exactly Δv to |v|, while an in-track RTN burn of the same magnitude loses a factor of cos γ where γ is the flight-path angle. Accepted by maneuvers, thrust, uncertainty, and frame transforms.
- **LVLH** is now a supported maneuver/thrust coordinate frame (previously valid only for uncertainty).
- **Unified frame-transform API**: `frametransform::to_gcrf(frame, pos, vel)` and `from_gcrf(frame, pos, vel)` replace the combinatorial explosion of per-frame helpers. `state_to_gcrf` / `gcrf_to_state` handle the position+velocity pair with the correct **TIRS-frame** Earth-rotation term (`ω⊕ × r_tirs`) for ITRF↔GCRF. Validated against Vallado Example 3-14.
- **New guide: "Theory: Maneuver Coordinate Frames"** (`docs/guide/maneuver_frames.md`) — side-by-side GCRF / RTN / NTW / LVLH comparison with flight-path-angle derivation, a worked numeric example on an e=0.3 orbit showing the 0.245 m/s discrepancy, a cheat sheet, and a summary table.

### Breaking: Unified Uncertainty API

- **`SatState::set_pos_uncertainty(sigma, frame)` and `set_vel_uncertainty(sigma, frame)`** replace the four per-frame methods (`set_lvlh_pos_uncertainty`, `set_lvlh_vel_uncertainty`, etc.). Supports `GCRF`, `LVLH`, `RTN`, and `NTW`. Each call preserves the 3×3 block it is not updating, so pos-then-vel correctly builds a full 6×6 covariance — the old methods silently overwrote the whole matrix. **Old methods are removed, not deprecated.**
- **Doc fix**: the default covariance frame is `LVLH`, not `RIC` as previously documented in several places.

### Breaking: Python API Parity with Rust

- `satstate.add_maneuver(time, delta_v, frame)`, `set_pos_uncertainty`, `set_vel_uncertainty`, and `thrust.constant` now all require an **explicit** `frame` argument from Python, matching Rust (no silent defaults).
- Added ergonomic helpers on `satstate`: `add_prograde`, `add_retrograde`, `add_radial`, `add_normal` alongside the generic `add_maneuver`.
- Added matching Rust constructors on `ImpulsiveManeuver`: `prograde`, `retrograde`, `radial_out`, `normal`, plus `gcrf` / `rtn` / `ntw` for arbitrary-vector burns.

### Default Gravity Model: EGM96

- **Breaking (subtle)**: `PropSettings::default()` now uses `GravityModel::EGM96` instead of `JGM3`. EGM96 is a more modern and more widely used model; the numerical difference for typical LEO propagation is sub-meter over a day, but the default selection changes. Python `propsettings()` and the standalone `gravity()` / `gravity_and_partials()` helpers pick up the new default automatically.

### Documentation

- **Coordinate Frame Transforms tutorial** rewritten: explicit GCRS/ICRF and ITRS definitions (quasar VLBI realisation vs. ground-tracking realisation), geodetic-vs-geocentric explanation, ground-track overlay on a cartopy `PlateCarree` map, time-series plot of `qgcrf2itrf_approx` vs full IERS 2010 error over 30 years. Dropped the low-value 24-hour Earth-rotation section.
- **New API reference page**: `docs/api/frame.md` documenting the `Frame` enum with all variants and aliases.
- **MathJax** now accepts both `\(...\)` / `\[...\]` and `$...$` / `$$...$$` delimiters, so equations render correctly in Jupyter-notebook tutorials (previously broken in `Quaternions.ipynb` and others).
- **Covariance Propagation** tutorial simplified to use the unified uncertainty API (dropped the hand-rolled LVLH→GCRF rotation).
- **High Precision Propagation**, **satprop guide**, **maneuver/covariance examples** updated for the new API.

### Tutorial Reorganization

- **Renamed "Coordinate Frame Transforms" → "Coordinate Frames"**. The tutorial is primarily a description of the frames themselves (GCRF, ITRF, TEME), not just the rotations between them.
- **Renamed "ITRF Coordinates" → "Geodetic Coordinates"**. This tutorial is about the `itrfcoord` data type (geodetic / Cartesian / ENU / NED / geodesic distance), not the ITRF reference frame. The old name made it sound like two views of the same topic as Coordinate Frames.
- Nav reordered so **Coordinate Frames** (frame theory) comes before **Geodetic Coordinates** (data type built on top). Reciprocal cross-reference notes added at the top of both tutorials.
- **Expanded TEME section** in Coordinate Frames: origin of the "True Equator, Mean Equinox" name (intentional half-and-half construction), the three practical awkwardness points (not uniquely defined, time-dependent orientation, positions cannot be compared directly), API table, and Vallado 2006 reference.
- **"Why yet another time type?" section** added at the top of the Time Systems tutorial, covering time-scale-as-first-class, microsecond-precision i64 representation, correct leap-second handling, built-in UT1/TDB, and the single-type-across-Rust-and-Python story.

### Propagator: Configurable `max_steps`

- **`PropSettings::max_steps`** (Rust) / **`propsettings(max_steps=...)`** (Python): configurable maximum number of integrator steps before the propagator aborts with a max-steps error. Applies uniformly to the adaptive Runge-Kutta / Rosenbrock solvers (via `numeris::ode::AdaptiveSettings::max_steps`) and the Gauss-Jackson 8 solver (via its own internal settings). Default: 1_000_000, which matches the previous hard-coded Gauss-Jackson 8 ceiling and is a loosening of the previously inherited numeris RK default of 100_000. This covers very long arcs (≈700 days of GJ8 at 60 s step) with headroom; lower for a tighter runaway-propagation safeguard.

### Release Tooling

- **`release.yml` `check_version`**: now verifies the tag matches *all three* version strings — root `Cargo.toml`, `python/Cargo.toml`, and `pyproject.toml`. Previously only the root Cargo.toml was checked, which allowed `python/Cargo.toml` to drift silently if a version bump was applied by hand instead of through `cargo release`. Any future drift will hard-block the release workflow.

### Bug Fixes

- **MathJax in Jupyter notebooks**: remove the `ignoreHtmlClass` / `processHtmlClass: "arithmatex"` restriction in `docs/javascripts/mathjax.js` that caused MathJax to skip notebook HTML entirely (mkdocs-jupyter does not wrap notebook-cell math in an `.arithmatex` span). Equations in the Quaternions tutorial and all other notebooks now render. Plain markdown pages still work because pymdownx.arithmatex (generic mode) emits raw delimiters that MathJax picks up under default scanning.
- **`test_gravity`**: explicitly pin to `model=sk.gravmodel.jgm3`. The ICGEM reference values in that test are for JGM3 specifically; previously they relied on the default, which switched to EGM96 in this release.

### Internal

- `Frame` derives `Copy + PartialEq + Eq`; `PyFrame::NTW`, `PyIntegrator::gauss_jackson8`, `PyPropSettings::gj_step_seconds`, `PyPropResult::gj_dense` exposed in the Python bindings.
- `.pyi` stubs updated throughout: new frame variants, new integrator variant, `gj_step_seconds`, unified uncertainty API, ergonomic maneuver helpers, corrected frame docstrings.
- All 20 tutorial notebooks re-executed and stripped of outputs (mkdocs-jupyter re-executes at build time).
- 157 Rust tests + 81 Python tests pass (up from 133 / 71 at 0.15.1).


## 0.15.1 - 2026-03-29

### URL Loading

- **`TLE.from_url(url)`**: Load TLE(s) directly from a URL returning plain-text TLE data
- **`OMM.from_url(url)`** (Rust): Load OMM(s) from a URL with auto-detection of JSON vs XML format
- **`omm_from_url(url)`** (Python): Fetch OMMs from a URL and return as a list of dictionaries, compatible with `sgp4()`

### SatState Documentation

- Expose `set_lvlh_vel_uncertainty()` and `set_gcrf_vel_uncertainty()` in Python bindings
- Rewrite `SatState` struct and type stub documentation: when to use it vs `propagate()`, units, pickle support
- Add `SatState` section to user guide with comparison table and code examples
- Add all missing method stubs to `satkit.pyi`

### Plot Styling

- Extract shared matplotlib style to `docs/satkit.mplstyle`, replacing duplicated `rcParams` blocks across 18 notebooks (22 occurrences, -386 lines)

## 0.15.0 - 2026-03-29

### Orbit Maneuvers

- **Impulsive maneuvers**: Add `ImpulsiveManeuver` to `SatState` — instantaneous delta-v applied at a scheduled time during propagation. Supports GCRF and RIC frames. Propagation automatically segments at maneuver times and applies delta-v, including backward propagation with sign reversal.
- **Continuous thrust**: Add `ContinuousThrust` and `ThrustProfile` types for constant-acceleration thrust arcs over time windows. Integrated into the force model via the `SatProperties` trait. Supports GCRF and RIC frames with automatic frame rotation.
- **RIC frame transforms**: Add `ric_to_gcrf()` and `gcrf_to_ric()` rotation matrix functions to `frametransform`
- **`Frame::RIC` and `Frame::LVLH`**: New coordinate frame variants with explicit axis definitions in all docs

### Python Bindings

- `satstate.add_maneuver(time, delta_v, frame)` — add impulsive maneuvers to satellite state
- `thrust.constant(accel, start, end, frame)` — create continuous thrust arcs
- `satproperties(thrusts=[...])` — attach thrust arcs to satellite properties
- `frametransform.ric_to_gcrf(pos, vel)` and `frametransform.gcrf_to_ric(pos, vel)`
- Full type stubs and docstrings for all new types

### Code Quality

- **Frame safety**: Replace `_ =>` catch-all match arms in thrust/maneuver frame handling with explicit variants; unsupported frames now panic with descriptive messages
- **Pickle round-trip**: `satstate` pickle now serializes maneuvers; `satproperties` pickle now serializes thrust arcs (backwards compatible with old format)
- **Coordinate frame docs**: Add explicit LVLH and RIC axis definitions across Rust doc comments, Python docstrings, and type stubs
- **Test split**: Split monolithic `test.py` (1,563 lines) into 6 domain-specific test files (`test_time`, `test_coordinates`, `test_frames`, `test_ephemeris`, `test_propagation`, `test_sgp4`)
- **`__init__.pyi`**: Fix `__all__` export list — add missing `frame`, `weekday`, `sgp4_error`, `sgp4_gravconst`, `sgp4_opsmode`, `geodetic`, `gravity_and_partials`, `nrlmsise00`
- **CI**: Update `build.yml` and `release.yml` to discover all test files via `pytest python/test/`

### Documentation

- New tutorial: Orbit Maneuvers (Jupyter notebook)
- Unified Learn section merging User Guide and Tutorials

### Internal

- 133 Rust tests + 37 doc-tests pass
- 71 Python tests pass (4 new pickle round-trip tests, 10 new maneuver/thrust tests)

## 0.14.3 - 2026-03-25

### Performance

- **Binary search interpolation**: Replace linear scan with `partition_point` for dense output step lookup (O(log n) vs O(n))
- **Batch interpolation**: Add `interp_batch` method to `PropagationResult` — single-pass walk over sorted query times (O(n+m) vs O(m log n))
- **Vectorized Python interp**: `result.interp(time_list)` now returns an Nx6 numpy array in one FFI round-trip instead of N individual calls (~4x speedup at 10k points)
- Requires numeris 0.5.7 (`interpolate_batch` support)

## 0.14.2 - 2026-03-25

### Bug Fixes

- **`from_gps_week_and_second`**: Fix week constant (was 168 days instead of 7 days)
- **GPS MJD conversions**: `as_mjd(GPS)` and `from_mjd(GPS)` now return proper Modified Julian Dates instead of days since GPS epoch
- **J2000 constant**: Fix ~96-second error where TT offset was applied in the wrong direction
- **TDB coefficient**: Fix 10x error in `from_mjd(TDB)` amplitude (`0.01657` -> `0.001657`)
- **TDB J2000 reference**: Fix JD-to-MJD offset (`2400000.4` -> `2400000.5`)
- **RFC 3339 timezone offsets**: `from_rfc3339` now correctly parses timezone offsets (e.g., `-05:00`, `+05:30`) instead of silently ignoring them
- **`from_string` parser**: Remove debug `println!`, fix microsecond default causing `-0.000001s` error, guard out-of-bounds access, fix index-shifting bug when removing parsed tokens
- **Cartopy `DownloadWarning`**: Fix warning filter in tutorial notebooks (`category=UserWarning` didn't match cartopy's `DownloadWarning`)
- **Python stubs**: Mark `quaternion.angle` and `quaternion.axis` as `@property` (were incorrectly declared as methods)

### Improvements

- **Duration display**: Show remaining units instead of cumulative totals (e.g., "1 days 1 hours" instead of "1 days 25 hours")
- **Typo fix**: "Coordinate Univeral Time" -> "Coordinated Universal Time"

### Documentation

- New tutorials: Time Systems, Quaternions, SGP4 vs Numerical Propagation
- Add 5 missing tutorials to mkdocs navigation
- Update homepage with quick-start examples and feature summary
- New Rust tests for GPS week/second and RFC 3339 timezone parsing
- New Python test for GPS week/second

## 0.14.1 - 2026-03-21

### Lambert Targeting

- Add Lambert's problem solver using Izzo's algorithm (2015) with Householder 4th-order iteration
- Handles all orbit types (elliptic, parabolic, hyperbolic) and 180-degree transfers
- Multi-revolution solution support
- Rust API: `satkit::lambert::lambert()` in new `lambert` module
- Python API: `satkit.lambert()` with full type stub and docstring
- 9 Rust tests, 6 Python tests

### Documentation Overhaul

- Switch all tutorial plots from Plotly to matplotlib with SciencePlots
- STIX serif fonts, SVG output, colorblind-friendly palette matching numeris docs
- Match numeris site theme: blue grey header with navigation tabs
- Muted steel blue link color (#4a7c96)
- Static SVG plots (density, forces) generated at build time via `docs/examples/gen_plots.py`
- Add Lambert Targeting tutorial with delta-v analysis, pork-chop plot, and orbit visualization
- Add Lambert solver to User Guide and API Reference
- Fix docstring formatting across all Python stubs: bullet style, Returns sections, clickable URLs, Notes admonitions
- Add concrete defs for all `@typing.overload` functions so mkdocstrings renders them
- Add `@typing.overload` signatures to `propresult.interp` for all call patterns
- CI/CD: add cartopy/certifi system deps, generate plots before build, remove plotly

### Internal

- 114 Rust tests pass (9 new Lambert)
- 58 Python tests pass (6 new Lambert)

## 0.14.0 - 2026-03-20

### Breaking: Replace nalgebra with numeris

The `nalgebra` dependency has been replaced with `numeris` 0.5.6 for all linear algebra. The built-in ODE solver module (`src/ode/`) has been removed in favor of the ODE solvers provided by `numeris`. This is a **breaking change** for Rust API consumers; the Python API is unchanged.

### Rust API Changes

- **Math types** (`satkit::mathtypes`): All type aliases now point to `numeris` types instead of `nalgebra`. `Vector<N>`, `Matrix<M,N>`, `Quaternion`, and `DMatrix<T>` remain available with the same names.
- **Vector construction**: `Vector3::new(x, y, z)` is replaced by `numeris::vector![x, y, z]`
- **Matrix construction**: `Matrix3::new(a,b,c,d,e,f,g,h,i)` is replaced by `Matrix3::new([[a,b,c],[d,e,f],[g,h,i]])`
- **Identity matrix**: `Matrix::identity()` is replaced by `Matrix::eye()`
- **Quaternion axis rotations**: `Quaternion::from_axis_angle(&Vector3::z_axis(), θ)` is replaced by `Quaternion::rotz(θ)` (also `rotx`, `roty`)
- **Quaternion vector rotation**: `q.transform_vector(&v)` is replaced by `q * v`
- **Quaternion storage order**: Changed from nalgebra's `[x,y,z,w]` to numeris `[w,x,y,z]` (scalar-first). Component access uses `.w`, `.x`, `.y`, `.z` fields.
- **Block extraction**: `m.fixed_view::<R,C>(i,j)` is replaced by `m.block::<R,C>(i,j)` (returns owned copy)
- **Block insertion**: `m.fixed_view_mut::<R,C>(i,j).copy_from(&src)` is replaced by `m.set_block(i, j, &src)`
- **Matrix inverse**: `m.try_inverse()` (returning `Option`) is replaced by `m.inverse()` (returning `Result`)
- **Cholesky**: `m.cholesky()` now returns `Result<CholeskyDecomposition, LinalgError>` instead of `Option`

### ODE Module Removed

The `satkit::ode` module (7 adaptive solvers, Rosenbrock, ODEState trait, ~2,500 lines) has been removed. Orbit propagation now uses `numeris::ode` solvers directly. The same solver algorithms are available (RKF45, RKTS54, RKV65, RKV87, RKV98, RKV98NoInterp, RODAS4). The `PropSettings` API and `Integrator` enum are unchanged.

### nalgebra Interoperability

If you need nalgebra types for interoperability with other crates, enable the `nalgebra` feature on `numeris`:

```toml
numeris = { version = "0.5.6", features = ["nalgebra"] }
```

This provides zero-cost `From`/`Into` conversions between numeris and nalgebra matrix, vector, and dynamic matrix types. Both libraries use identical column-major storage, so conversions are a `memcpy`.

### Python Bindings

- No breaking changes to the Python API
- All internal conversions updated for numeris types
- Quaternion component order handling updated internally (transparent to Python users)

### Code Simplification

- Use `numeris::vector!` macro throughout instead of `Vector3::from_array([...])`
- Use `Quaternion::rotation_between()` from numeris instead of hand-rolled helper
- Remove `qrot_xcoord`/`qrot_ycoord`/`qrot_zcoord` wrappers; use `Quaternion::rotx`/`roty`/`rotz` directly
- Remove `satkit::filters` module (UKF); use `numeris::estimate::Ukf` instead
- Enable `estimate` feature on numeris

### Dependency Cleanup

- Remove `nalgebra` dependency entirely
- Remove `ndarray` dependency (unused; `numpy` crate re-exports it for Python bindings)
- Remove `cty` dependency; use `std::ffi::{c_double, c_int}` instead
- Remove `once_cell` dependency; use `std::sync::OnceLock` (stable since Rust 1.70)
- Remove redundant reference-based `Add`/`Sub` operator impls from `ITRFCoord` (both types are `Copy`)

### Internal

- Net reduction of ~11,000 lines of code (removed ODE module and filters module)
- 141 Rust tests pass (105 lib + 36 doc)
- 52 Python tests pass


## 0.13.0 - 2026-03-15

### Integrator and Gravity Model Selection

- Add `Integrator` enum to `PropSettings` for selecting ODE solver (RKV98, RKV87, RKV65, RKTS54, RODAS4)
- Add `GravityModel` enum for selecting Earth gravity model (JGM3, JGM2, EGM96, ITU GRACE16)
- Optimize ODE solver hot paths


## 0.12.0 - 2026-03-02

### API Improvements

- **`Instant::utc()` convenience constructor** — shorter alias for `from_datetime()`: `Instant::utc(2024, 1, 1, 12, 0, 0.0)`
- **Explicit time scale method names** — add `as_mjd_utc()`, `as_jd_utc()`, `from_mjd_utc()`, `from_jd_utc()` with explicit scale in the name. Deprecate the old implicit-UTC methods `as_mjd()`, `as_jd()`, `from_mjd()`, `from_jd()` with messages pointing to the new names
- **`Geodetic` named struct** — new `Geodetic { latitude_rad, longitude_rad, height_m }` struct with `latitude_deg()`/`longitude_deg()` helpers and `Display` impl. Add `ITRFCoord::to_geodetic()` returning it. Exported from crate root and prelude
- **`ITRFCoord::distance_to()`** — convenience method returning geodesic distance in meters (wraps `geodesic_distance().0`)
- **`Kepler` convenience constructors** — add `with_true_anomaly()`, `with_mean_anomaly()`, `with_eccentric_anomaly()` to avoid requiring the `Anomaly` enum directly
- **`PropSettings::set_gravity()`** — validated setter for gravity degree/order, returns error if `order > degree`
- **`Duration` now derives `Debug`**
- Remove 10 redundant `&Duration` / `&mut Instant` operator impl variants — both types are `Copy`, so ref-based operators are unnecessary
- **`Display` for `Kepler`** — shows all 6 elements in a readable format

### Breaking Changes

- **`ITRFCoord`: `From<&[f64]>` replaced with `TryFrom<&[f64]>`** — the old impl panicked via `assert!` on wrong-length slices; now returns `Result` with a descriptive error message
- **`as_mjd()`, `as_jd()`, `from_mjd()`, `from_jd()`** are deprecated (still functional). Use `as_mjd_utc()`, `as_jd_utc()`, `from_mjd_utc()`, `from_jd_utc()` or the `_with_scale()` variants

### Performance

- **`drag.rs`**: replace separate `itrf.hae()` + `itrf.latitude_rad()` + `itrf.longitude_rad()` calls with single `itrf.to_geodetic_rad()` (eliminates 2 redundant iterative Bowring geodetic conversions per call)
- **`drag.rs`**: cache `vrel.norm()` in `drag_and_partials` (was computed 3 times, now 1)
- **`point_gravity.rs`**: cache `rsnorm2 * rsnorm` as `rsnorm3` (was computed twice per call)
- **`jplephem.rs`**: replace `(m.transpose() * t)[(0,0)]` with `m.column(0).dot(&t)` in Chebyshev evaluation (avoids transposed-matrix allocation for a dot product)

### Documentation

- **Frame transform reference table** — add module-level docs to `frametransform` with accuracy and description table for all available transforms (ITRF, GCRF, TEME, TIRS, CIRS, MOD)
- **`qteme2gcrf` accuracy note** — prominently document ~1 arcsec approximate accuracy in function docs
- Fix ~20 spelling/grammar errors across the codebase: "Runga-Kutta" → "Runge-Kutta", "Dorumund-Prince" → "Dormand-Prince", "coeffeicient" → "coefficient", "velcocity" → "velocity", and others

### Internal

- **`jplephem.rs`**: extract `ChebySetup` struct and `dispatch_ncoeff!` macro, deduplicating ~40 lines across `body_pos_optimized`/`body_state_optimized`/`barycentric_pos`/`barycentric_state`
- **`itrfcoord.rs`**: deduplicate geodetic conversions in `geodesic_distance()` and `move_with_heading()`
- Curate prelude with explicit imports instead of wildcard re-exports
- Add root-level re-exports for `Frame`, `ITRFCoord`, `Kepler`, `Quaternion`, `Vector3`, `propagate`, `PropSettings`, `SatState`, `SolarSystem`, `TLE`
- Fix "attractur" → "attractor" typo in `point_gravity.rs`

### Python Binding Improvements

- **`satkit.geodetic` class** — new Python class wrapping the Rust `Geodetic` struct with named fields `latitude_rad`, `longitude_rad`, `height_m` and computed properties `latitude_deg`, `longitude_deg`
- **`itrfcoord.geodetic` property** — replaces `geodetic_rad` and `geodetic_deg` tuple properties with a single `geodetic` property returning `satkit.geodetic`
- **`propsettings.precompute_terms`**: `step` argument now accepts `satkit.duration`, `float` (seconds), or `datetime.timedelta`; adds `Precomputed::new_with_step` and `PropSettings::precompute_terms_with_step` in Rust core
- **`propresult.interp`**: `time` argument now accepts `satkit.time` or `datetime.datetime`; also accepts a `list` of either type, returning a `list` of interpolated state arrays
- **`instant_from_pyany`**: internal utility for extracting a single `satkit::Instant` from either `satkit.time` or `datetime.datetime`, for use across Python binding functions


## 0.11.0 - 2026-02-27

### Configurable gravity degree/order and third-body toggles

- **Breaking:** Rename `gravity_order` to `gravity_degree` in `PropSettings` (Rust) and `propsettings` (Python)
- Add separate `gravity_order` parameter for spherical harmonic order (defaults to `gravity_degree`; must be ≤ `gravity_degree`)
- Add `use_sun_gravity` and `use_moon_gravity` toggles to enable/disable third-body perturbations (default `true`)
- Update `gravity()` and `gravity_and_partials()` Python functions: rename `order` kwarg to `degree`, add new `order` kwarg (defaults to `degree`)
- Update Rust `earthgravity` API to accept separate `degree` and `order` parameters
- Add 7 new Rust tests and 3 new Python tests for the new functionality
- Update documentation, type stubs, README, and tutorial notebook


## 0.10.4 - 2026-02-26

### Data directory fix
- Fix data directory resolution for `satkit_data` pip package: the `data/` subdirectory was not being found because the path was missing the `/data` suffix and used the wrong parent level relative to the dylib
- Python `__init__.py` now uses Python's import system to locate `satkit_data` package and set the data directory, rather than relying solely on Rust-side path heuristics
- Update documentation to reflect correct data directory search order


## 0.10.3

  - Clean up LICENSE file so GitHub correctly detects MIT license
  - Point homepage and documentation URLs to satkit.dev
  - Add CNAME for GitHub Pages custom domain (satkit.dev)

## 0.10.1

  - Fix PyO3 0.28 deprecation warnings: add `from_py_object` to all `#[pyclass]` types deriving `Clone`
  - Use SPDX `license = "MIT"` in Cargo.toml and pyproject.toml (fixes crates.io license detection)
  - Fix broken release badge in README (wheels.yml was renamed to release.yml)
  - Rewrite README for clarity and conciseness
  - Fix `release.toml` regex so `cargo release` correctly syncs all version files

## 0.10.0

Starting with this release, Rust and Python versions are tracked with identical version numbers.

  - Add 20 unit tests for core physics modules: frame transforms, point gravity, atmospheric drag, ITRFCoord, Kepler elements, and earth gravity
  - Test count increases from 79 to 99 library tests

## Python 0.9.4
  - ensure python packages are built in release mode (they have been, but adding additoinal flags)
  - Restructure into Cargo workspace with separate Python bindings crate
  - Fix and modernize Python `.pyi` stub files: explicit typed signatures for `time`, `duration`, `itrfcoord`, `quaternion`, `kepler`, `propsettings`, and `propagate` (replaces `*args`/`**kwargs`)
  - Migrate documentation from Sphinx/Read the Docs to MkDocs + Material theme + GitHub Pages
  - Replace `pytz` with stdlib `zoneinfo` in sunrise/sunset tutorial
  - Add `cargo-release` config to sync `pyproject.toml` version from `Cargo.toml`
  - Drop Python 3.8 and 3.9 support; minimum is now Python 3.10
  - Add explanatory markdown to all Jupyter notebook tutorials
  - Fix MathJax rendering in notebook tutorials
  - Remove ReadTheDocs workflow; docs now deployed via GitHub Pages
  - Streamline CI: merge `wheels.yml` and `cargo-publish.yml` into single `release.yml` on `v*` tags
  - Add Python bindings test job to `build.yml` (runs on every push)
  - Move cibuildwheel testing out of `pyproject.toml` into dedicated CI job
  - Update PyO3 to 0.28.2 and numpy to 0.28

## Rust 0.9.4
  - Add prelude to expose commonly-used structs and methods
  - OMM no-longer exposed at top crate level (but is expose via prelude)
  - Make `epoch_instant` function public in OMM
  - OMM supports import of xml files with `omm-xml` feature (enabled by default)

## Python 0.9.3
  - bugfix: Fixed transposed state transition matrix in python bindings for high-precision propagator

## Rust 0.9.3, Python 0.9.2
  - bugfix: Fix (and document in python) the `to_enu` and `to_ned` functions in ITRFCoord

## Rust 0.9.2, Python 0.9.1
  - bugfix: handle high-precision propagation without error when duration is zero
  - Fix Rust documentation for SGP4 to accurately represent sgp4 source
  - Add a "zero" static function for duration to represent zero duration

## Rust 0.9.1
  - Functions that accept time as input now accept structs that implement the new `TimeLike` trait.
  - Add a `chrono` feature that enables interoperability with the chrono crate.  implement `TimeLike` for `chrono::DateTime`

## Rust 0.9.0, Python 0.9.0
  - Support Orbital Mean-Element Messages (OMM) in JSON format
  - Add OMM documentation, tests, and python example
  - Structured output of SGP4 propagator in rust
  - add "as_datetime" function in python for satkit.time (for consistent nomenclature)
  - Rename time-interval boundary nomenclature from `start/stop` to `begin/end` across Rust + Python APIs (including propagation functions and related settings/results).

-----------------

## Rust 0.8.4, Python 0.8.5
  - Add "x", "y", "z", "w" property getters to quaternion in python
  - Add pickle serialize/deserialize tests in python testing
  - allow for duration division by float and by other duration in python

## Rust 0.8.3, Python 0.8.4
  - Fix small python typing bugs to allow setting of properties without errors
  - add additional option for RK integrator
  - Add functions for phase of moon & fraction of moon illuminated
  - typo corrections in code

## Python 0.8.3
  - Use pyo3 0.27.1 (transparent to user but annoying API updates)
  - add typing for property setters that were left out of python bindings (e.g., propsettings)
  - Allow user to set values for kepler object following object creation

## Rust 0.8.2
  - Cleanup of referencing of nalgebra types: commonly used aliases are now all referenced from mathtypes.rs
  - Improve documentation

## Rust 0.8.1
  - Remove un-used dependencies

## Python 0.8.2

### Allow TLE setting of parameters
  - Allow setting of TLE parameters in python bindings

### Support for Python 3.14
  - Build wheels for Python 3.14


## Python 0.8.1

### Fix _version.py
  - export "version" and "_version"

## Python 0.8.0.  Rust 0.8.0

### TLE export to lines
  - functions for generating 69-character TLE lines from a TLE object

### Python use of "anyhow::Result"
  - Use "anyhow::Result" where appropriate in python bindings, as it is used in main code branch and compatible with pyo3

### Code Cleanup
  - Remove "clippy" warnings, mainly in comment indentation and a few inefficient code idioms

### Error Checking on from_datetime
  - Bounds checking on month, day, hour, minute, second

### TLE Fitting
  - Add functionality to generate TLE from high-precision state vectors

### Day of Year
  - Add functionality to compute 1-based day of year in "Instant" structure

## Python 0.7.4 Rust 0.7.4

### Separate Rust and python versions
 - Rust and python versions will now evolve independently

### Fix EOP-induced crashes in frame transform
 - Earth orientation parameters query returns None for invalid dates, which caused frame transform functions that rely on it to panic.  Instead, print warning to stderr in query when out of bounds, and for frame transforms set all values to zero when out of bounds.  Also add function to disable the warning from being shown even once

### Add workflow_dispatch trigger to GitHub Actions
 - Suggestion & contribution by "DeflateAwning"

### Docs cleanup and fix path for download of python scripts
 - Contribution by "DeflateAwning"

### No panic if TLE lines are too short
 - Return error rather than panic if not enough characters in TLE
 - Contribution by "DeflateAwning"


## 0.7.3 - 2025-07-30

### Python data file warning to stderr
 - Print warning to stderr in python bindings if importing with missing datafiles

## 0.7.1 - 2025-07-22
## 0.7.2 - 2025-07-22

### Quaternion python bindings
 - Add ability to create quaternion from (w,x,y,z) values in python bindings
 - Did not merge correctly, so upped version twice.

## 0.7.0 - 2025-07-17

### satkit-data package for python bindings
- For python bindings, necessary data files are now included in a separate package, ``satkit-data``, which is a dependency of ``satkit``


## 0.6.2 - 2025-07-14

### Jupyter notebook example fixes
- Fix jupyter notebook examples to work with time and duration casting as properties


## 0.6.1 - 2025-07-12

### Python comparison operators
- Add complete set of comparison operators for python bindings of time and duration

## 0.5.9 - 2025-07-10

### Python typing fixes
- Python typing fixes, too many to enumerate

### Python duration casting
- Python binding duration casting (e.g., seconds, minutes, hours, days) are now properties, not function calls, e.g. d.seconds instead of d.seconds()


## 0.5.8 - 2025-07-07

### Python fixes
- Fix issue with quaternion rotation on multiple vectors failing if memory is non-contiguous
- Fix indexing error with SGP4 when propagating with multiple TLEs

### Clippy Warnings
- minor code changes to remove rust clippy warnings


## 0.5.7 - 2025-04-25

### Linux ARM
- Include Python binaries for Linux 64-bit ARM

## 0.5.6 - 2025-04-6

### Anyhow
- Use "anyhow" crate for error handling, both in core code and in python bindings (it is very nice!)

## 0.5.5 - 2025-01-27

### Low-Precision Ephemeris
 - Coefficients for low-precision planetary ephemerides did not match paper or JPL website referenced in documentation.  Not sure where original nu mbers came from.  Some plantes (e.g., Mercury) matched.  Others (e.g., Mars) did not, although numbers for Mars did match a google search. Very strange ... regardless, update so they match.  Coefficients for years 1800 to 2050 AD are correct and remain unchanged.
 - Add Pluto as a planet for low-precision ephemerides (it is included in reference paper, but not JPL website)

### Two-Line Element Sets
- Fix issue where two-line element sets confuse satellite name for a line number if satellite name starts with a "1" or a "2"