kshana 0.36.0

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
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
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
// SPDX-License-Identifier: AGPL-3.0-only
//! SP3-c/d precise-ephemeris reader and writer.
//!
//! SP3 (Standard Product 3) is the format the IGS and the analysis centres
//! distribute precise GNSS orbits and clocks in: a tabulated time series of
//! Earth-fixed (ECEF) satellite positions and clock offsets, sampled every
//! 15 minutes (or finer). Where a RINEX navigation file carries the *broadcast*
//! ephemeris a receiver decodes, an SP3 file carries the *post-processed* truth
//! that PPP engines (Ginan, RTKLIB, gLAB) treat as reference. Reading it is what
//! lets this engine consume the IGS archive rather than only its own synthetic
//! orbits.
//!
//! This module parses the SP3-c and SP3-d position records into an [`Sp3File`]:
//! the header (version, epoch grid, satellite list) and, for each epoch, every
//! satellite's ECEF position (converted km → m) and clock offset (µs), plus the
//! velocity record when the file is a `V` (position+velocity) product. It also
//! goes the other way: [`Sp3File::from_propagators`] builds an SP3 from a
//! propagated constellation (TEME → ECEF per epoch) and [`Sp3File::to_sp3_string`]
//! serialises it — so Kshana orbits can be written in the format Ginan/RTKLIB/gLAB
//! ingest, completing the read↔write round trip.
//!
//! A parsed file is also a propagation source: [`Sp3File::interpolator`] builds a
//! per-satellite [`Sp3Interpolator`] that fills the position between epochs with a
//! 10th-order (11-point) polynomial (standard IGS practice). Each tabulated node
//! is first Earth-rotation corrected — rotated about +Z by ω⊕·(t_node − t) so all
//! nodes share the Earth-fixed frame of the query instant — exactly as RTKLIB's
//! `peph2pos` does (`preceph.c`); the corrected ECEF result is then rotated into
//! the shared TEME frame and exposed as a [`crate::orbit::Propagator`].
//!
//! The accuracy codes (header `++` exponents, the `%f` bases and each record's
//! standard-deviation exponents) are read separately by [`parse_sp3_accuracy`] and mapped to a
//! per-observation position sigma by [`Sp3Accuracy::position_sigma_m`].
//!
//! Scope (this stage): the read/write round trip and Lagrange position
//! interpolation. Clock interpolation and a RINEX/SP3 scenario kind are next. The
//! bad-value sentinels (positions of exactly 0, clock 999999.999999) are preserved
//! as parsed, not silently rewritten, so a caller can decide how to treat them.

use crate::portable_math::PortableFloat;
use crate::rinex::EpochUtc;
use serde::Serialize;

/// The SP3 "bad or absent clock" sentinel: a clock value of `999999.999999` µs
/// means the clock is unavailable for that satellite/epoch (SP3 specification).
pub const BAD_CLOCK_US: f64 = 999_999.999_999;

/// One satellite's state at one epoch: ECEF position (m), clock offset (µs), and
/// — for a `V` product — ECEF velocity (m/s). Velocity is `None` for a
/// position-only (`P`) file.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Sp3SatState {
    /// Satellite identifier, e.g. `"G01"` (system letter + two-digit PRN).
    pub sat: String,
    /// ECEF position (m). SP3 stores kilometres; this is converted to metres.
    pub pos_m: [f64; 3],
    /// Satellite clock offset (µs). Equals [`BAD_CLOCK_US`] when unavailable.
    pub clock_us: f64,
    /// ECEF velocity (m/s) when the file carries `V` records; SP3 stores dm/s,
    /// converted here to m/s.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vel_m_s: Option<[f64; 3]>,
}

impl Sp3SatState {
    /// True when the clock field is the SP3 "unavailable" sentinel.
    pub fn clock_is_bad(&self) -> bool {
        (self.clock_us - BAD_CLOCK_US).abs() < 1e-6
    }
}

/// All satellite states at one epoch.
#[derive(Clone, Debug, Serialize)]
pub struct Sp3Epoch {
    /// Epoch time (the SP3 calendar time, GPS time scale).
    pub time: EpochUtc,
    /// Per-satellite states recorded at this epoch.
    pub sats: Vec<Sp3SatState>,
}

/// The SP3 file header.
#[derive(Clone, Debug, Serialize)]
pub struct Sp3Header {
    /// Format version letter (`'c'` or `'d'`).
    pub version: char,
    /// `true` for a `V` (position+velocity) product, `false` for `P` (position).
    pub has_velocity: bool,
    /// First epoch (start of the time series).
    pub start: EpochUtc,
    /// Number of epochs declared in the header.
    pub num_epochs: usize,
    /// Satellite identifiers listed in the `+` header records.
    pub sat_ids: Vec<String>,
}

/// A parsed SP3 precise-ephemeris file.
#[derive(Clone, Debug, Serialize)]
pub struct Sp3File {
    pub header: Sp3Header,
    pub epochs: Vec<Sp3Epoch>,
}

impl Sp3File {
    /// The satellite identifiers actually present in the parsed epoch records
    /// (deduplicated, in first-seen order). May differ from the header list if a
    /// file is truncated.
    pub fn observed_satellites(&self) -> Vec<String> {
        let mut seen = Vec::new();
        for epoch in &self.epochs {
            for s in &epoch.sats {
                if !seen.contains(&s.sat) {
                    seen.push(s.sat.clone());
                }
            }
        }
        seen
    }

    /// The ECEF position (m) of satellite `sat` at epoch index `idx`, if present.
    pub fn position_of(&self, sat: &str, idx: usize) -> Option<[f64; 3]> {
        self.epochs
            .get(idx)?
            .sats
            .iter()
            .find(|s| s.sat == sat)
            .map(|s| s.pos_m)
    }

    /// Build an SP3 file from a propagated constellation: each satellite's
    /// inertial (TEME) state is sampled on the time grid and rotated into the
    /// Earth-fixed frame, giving the ECEF position series SP3 records. `start` is
    /// the calendar time of epoch 0 (GPS time scale) and `start_jd_ut1` its UT1
    /// Julian Date — the GMST argument the TEME→ECEF rotation needs; later epochs
    /// advance both by `step_s`. Satellites carry no clock model, so every clock
    /// field is the SP3 "unavailable" sentinel. This is the export half of SP3
    /// interop: Kshana orbits out, in the format Ginan/RTKLIB/gLAB ingest.
    pub fn from_propagators(
        ids: &[String],
        sats: &[crate::orbit::Propagator],
        start: EpochUtc,
        start_jd_ut1: f64,
        step_s: f64,
        num_epochs: usize,
    ) -> Self {
        let start_jd_cal = crate::timescales::julian_date(
            start.year,
            start.month,
            start.day,
            start.hour,
            start.minute,
            start.second,
        );
        let mut epochs = Vec::with_capacity(num_epochs);
        for i in 0..num_epochs {
            let t = i as f64 * step_s;
            let jd_ut1 = start_jd_ut1 + t / 86_400.0;
            let civil = crate::timescales::civil_from_jd(start_jd_cal + t / 86_400.0);
            let mut states = Vec::with_capacity(sats.len());
            for (id, sat) in ids.iter().zip(sats.iter()) {
                let ecef = crate::frames::teme_to_ecef(sat.position_eci(t), jd_ut1);
                states.push(Sp3SatState {
                    sat: id.clone(),
                    pos_m: ecef,
                    clock_us: BAD_CLOCK_US,
                    vel_m_s: None,
                });
            }
            epochs.push(Sp3Epoch {
                time: EpochUtc {
                    year: civil.year,
                    month: civil.month,
                    day: civil.day,
                    hour: civil.hour,
                    minute: civil.minute,
                    second: civil.second,
                },
                sats: states,
            });
        }
        Sp3File {
            header: Sp3Header {
                version: 'c',
                has_velocity: false,
                start,
                num_epochs,
                sat_ids: ids.to_vec(),
            },
            epochs,
        }
    }

    /// Serialise to SP3-c position-record text. The output round-trips through
    /// [`parse_sp3`]: header line, the `+` satellite list, one `*` epoch header
    /// per epoch with its `P` records (ECEF m → km, clock µs), and an `EOF`
    /// trailer. Velocity records are not emitted (positions only).
    pub fn to_sp3_string(&self) -> String {
        let mut out = String::new();
        let s = &self.header.start;
        // Header line 1: version, position mode, start epoch, epoch count.
        out.push_str(&format!(
            "#cP{:4} {:2} {:2} {:2} {:2} {:11.8}  {:6} ORBIT IGS14 HLM KSHANA\n",
            s.year, s.month, s.day, s.hour, s.minute, s.second, self.header.num_epochs,
        ));
        // `+` satellite list: count then three-character ids packed from column 9.
        let mut plus = format!("+   {:2}   ", self.header.sat_ids.len());
        for id in &self.header.sat_ids {
            plus.push_str(id);
        }
        out.push_str(&plus);
        out.push('\n');
        // Epoch blocks.
        for epoch in &self.epochs {
            let t = &epoch.time;
            out.push_str(&format!(
                "*  {:4} {:2} {:2} {:2} {:2} {:11.8}\n",
                t.year, t.month, t.day, t.hour, t.minute, t.second,
            ));
            for st in &epoch.sats {
                out.push_str(&format!(
                    "P{}{:14.6}{:14.6}{:14.6}{:14.6}\n",
                    st.sat,
                    st.pos_m[0] / 1000.0,
                    st.pos_m[1] / 1000.0,
                    st.pos_m[2] / 1000.0,
                    st.clock_us,
                ));
            }
        }
        out.push_str("EOF\n");
        out
    }

    /// Build a position interpolator for one satellite, ready to be used as a
    /// [`crate::orbit::Propagator`]. Returns `None` if the satellite is absent or
    /// has fewer than two epochs. The UT1 reference is taken from the file's start
    /// epoch (the SP3 time scale is GPS time and UT1 ≈ UTC ≈ GPS time at the
    /// GMST-only level used for the Earth-fixed↔inertial rotation).
    pub fn interpolator(&self, sat: &str) -> Option<Sp3Interpolator> {
        let s0 = self.header.start;
        let jd0 = crate::timescales::julian_date(
            s0.year, s0.month, s0.day, s0.hour, s0.minute, s0.second,
        );
        let (mut t_s, mut x, mut y, mut z) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
        for epoch in &self.epochs {
            if let Some(st) = epoch.sats.iter().find(|s| s.sat == sat) {
                let e = epoch.time;
                // Node time relative to the file start, in seconds. Computing this
                // as `(julian_date(e) - julian_date(s0)) * 86400` suffers
                // catastrophic cancellation: both JDs are ~2.45e6, whose f64 ULP
                // is ~5e-10 day ≈ 47 µs, so the difference carries a ±13 µs jitter
                // per node. At GNSS orbital velocity (~3.9 km/s for MEO) that
                // jitter injects ~5 cm of interpolation error — the dominant
                // discrepancy against RTKLIB. Instead difference the integer day
                // number and the seconds-of-day SEPARATELY, both small-magnitude
                // and exactly representable, eliminating the cancellation.
                t_s.push(node_offset_s(s0, e));
                x.push(st.pos_m[0]);
                y.push(st.pos_m[1]);
                z.push(st.pos_m[2]);
            }
        }
        if t_s.len() < 2 {
            return None;
        }
        Some(Sp3Interpolator {
            sat: sat.to_string(),
            t_s,
            x,
            y,
            z,
            start_jd_ut1: jd0,
        })
    }

    /// The valid clock nodes of satellite `sat` as (seconds from the file start, clock offset
    /// in s), in increasing time. Records carrying the [`BAD_CLOCK_US`] sentinel are left out.
    pub fn clock_nodes(&self, sat: &str) -> Vec<(f64, f64)> {
        let s0 = self.header.start;
        let mut out: Vec<(f64, f64)> = self
            .epochs
            .iter()
            .filter_map(|epoch| {
                let st = epoch.sats.iter().find(|s| s.sat == sat)?;
                (!st.clock_is_bad()).then(|| (node_offset_s(s0, epoch.time), st.clock_us * 1e-6))
            })
            .collect();
        out.sort_by(|a, b| a.0.total_cmp(&b.0));
        out
    }

    /// The satellite clock offset (s) of `sat` at `t` seconds from the file start, by linear
    /// interpolation between the two valid clock nodes that bracket `t` (the node value itself
    /// at a node). This is the scheme of RTKLIB's `pephclk` (`preceph.c`) and of the IGS
    /// guidance for tabulated clocks: a satellite clock is a random process, so a high-order
    /// polynomial through it does not help and can hurt. `None` when a bracketing node is
    /// missing or bad, or when either bracketing node lies more than `max_gap_s` from `t`.
    pub fn clock_s(&self, sat: &str, t: f64, max_gap_s: f64) -> Option<f64> {
        interpolate_clock_linear(&self.clock_nodes(sat), t, max_gap_s)
    }
}

/// Linear interpolation of a clock series `(t, offset)` sorted by time, as
/// [`Sp3File::clock_s`] describes. Exact at a node.
pub fn interpolate_clock_linear(nodes: &[(f64, f64)], t: f64, max_gap_s: f64) -> Option<f64> {
    let i = nodes.partition_point(|&(ti, _)| ti < t);
    if i < nodes.len() && nodes[i].0 == t {
        return Some(nodes[i].1);
    }
    if i == 0 || i == nodes.len() {
        return None;
    }
    let (t0, c0) = nodes[i - 1];
    let (t1, c1) = nodes[i];
    if t - t0 > max_gap_s || t1 - t > max_gap_s {
        return None;
    }
    Some(c0 + (c1 - c0) * (t - t0) / (t1 - t0))
}

/// Seconds elapsed from epoch `from` to epoch `to`, computed without the
/// large-magnitude Julian-Date cancellation that injects per-node timing jitter.
///
/// The integer day number (whole-day JD, exactly representable) and the
/// seconds-of-day (0‥86400, exact to full f64 precision for the values SP3
/// carries) are differenced separately, so the result is the exact grid offset
/// rather than a value contaminated by the ~13 µs ULP noise of subtracting two
/// ~2.45e6 Julian Dates. See [`Sp3File::interpolator`] for why this matters at
/// GNSS orbital velocity.
fn node_offset_s(from: EpochUtc, to: EpochUtc) -> f64 {
    // Whole-day JD: julian_date at midnight ends in .5, so + 0.5 is an integer.
    let day_num = |e: &EpochUtc| -> f64 {
        crate::timescales::julian_date(e.year, e.month, e.day, 0, 0, 0.0) + 0.5
    };
    let sod = |e: &EpochUtc| -> f64 { e.hour as f64 * 3600.0 + e.minute as f64 * 60.0 + e.second };
    (day_num(&to) - day_num(&from)) * 86_400.0 + (sod(&to) - sod(&from))
}

/// Earth angular velocity (rad/s), IS-GPS-200 value — matches RTKLIB's `OMGE`
/// (`rtklib.h`: `7.2921151467E-5`). Used for the per-node Earth-rotation
/// correction in SP3 interpolation (see [`Sp3Interpolator::position_ecef`]).
const OMGE: f64 = 7.292_115_146_7e-5;

/// IGS-standard interpolation order for SP3 positions: a 10th-order (11-point)
/// polynomial over the epochs bracketing the query time. This matches RTKLIB's
/// `peph2pos`/`pephpos` (`preceph.c`: `NMAX = 10`, so `NMAX + 1 = 11` nodes).
const SP3_INTERP_ORDER: usize = 10;

/// Lagrange interpolation of the samples `(xs, ys)` evaluated at `x`. The nodes
/// must be distinct. Exact at the nodes and for polynomials up to degree
/// `xs.len() - 1`.
fn lagrange(xs: &[f64], ys: &[f64], x: f64) -> f64 {
    let mut sum = 0.0;
    for j in 0..xs.len() {
        let mut term = ys[j];
        for k in 0..xs.len() {
            if k != j {
                term *= (x - xs[k]) / (xs[j] - xs[k]);
            }
        }
        sum += term;
    }
    sum
}

/// A per-satellite SP3 position interpolator: the Earth-fixed (ECEF) position
/// samples and their times relative to the file start, queried by 10th-order
/// (11-point) polynomial interpolation with the IGS-standard per-node
/// Earth-rotation correction (the `peph2pos` scheme; see
/// [`Sp3Interpolator::position_ecef`]) and rotated into the shared TEME inertial
/// frame. This is what turns a precise-ephemeris file into a
/// [`crate::orbit::Propagator`] source.
#[derive(Clone, Debug)]
pub struct Sp3Interpolator {
    /// Satellite identifier this interpolator was built for.
    pub sat: String,
    t_s: Vec<f64>,
    x: Vec<f64>,
    y: Vec<f64>,
    z: Vec<f64>,
    start_jd_ut1: f64,
}

impl Sp3Interpolator {
    /// The contiguous window of up to `SP3_INTERP_ORDER + 1` sample indices
    /// bracketing `t`, clamped to the available range at the ends. This mirrors
    /// RTKLIB `pephpos` (`preceph.c`): locate `index`, the last node at or before
    /// `t`, then take the window starting at `index - (NMAX+1)/2`, clamped so the
    /// `NMAX + 1` (= 11) nodes stay inside the table.
    fn window(&self, t: f64) -> (usize, usize) {
        let n = self.t_s.len();
        let npts = (SP3_INTERP_ORDER + 1).min(n);
        // First node at or after `t` (RTKLIB's binary-search result `i`).
        let mut i = 0;
        while i < n && self.t_s[i] < t {
            i += 1;
        }
        // `index` = last node strictly before `t` (or 0), as in RTKLIB.
        let index = i.saturating_sub(1);
        // Window start = index - (NMAX+1)/2, clamped to [0, n - npts].
        let start = index.saturating_sub(npts / 2).min(n - npts);
        (start, start + npts)
    }

    /// Interpolated ECEF position (m) at `t` seconds from the file start.
    ///
    /// This applies the IGS-standard Earth-rotation node correction used by
    /// RTKLIB's `peph2pos`/`pephpos` (`preceph.c`, "correction for earh rotation
    /// ver.2.4.0"): before fitting the polynomial, each tabulated node's ECEF
    /// position is rotated about +Z by `OMGE·(t_node − t)` so that every node is
    /// expressed in the Earth-fixed frame at the SAME evaluation instant `t`.
    /// Only then is the 11-point Lagrange polynomial fit and evaluated. Without
    /// this correction a plain Lagrange fit on the raw ECEF samples disagrees
    /// with RTKLIB by several centimetres at GNSS orbital velocity, because the
    /// Earth-fixed frame has rotated by ω⊕·(t_node − t) between each node and the
    /// query instant.
    pub fn position_ecef(&self, t: f64) -> [f64; 3] {
        let (a, b) = self.window(t);
        let xs = &self.t_s[a..b];
        // Earth-rotation correction per node: rotate (x, y) about +Z by
        // OMGE·(t_node − t). R3(θ) with θ = OMGE·(t_node − t):
        //   x' =  cosθ·x − sinθ·y
        //   y' =  sinθ·x + cosθ·y
        // (matches RTKLIB pephpos: sinl/cosl from sin/cos(OMGE·t[j]), where
        //  t[j] = timediff(peph.time, time) = t_node − t.) Z is unaffected.
        let n = xs.len();
        let mut xr = Vec::with_capacity(n);
        let mut yr = Vec::with_capacity(n);
        for (k, &node_t) in xs.iter().enumerate() {
            let theta = OMGE * (node_t - t);
            let (sin_t, cos_t) = theta.psin_cos();
            let x = self.x[a + k];
            let y = self.y[a + k];
            xr.push(cos_t * x - sin_t * y);
            yr.push(sin_t * x + cos_t * y);
        }
        [
            lagrange(xs, &xr, t),
            lagrange(xs, &yr, t),
            lagrange(xs, &self.z[a..b], t),
        ]
    }

    /// The UT1 Julian Date of `t` (s) after the file start: the Earth-rotation date
    /// [`Self::position_teme`] rotates the tabulated Earth-fixed position through.
    pub fn jd_ut1(&self, t: f64) -> f64 {
        self.start_jd_ut1 + t / 86_400.0
    }

    /// Interpolated position (m) in the shared TEME inertial frame at `t`.
    pub fn position_teme(&self, t: f64) -> [f64; 3] {
        crate::frames::ecef_to_teme(self.position_ecef(t), self.start_jd_ut1 + t / 86_400.0)
    }

    /// Approximate orbital period (s) from the radius at the first sample,
    /// `2π·√(r³/μ⊕)`.
    pub fn approx_period_s(&self) -> f64 {
        let r = (self.x[0].ppowi(2) + self.y[0].ppowi(2) + self.z[0].ppowi(2)).sqrt();
        std::f64::consts::TAU * (r * r * r / crate::orbit::MU_EARTH).sqrt()
    }
}

/// Parse a calendar epoch from the six whitespace-separated fields
/// `year month day hour minute second`.
fn parse_epoch(tokens: &[&str]) -> Result<EpochUtc, String> {
    if tokens.len() < 6 {
        return Err(format!("epoch needs 6 time fields, got {}", tokens.len()));
    }
    let p_i = |s: &str, what: &str| s.parse::<i64>().map_err(|_| format!("bad {what}: {s:?}"));
    Ok(EpochUtc {
        year: p_i(tokens[0], "year")? as i32,
        month: p_i(tokens[1], "month")? as u32,
        day: p_i(tokens[2], "day")? as u32,
        hour: p_i(tokens[3], "hour")? as u32,
        minute: p_i(tokens[4], "minute")? as u32,
        second: tokens[5]
            .parse::<f64>()
            .map_err(|_| format!("bad second: {:?}", tokens[5]))?,
    })
}

/// Parse the `sat X Y Z clock` fields of a `P`/`V` record body (everything after
/// the leading `P`/`V` and the three-character satellite id). Returns the three
/// coordinates and the clock value as written (units converted by the caller).
fn parse_record_values(body: &str) -> Result<[f64; 4], String> {
    // SP3 P/V records carry four `%14.6f` columns (X, Y, Z, clock). They are usually
    // whitespace-separated, but the format is fixed-width, so a wide or negative value
    // can fill its 14-char column and abut the next with no separating space — common in
    // the velocity records of reduced-dynamic products (e.g. ESA Swarm). Try a plain
    // whitespace split first; if that does not yield four parseable numbers, fall back to
    // slicing the four fixed-width columns.
    let ws: Vec<&str> = body.split_whitespace().take(4).collect();
    if ws.len() == 4 {
        if let Ok(nums) = ws
            .iter()
            .map(|t| t.parse::<f64>())
            .collect::<Result<Vec<f64>, _>>()
        {
            return Ok([nums[0], nums[1], nums[2], nums[3]]);
        }
    }
    // Fixed-width fallback: four 14-character columns (SP3-c/d are ASCII, so byte == char).
    let mut nums = [0.0f64; 4];
    for (k, slot) in nums.iter_mut().enumerate() {
        let start = k * 14;
        let end = (start + 14).min(body.len());
        let field = body
            .get(start..end)
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .ok_or_else(|| format!("record needs 4 values, missing column {}", k + 1))?;
        *slot = field
            .parse::<f64>()
            .map_err(|_| format!("bad number: {field:?}"))?;
    }
    Ok(nums)
}

/// Parse an SP3-c or SP3-d file into an [`Sp3File`]. The header line, the `+`
/// satellite-list records, the `*` epoch headers, and the `P`/`V` position
/// (and velocity) records are read; other header lines are skipped. Parsing
/// stops at the `EOF` trailer or end of input.
pub fn parse_sp3(text: &str) -> Result<Sp3File, String> {
    let mut lines = text.lines();

    // --- Line 1: version, P/V mode, start epoch, number of epochs. ---
    let first = lines.next().ok_or("empty SP3 input")?;
    if !first.starts_with('#') {
        return Err(format!("first line is not an SP3 header: {first:?}"));
    }
    let version = first
        .chars()
        .nth(1)
        .filter(|c| *c == 'c' || *c == 'd')
        .ok_or_else(|| format!("unsupported SP3 version in {first:?}"))?;
    let has_velocity = first.chars().nth(2) == Some('V');
    // After the `#cP` prefix the rest is whitespace-separated: the 6 epoch
    // fields then the epoch count.
    let rest: Vec<&str> = first.get(3..).unwrap_or("").split_whitespace().collect();
    let start = parse_epoch(&rest)?;
    let num_epochs: usize = rest
        .get(6)
        .ok_or("missing epoch count on header line 1")?
        .parse()
        .map_err(|_| "bad epoch count")?;

    // --- `+` records: satellite identifiers (three characters each from col 9). ---
    let mut sat_ids = Vec::new();
    let mut epochs: Vec<Sp3Epoch> = Vec::new();
    let mut current: Option<Sp3Epoch> = None;

    for line in lines {
        if line.starts_with("++") || line.starts_with("%") || line.starts_with("/*") {
            continue;
        }
        if let Some(rest) = line.strip_prefix('+') {
            // Satellite ids are packed three characters each, starting at column 9
            // of the full line → offset 8 after stripping the leading '+'.
            let ids = rest.get(8..).unwrap_or("");
            let bytes = ids.as_bytes();
            let mut i = 0;
            while i + 3 <= bytes.len() {
                let chunk = &ids[i..i + 3];
                let c0 = chunk.as_bytes()[0];
                // A real id is a system letter followed by two digits; "  0"/"000"
                // padding entries are skipped.
                if c0.is_ascii_uppercase() && chunk[1..].bytes().all(|b| b.is_ascii_digit()) {
                    sat_ids.push(chunk.to_string());
                }
                i += 3;
            }
            continue;
        }
        if line.starts_with("EOF") {
            break;
        }
        if let Some(rest) = line.strip_prefix('*') {
            // New epoch header: flush the one in progress.
            if let Some(e) = current.take() {
                epochs.push(e);
            }
            let tokens: Vec<&str> = rest.split_whitespace().collect();
            current = Some(Sp3Epoch {
                time: parse_epoch(&tokens)?,
                sats: Vec::new(),
            });
            continue;
        }
        let is_pos = line.starts_with('P');
        let is_vel = line.starts_with('V');
        if is_pos || is_vel {
            let sat = line
                .get(1..4)
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .ok_or_else(|| format!("record has no satellite id: {line:?}"))?;
            let vals = parse_record_values(line.get(4..).unwrap_or(""))?;
            let epoch = current
                .as_mut()
                .ok_or("position record before any epoch header")?;
            if is_pos {
                epoch.sats.push(Sp3SatState {
                    sat,
                    pos_m: [vals[0] * 1000.0, vals[1] * 1000.0, vals[2] * 1000.0],
                    clock_us: vals[3],
                    vel_m_s: None,
                });
            } else if let Some(state) = epoch.sats.iter_mut().rev().find(|s| s.sat == sat) {
                // SP3 dm/s → m/s.
                state.vel_m_s = Some([vals[0] * 0.1, vals[1] * 0.1, vals[2] * 0.1]);
            }
        }
    }
    if let Some(e) = current.take() {
        epochs.push(e);
    }

    if epochs.is_empty() {
        return Err("SP3 file contained no epoch records".into());
    }

    Ok(Sp3File {
        header: Sp3Header {
            version,
            has_velocity,
            start,
            num_epochs,
            sat_ids,
        },
        epochs,
    })
}

/// The accuracy codes of one `P` record: the per-axis position standard-deviation exponents
/// (columns 62–63, 65–66, 68–69) and the clock exponent (columns 71–73). A blank field is `None`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Sp3RecordAccuracy {
    /// Satellite identifier.
    pub sat: String,
    /// X, Y, Z position standard-deviation exponents `n` (σ = `pos_vel_base`ⁿ mm).
    pub pos_exponent: [Option<u32>; 3],
    /// Clock standard-deviation exponent `n` (σ = `clock_base`ⁿ ps).
    pub clock_exponent: Option<u32>,
}

/// The accuracy information of an SP3-c/d file, read by [`parse_sp3_accuracy`] separately from
/// [`parse_sp3`] (whose output is unchanged):
///
/// * the header `++` **accuracy exponents**, one per satellite slot of the `+` list
///   (accuracy = 2ⁿ mm; `0` means unknown),
/// * the first `%f` line's **floating-point bases** for position/velocity and clock/clock-rate
///   standard deviations (`0` means the per-record fields are unused),
/// * each `P` record's **standard-deviation exponents** (σ = baseⁿ mm per axis).
///
/// [`Sp3Accuracy::position_sigma_m`] maps these to the one-sigma per-axis position uncertainty an
/// orbit fit weights by (`precise_od::Observation::sigma`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Sp3Accuracy {
    /// `(satellite, exponent)` for every identified satellite slot of the header, in list order.
    pub header_exponent: Vec<(String, u32)>,
    /// Base for the position/velocity standard deviations (first `%f` line, columns 4–13).
    pub pos_vel_base: f64,
    /// Base for the clock/clock-rate standard deviations (first `%f` line, columns 15–26).
    pub clock_base: f64,
    /// Per epoch (same order as [`Sp3File::epochs`]), the accuracy codes of each `P` record.
    pub records: Vec<Vec<Sp3RecordAccuracy>>,
}

impl Sp3Accuracy {
    /// The header accuracy of `sat` in metres, `2ⁿ mm`; `None` when the exponent is 0 (unknown)
    /// or the satellite is not listed.
    pub fn header_sigma_m(&self, sat: &str) -> Option<f64> {
        self.header_exponent
            .iter()
            .find(|(s, _)| s == sat)
            .and_then(|&(_, e)| (e > 0).then(|| 2f64.ppowi(e as i32) * 1e-3))
    }

    /// The per-axis position standard deviations (m) of `sat` at epoch index `epoch`,
    /// `pos_vel_base`ⁿ mm; `None` unless the base is positive and all three exponents are present.
    pub fn record_sigma_m(&self, epoch: usize, sat: &str) -> Option<[f64; 3]> {
        if self.pos_vel_base.is_nan() || self.pos_vel_base <= 0.0 {
            return None;
        }
        let rec = self.records.get(epoch)?.iter().find(|r| r.sat == sat)?;
        let mut out = [0.0; 3];
        for (o, e) in out.iter_mut().zip(rec.pos_exponent) {
            *o = self.pos_vel_base.ppowi(e? as i32) * 1e-3;
        }
        Some(out)
    }

    /// The one-sigma **per-axis** position uncertainty (m) for `sat` at epoch index `epoch`: the
    /// record standard deviations combined as `√((σx² + σy² + σz²)/3)` when present, otherwise the
    /// header accuracy `2ⁿ mm` taken as a per-axis sigma, otherwise `None` (no accuracy
    /// information; the caller keeps its own default).
    pub fn position_sigma_m(&self, epoch: usize, sat: &str) -> Option<f64> {
        if let Some(s) = self.record_sigma_m(epoch, sat) {
            return Some(((s[0] * s[0] + s[1] * s[1] + s[2] * s[2]) / 3.0).sqrt());
        }
        self.header_sigma_m(sat)
    }
}

/// Parse an exponent field: blank → `None`, otherwise an unsigned integer.
fn exp_field(line: &str, range: std::ops::Range<usize>) -> Result<Option<u32>, String> {
    let f = line.get(range).unwrap_or("").trim();
    if f.is_empty() {
        return Ok(None);
    }
    f.parse::<u32>()
        .map(Some)
        .map_err(|_| format!("bad accuracy exponent {f:?} in {line:?}"))
}

/// Read the accuracy codes of an SP3-c/d file (see [`Sp3Accuracy`]). The header `+` and `++`
/// records are read slot by slot (three characters from column 10), the first `%f` line gives the
/// bases, and each `P` record contributes its columns 62–73. Epochs are counted by their `*`
/// headers, so `records[i]` belongs to [`Sp3File::epochs`]`[i]` of [`parse_sp3`] on the same text.
pub fn parse_sp3_accuracy(text: &str) -> Result<Sp3Accuracy, String> {
    let mut slots: Vec<String> = Vec::new();
    let mut exps: Vec<Option<u32>> = Vec::new();
    let mut acc = Sp3Accuracy::default();
    let mut seen_f = false;
    let chunks = |line: &str| -> Vec<String> {
        let body = line.get(9..).unwrap_or("");
        let b = body.as_bytes();
        (0..b.len() / 3)
            .map(|i| String::from_utf8_lossy(&b[3 * i..3 * i + 3]).into_owned())
            .collect()
    };
    for line in text.lines().skip(1) {
        if line.starts_with("EOF") {
            break;
        }
        if line.starts_with("++") {
            for c in chunks(line) {
                let t = c.trim();
                exps.push(if t.is_empty() {
                    None
                } else {
                    Some(
                        t.parse::<u32>()
                            .map_err(|_| format!("bad header accuracy {t:?}"))?,
                    )
                });
            }
        } else if line.starts_with('+') {
            slots.extend(chunks(line));
        } else if line.starts_with("%f") && !seen_f {
            seen_f = true;
            let tok: Vec<&str> = line[2..].split_whitespace().collect();
            let num = |i: usize| -> Result<f64, String> {
                tok.get(i)
                    .ok_or_else(|| format!("short %f line {line:?}"))?
                    .parse::<f64>()
                    .map_err(|_| format!("bad %f base in {line:?}"))
            };
            acc.pos_vel_base = num(0)?;
            acc.clock_base = num(1)?;
        } else if line.starts_with('*') {
            acc.records.push(Vec::new());
        } else if line.starts_with('P') {
            let sat = line.get(1..4).unwrap_or("").trim().to_string();
            let rec = Sp3RecordAccuracy {
                sat,
                pos_exponent: [
                    exp_field(line, 61..63)?,
                    exp_field(line, 64..66)?,
                    exp_field(line, 67..69)?,
                ],
                clock_exponent: exp_field(line, 70..73)?,
            };
            acc.records
                .last_mut()
                .ok_or("position record before any epoch header")?
                .push(rec);
        }
    }
    for (slot, e) in slots.iter().zip(exps) {
        let b = slot.as_bytes();
        if b[0].is_ascii_uppercase() && slot[1..].bytes().all(|c| c.is_ascii_digit()) {
            acc.header_exponent.push((slot.clone(), e.unwrap_or(0)));
        }
    }
    Ok(acc)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn accuracy_codes_map_to_position_sigmas() {
        // SAMPLE with per-record standard deviations on G01's first record (base 1.25 mm):
        // exponents 10, 9, 11 → 1.25^n mm. Header exponents: G01 = 2, G02 = 2.
        let text = SAMPLE.replace(
            "PG01  15000.000000  -5000.000000  20000.000000    123.456789",
            "PG01  15000.000000  -5000.000000  20000.000000    123.456789 10  9 11 105",
        );
        // The position/clock parse is unaffected by the accuracy columns.
        let sats = |t: &str| -> Vec<Vec<Sp3SatState>> {
            parse_sp3(t)
                .unwrap()
                .epochs
                .into_iter()
                .map(|e| e.sats)
                .collect()
        };
        assert_eq!(sats(&text), sats(SAMPLE));
        let acc = parse_sp3_accuracy(&text).expect("parses");
        assert_eq!(acc.pos_vel_base, 1.25);
        assert_eq!(acc.clock_base, 1.025);
        assert_eq!(
            acc.header_exponent,
            vec![("G01".to_string(), 2), ("G02".to_string(), 2)]
        );
        assert_eq!(acc.records.len(), 2);
        assert_eq!(
            acc.records[0][0].pos_exponent,
            [Some(10), Some(9), Some(11)]
        );
        assert_eq!(acc.records[0][0].clock_exponent, Some(105));
        assert_eq!(acc.records[1][0].pos_exponent, [None, None, None]);
        let s = acc.record_sigma_m(0, "G01").unwrap();
        for (got, n) in s.iter().zip([10, 9, 11]) {
            assert!((got - 1.25f64.ppowi(n) * 1e-3).abs() < 1e-15);
        }
        let want = ((s[0] * s[0] + s[1] * s[1] + s[2] * s[2]) / 3.0).sqrt();
        assert_eq!(acc.position_sigma_m(0, "G01"), Some(want));
        // No record codes at epoch 1 → the header 2^2 mm.
        assert_eq!(acc.position_sigma_m(1, "G01"), Some(4e-3));
        assert_eq!(acc.position_sigma_m(1, "G09"), None);
    }

    #[test]
    fn zero_accuracy_codes_mean_unknown() {
        let text = SAMPLE
            .replace("++         2  2", "++         0  3")
            .replace("%f  1.2500000  1.025000000", "%f  0.0000000  0.000000000");
        let acc = parse_sp3_accuracy(&text).expect("parses");
        assert_eq!(acc.position_sigma_m(0, "G01"), None);
        assert_eq!(acc.position_sigma_m(0, "G02"), Some(8e-3));
        assert!(parse_sp3_accuracy(&SAMPLE.replace("++         2  2", "++         x  2")).is_err());
    }

    // A minimal but format-valid SP3-c position file: two epochs, two GPS
    // satellites, 15-minute spacing. Positions are in km, clocks in µs.
    const SAMPLE: &str = "\
#cP2023  1  1  0  0  0.00000000       2 ORBIT IGS14 HLM  IGS
## 2244 172800.00000000   900.00000000 59945 0.0000000000000
+    2   G01G02  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
++         2  2  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
%c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f  1.2500000  1.025000000  0.00000000000  0.000000000000000
%i    0    0    0    0      0      0      0      0         0
/* SYNTHETIC SP3 FIXTURE FOR TESTING
*  2023  1  1  0  0  0.00000000
PG01  15000.000000  -5000.000000  20000.000000    123.456789
PG02 -10000.000000  18000.000000  -8000.000000 999999.999999
*  2023  1  1  0 15  0.00000000
PG01  15100.000000  -5100.000000  20100.000000    124.000000
PG02 -10100.000000  18100.000000  -8100.000000    -46.000000
EOF";

    #[test]
    fn parses_header_fields() {
        let f = parse_sp3(SAMPLE).expect("parses");
        assert_eq!(f.header.version, 'c');
        assert!(!f.header.has_velocity);
        assert_eq!(f.header.num_epochs, 2);
        assert_eq!(f.header.start.year, 2023);
        assert_eq!(f.header.start.month, 1);
        assert_eq!(f.header.start.day, 1);
        assert_eq!(f.header.sat_ids, vec!["G01", "G02"]);
    }

    #[test]
    fn parses_epochs_and_positions_km_to_m() {
        let f = parse_sp3(SAMPLE).expect("parses");
        assert_eq!(f.epochs.len(), 2);
        // First epoch, G01 position: km → m.
        let p = f.position_of("G01", 0).expect("G01 at epoch 0");
        assert_eq!(p, [15_000_000.0, -5_000_000.0, 20_000_000.0]);
        // Clock carried through in µs.
        let g01 = &f.epochs[0].sats[0];
        assert_eq!(g01.sat, "G01");
        assert!((g01.clock_us - 123.456789).abs() < 1e-6);
        // Second epoch time advances 15 minutes.
        assert_eq!(f.epochs[1].time.minute, 15);
        assert_eq!(
            f.position_of("G02", 1).unwrap(),
            [-10_100_000.0, 18_100_000.0, -8_100_000.0]
        );
    }

    #[test]
    fn flags_the_bad_clock_sentinel() {
        let f = parse_sp3(SAMPLE).unwrap();
        // G02 at epoch 0 has the 999999.999999 sentinel.
        let g02 = &f.epochs[0].sats[1];
        assert!(g02.clock_is_bad());
        // G01 has a real clock.
        assert!(!f.epochs[0].sats[0].clock_is_bad());
    }

    #[test]
    fn observed_satellites_lists_each_once() {
        let f = parse_sp3(SAMPLE).unwrap();
        assert_eq!(f.observed_satellites(), vec!["G01", "G02"]);
    }

    #[test]
    fn parses_a_velocity_product() {
        // A `V`-mode file pairs each P record with a V record (dm/s).
        let vfile = "\
#dV2023  1  1  0  0  0.00000000       1 ORBIT IGS20 HLM  IGS
+    1   G01  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
*  2023  1  1  0  0  0.00000000
PG01  15000.000000  -5000.000000  20000.000000    123.456789
VG01  -8000.000000   3000.000000  39000.000000      0.000000
EOF";
        let f = parse_sp3(vfile).expect("parses V file");
        assert_eq!(f.header.version, 'd');
        assert!(f.header.has_velocity);
        let v = f.epochs[0].sats[0].vel_m_s.expect("velocity present");
        // dm/s → m/s: -8000 dm/s = -800 m/s.
        assert_eq!(v, [-800.0, 300.0, 3900.0]);
    }

    #[test]
    fn parses_velocity_records_with_abutting_fixed_width_fields() {
        // Real reduced-dynamic products (e.g. ESA Swarm `SW_OPER_SP3ACOM_2_`) write the
        // four `%14.6f` columns flush, so a wide or negative value abuts its neighbour
        // with no separating space: here the 2nd and 3rd velocity columns touch
        // (`…-75385.7480502`). A naïve whitespace split mis-tokenises this; the parser
        // must fall back to fixed-width columns. Two 14-char columns glued below:
        //   col1=` -1234.5678901` col2=`-75385.7480502` col3=`   3000.000000` col4=`      0.000000`
        let vfile = "\
#dV2023  1  1  0  0  0.00000000       1 ORBIT IGS20 HLM  IGS
+    1   G01  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
*  2023  1  1  0  0  0.00000000
PG01  15000.000000  -5000.000000  20000.000000    123.456789
VG01 -1234.5678901-75385.7480502   3000.000000      0.000000
EOF";
        let f = parse_sp3(vfile).expect("parses V file with abutting columns");
        assert_eq!(f.position_of("G01", 0).unwrap(), [15e6, -5e6, 20e6]);
        let v = f.epochs[0].sats[0].vel_m_s.expect("velocity present");
        // dm/s → m/s (× 0.1): -1234.5678901 → -123.4567890, -75385.7480502 → -7538.57480502.
        assert!((v[0] - (-123.45678901)).abs() < 1e-6, "vx {}", v[0]);
        assert!((v[1] - (-7538.57480502)).abs() < 1e-6, "vy {}", v[1]);
        assert!((v[2] - 300.0).abs() < 1e-6, "vz {}", v[2]);
    }

    #[test]
    fn rejects_non_sp3_and_empty_input() {
        assert!(parse_sp3("").is_err());
        assert!(parse_sp3("not an sp3 file").is_err());
        // Header but no epoch records.
        assert!(parse_sp3("#cP2023  1  1  0  0  0.00000000       0 ORBIT").is_err());
    }

    #[test]
    fn write_then_read_round_trips() {
        // Parse the fixture, serialise it back to SP3, and re-parse: the satellite
        // list, positions, and bad-clock sentinel must survive the round trip.
        let a = parse_sp3(SAMPLE).unwrap();
        let text = a.to_sp3_string();
        let b = parse_sp3(&text).expect("written SP3 re-parses");
        assert_eq!(a.header.version, b.header.version);
        assert_eq!(a.header.num_epochs, b.header.num_epochs);
        assert_eq!(a.observed_satellites(), b.observed_satellites());
        assert_eq!(a.position_of("G01", 0), b.position_of("G01", 0));
        assert_eq!(a.position_of("G02", 1), b.position_of("G02", 1));
        assert!(b.epochs[0].sats[1].clock_is_bad()); // G02 epoch 0 sentinel survives
        assert_eq!(b.epochs[1].time.minute, 15);
    }

    #[test]
    fn lagrange_is_exact_for_a_low_degree_polynomial() {
        // f(x) = 2x³ − x + 5 sampled at four nodes; Lagrange of four points
        // recovers the cubic exactly at an interior point.
        let f = |x: f64| 2.0 * x * x * x - x + 5.0;
        let xs = [0.0, 1.0, 2.0, 3.0];
        let ys = xs.map(f);
        assert!((lagrange(&xs, &ys, 1.5) - f(1.5)).abs() < 1e-9);
    }

    #[test]
    fn node_offset_is_exact_on_the_sp3_grid() {
        // The node-time construction must be jitter-free: differencing two
        // ~2.45e6 Julian Dates leaves ~13 µs ULP noise per node (≈5 cm at MEO
        // velocity); node_offset_s differences day-number and seconds-of-day
        // separately and is exact on a regular grid. Walk a full UTC day of
        // 15-minute SP3 epochs (incl. the midnight day rollover) and require the
        // offset to equal the exact grid value to the nanosecond.
        let start = EpochUtc {
            year: 2011,
            month: 4,
            day: 2,
            hour: 0,
            minute: 0,
            second: 0.0,
        };
        for ep in 0..96i64 {
            let tot = ep * 900;
            let e = EpochUtc {
                year: 2011,
                month: 4,
                day: (2 + tot / 86_400) as u32,
                hour: ((tot / 3600) % 24) as u32,
                minute: ((tot / 60) % 60) as u32,
                second: (tot % 60) as f64,
            };
            let got = node_offset_s(start, e);
            let exact = ep as f64 * 900.0;
            assert!(
                (got - exact).abs() < 1e-9,
                "epoch {ep}: node_offset_s = {got} s vs exact {exact} s (Δ={:.3e} s)",
                got - exact
            );
        }
    }

    #[test]
    fn earth_rotation_correction_is_exact_at_a_node_and_changes_the_midpoint() {
        // Build an SP3 from a Kepler orbit (enough epochs for the 11-point
        // window) and check the two defining properties of the peph2pos scheme:
        //  (1) at a tabulated node the interpolated ECEF equals the stored node
        //      exactly (the per-node rotation argument OMGE·(t_node − t) is zero
        //      at t = t_node, so the polynomial passes through the raw sample);
        //  (2) off a node the Earth-rotation correction actually changes the
        //      result versus a naïve raw-sample Lagrange fit — by a magnitude
        //      consistent with ω⊕·Δt·r over the window half-width.
        use crate::orbit::{Orbit, Propagator};
        let a = 26_560_000.0;
        let orbit = Orbit::keplerian(a, 0.01, 0.9, 0.3, 0.2, 0.4);
        let sats = vec![Propagator::Kepler(orbit)];
        let ids = vec!["G01".to_string()];
        let start = EpochUtc {
            year: 2023,
            month: 1,
            day: 1,
            hour: 0,
            minute: 0,
            second: 0.0,
        };
        let jd = crate::timescales::julian_date(2023, 1, 1, 0, 0, 0.0);
        let f = Sp3File::from_propagators(&ids, &sats, start, jd, 900.0, 20);
        let interp = f.interpolator("G01").expect("interpolator builds");

        // (1) Exact at an interior node: rotation is identity there (the
        // argument OMGE·(t_node − t) is zero), so the corrected polynomial passes
        // through the raw stored sample. Query at the node's OWN tabulated time
        // (interp.t_s[10]); the nominal 10×900 s differs from it by the sub-µs
        // round-trip noise of from_propagators' civil_from_jd, which at MEO
        // velocity would otherwise show as ~cm — exactly the precision sensitivity
        // this whole change is about.
        let t_node = interp.t_s[10];
        let stored = f.position_of("G01", 10).expect("stored node");
        let at_node = interp.position_ecef(t_node);
        for k in 0..3 {
            assert!(
                (at_node[k] - stored[k]).abs() < 1e-6,
                "node axis {k}: corrected {} vs stored {}",
                at_node[k],
                stored[k]
            );
        }

        // (2) Off a node, the correction shifts X/Y relative to a raw Lagrange
        // fit (Z is untouched by a +Z rotation). The shift is sub-metre over a
        // 900 s half-step at this radius but must be clearly non-zero.
        let t_mid = 10.5 * 900.0;
        let corrected = interp.position_ecef(t_mid);
        // Reproduce the raw (uncorrected) Lagrange fit over the same window.
        let (lo, hi) = interp.window(t_mid);
        let xs = &interp.t_s[lo..hi];
        let raw = [
            lagrange(xs, &interp.x[lo..hi], t_mid),
            lagrange(xs, &interp.y[lo..hi], t_mid),
            lagrange(xs, &interp.z[lo..hi], t_mid),
        ];
        let dxy = ((corrected[0] - raw[0]).ppowi(2) + (corrected[1] - raw[1]).ppowi(2)).sqrt();
        assert!(
            dxy > 1e-3,
            "Earth-rotation correction should move the X/Y fit off the raw fit, Δxy={dxy:.6} m"
        );
        // Z is unaffected by a rotation about +Z.
        assert!(
            (corrected[2] - raw[2]).abs() < 1e-9,
            "Z must be unchanged by the +Z Earth-rotation correction"
        );
    }

    #[test]
    fn interpolator_reproduces_the_nodes_and_a_kepler_orbit() {
        use crate::orbit::{Orbit, Propagator};
        // Build an SP3 from a known Kepler orbit (20 epochs × 900 s), then
        // interpolate. At a node the result is exact; at a midpoint it matches the
        // true Kepler position to well under a metre (10th-order Lagrange at IGS
        // spacing).
        let a = 26_560_000.0;
        let orbit = Orbit::keplerian(a, 0.01, 0.9, 0.3, 0.2, 0.4);
        let sats = vec![Propagator::Kepler(orbit)];
        let ids = vec!["G01".to_string()];
        let start = EpochUtc {
            year: 2023,
            month: 1,
            day: 1,
            hour: 0,
            minute: 0,
            second: 0.0,
        };
        let jd = crate::timescales::julian_date(2023, 1, 1, 0, 0, 0.0);
        let f = Sp3File::from_propagators(&ids, &sats, start, jd, 900.0, 20);
        let interp = f.interpolator("G01").expect("interpolator builds");

        // At a sample node (epoch 9 → t = 8100 s) it matches the Kepler orbit.
        let t_node = 9.0 * 900.0;
        let node = interp.position_teme(t_node);
        let truth_node = orbit.position_eci(t_node);
        for k in 0..3 {
            assert!((node[k] - truth_node[k]).abs() < 1.0, "node axis {k}");
        }
        // At a midpoint (t = 8550 s) the Lagrange interpolation is still accurate.
        let t_mid = 9.5 * 900.0;
        let mid = interp.position_teme(t_mid);
        let truth_mid = orbit.position_eci(t_mid);
        let err = ((mid[0] - truth_mid[0]).ppowi(2)
            + (mid[1] - truth_mid[1]).ppowi(2)
            + (mid[2] - truth_mid[2]).ppowi(2))
        .sqrt();
        assert!(err < 100.0, "midpoint interpolation error {err:.3} m");

        // As a Propagator it is a GPS-radius orbit with a ~12 h period.
        let p: Propagator = interp.into();
        assert!(matches!(p, Propagator::Sp3Precise(_)));
        let r = (p.position_eci(t_mid)[0].ppowi(2)
            + p.position_eci(t_mid)[1].ppowi(2)
            + p.position_eci(t_mid)[2].ppowi(2))
        .sqrt();
        assert!((r - a).abs() < 400_000.0, "radius {r:.0} m");
        assert!(
            (4.2e4..4.4e4).contains(&p.period_s()),
            "period {} s",
            p.period_s()
        );
    }

    #[test]
    fn interpolator_needs_at_least_two_epochs() {
        let f = parse_sp3(SAMPLE).unwrap();
        assert!(f.interpolator("G01").is_some()); // two epochs
        assert!(f.interpolator("G99").is_none()); // absent
    }

    #[test]
    fn builds_an_sp3_from_propagated_orbits() {
        use crate::orbit::{Orbit, Propagator};
        // Two GPS-altitude satellites in different planes.
        let a = 26_560_000.0;
        let sats = vec![
            Propagator::Kepler(Orbit::new(a, 0.96, 0.0, 0.0)),
            Propagator::Kepler(Orbit::new(a, 0.96, std::f64::consts::PI, 1.0)),
        ];
        let ids = vec!["G01".to_string(), "G02".to_string()];
        let start = EpochUtc {
            year: 2023,
            month: 1,
            day: 1,
            hour: 0,
            minute: 0,
            second: 0.0,
        };
        let jd = crate::timescales::julian_date(2023, 1, 1, 0, 0, 0.0);
        let f = Sp3File::from_propagators(&ids, &sats, start, jd, 900.0, 3);
        assert_eq!(f.header.sat_ids, ids);
        assert_eq!(f.epochs.len(), 3);
        // Earth-fixed rotation preserves the geocentric radius (GPS altitude).
        let p = f.position_of("G01", 0).unwrap();
        let r = (p[0].ppowi(2) + p[1].ppowi(2) + p[2].ppowi(2)).sqrt();
        assert!((r - a).abs() < 1.0, "radius {r:.0} m");
        // Clocks are the unavailable sentinel (no clock model).
        assert!(f.epochs[0].sats[0].clock_is_bad());
        // Third epoch is 2 × 900 s = 30 minutes after the start.
        assert_eq!(f.epochs[2].time.minute, 30);
        // And it serialises to something the reader accepts.
        let reparsed = parse_sp3(&f.to_sp3_string()).expect("built SP3 re-parses");
        assert_eq!(reparsed.observed_satellites(), ids);
    }

    #[test]
    fn clock_interpolation_is_linear_between_valid_nodes_and_exact_at_them() {
        let f = parse_sp3(SAMPLE).expect("parses");
        // G01: 123.456789 µs at 0 s, 124.0 µs at 900 s.
        let at = |t| f.clock_s("G01", t, 900.0).expect("bracketed");
        assert_eq!(at(0.0), 123.456789e-6);
        assert_eq!(at(900.0), 124.0e-6);
        let mid = 0.5 * (123.456789e-6 + 124.0e-6);
        assert!((at(450.0) - mid).abs() < 1e-18);
        // Outside the table, and a gap larger than allowed.
        assert!(f.clock_s("G01", -1.0, 900.0).is_none());
        assert!(f.clock_s("G01", 901.0, 900.0).is_none());
        assert!(f.clock_s("G01", 450.0, 300.0).is_none());
        // G02's first clock is the bad sentinel: one valid node only, nothing to interpolate.
        assert_eq!(f.clock_nodes("G02"), vec![(900.0, -46.0e-6)]);
        assert!(f.clock_s("G02", 450.0, 900.0).is_none());
        assert_eq!(f.clock_s("G02", 900.0, 900.0), Some(-46.0e-6));
        assert!(f.clock_s("G03", 0.0, 900.0).is_none());
    }
}