kshana 0.9.2

Open hybrid quantum/classical PNT performance simulator
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
// SPDX-License-Identifier: Apache-2.0
//! RINEX 3 navigation-message (broadcast ephemeris) parser.
//!
//! RINEX (Receiver Independent Exchange Format) is the lingua franca of GNSS
//! data: the format every receiver, agency, and processing tool reads and writes.
//! Being able to ingest it is what lets this engine sit alongside RTKLIB, gLAB,
//! and the IGS archives rather than in a synthetic-only silo.
//!
//! This module parses RINEX 3.x GPS navigation records — the broadcast ephemeris
//! a satellite transmits — into a [`RinexEphemeris`] of Keplerian elements and
//! clock corrections, following the field layout in the RINEX 3.04 specification
//! (IGS/RTCM) and the GPS interface specification IS-GPS-200 for the parameter
//! meanings. The numeric fields use the Fortran `D`-exponent floating format
//! (e.g. `-1.234567890123D-09`), handled by [`parse_d`].
//!
//! From a parsed [`RinexEphemeris`] this evaluates the satellite's ECEF position
//! ([`RinexEphemeris::sv_position_ecef`], IS-GPS-200 §20.3.3.4.3.1) and its clock
//! bias including the relativistic correction
//! ([`RinexEphemeris::sv_clock_bias_s`], §20.3.3.3.3.1).
//!
//! Scope (this stage): the GPS (`G`) LNAV ephemeris block. Galileo F/I-NAV,
//! BeiDou, and GLONASS records, and a [`crate::orbit::Propagator`] source built
//! on the ephemeris are the next steps. Records for other systems are skipped,
//! not rejected, so a mixed-constellation file still yields its GPS ephemerides.

/// A calendar epoch in UTC/GPS time, as carried in a RINEX record (the clock
/// reference time `Toc`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EpochUtc {
    pub year: i32,
    pub month: u32,
    pub day: u32,
    pub hour: u32,
    pub minute: u32,
    pub second: f64,
}

/// A GPS broadcast ephemeris parsed from one RINEX 3 navigation record. Field
/// names follow IS-GPS-200 (angles in radians, distances in metres, times in
/// seconds, `sqrt_a` in √m). The eight source lines map to these as: the SV/epoch
/// line carries the PRN, `Toc`, and the three clock polynomial terms; the seven
/// `BROADCAST ORBIT` lines carry the rest in order.
#[derive(Clone, Copy, Debug)]
pub struct RinexEphemeris {
    /// Satellite system identifier (`'G'` for GPS).
    pub system: char,
    /// PRN number within the system.
    pub prn: u8,
    /// Clock reference epoch `Toc`.
    pub toc: EpochUtc,
    /// SV clock bias `af0` (s).
    pub af0: f64,
    /// SV clock drift `af1` (s/s).
    pub af1: f64,
    /// SV clock drift rate `af2` (s/s²).
    pub af2: f64,

    /// Issue of data, ephemeris.
    pub iode: f64,
    /// Orbit-radius sine-harmonic correction `Crs` (m).
    pub crs: f64,
    /// Mean-motion difference `Δn` (rad/s).
    pub delta_n: f64,
    /// Mean anomaly at reference time `M0` (rad).
    pub m0: f64,

    /// Argument-of-latitude cosine-harmonic correction `Cuc` (rad).
    pub cuc: f64,
    /// Eccentricity `e` (dimensionless).
    pub e: f64,
    /// Argument-of-latitude sine-harmonic correction `Cus` (rad).
    pub cus: f64,
    /// Square root of the semi-major axis `√A` (√m).
    pub sqrt_a: f64,

    /// Ephemeris reference time of week `Toe` (s).
    pub toe: f64,
    /// Inclination cosine-harmonic correction `Cic` (rad).
    pub cic: f64,
    /// Longitude of ascending node at weekly epoch `Ω0` (rad).
    pub omega0: f64,
    /// Inclination sine-harmonic correction `Cis` (rad).
    pub cis: f64,

    /// Inclination at reference time `i0` (rad).
    pub i0: f64,
    /// Orbit-radius cosine-harmonic correction `Crc` (m).
    pub crc: f64,
    /// Argument of perigee `ω` (rad).
    pub omega: f64,
    /// Rate of right ascension `Ω̇` (rad/s).
    pub omega_dot: f64,

    /// Rate of inclination `IDOT` (rad/s).
    pub idot: f64,
    /// GPS week number (continuous, not mod-1024).
    pub gps_week: f64,

    /// SV accuracy (URA, m).
    pub sv_accuracy: f64,
    /// SV health flag (0 = healthy).
    pub sv_health: f64,
    /// Group delay differential `TGD` (s).
    pub tgd: f64,
    /// Issue of data, clock.
    pub iodc: f64,

    /// Message transmission time of week (s).
    pub trans_time: f64,
}

/// Parse a Fortran `D`/`E`-exponent float as written in RINEX (`-1.23D-09`,
/// `4.5678E+04`, or a plain decimal). Blank fields parse to `0.0`.
pub fn parse_d(s: &str) -> Result<f64, String> {
    let t = s.trim();
    if t.is_empty() {
        return Ok(0.0);
    }
    let normalized = t.replace(['D', 'd'], "E");
    normalized
        .parse::<f64>()
        .map_err(|_| format!("not a number: {s:?}"))
}

/// Slice a fixed-width column `[lo, hi)` from `line`, clamped to its length
/// (RINEX lines may be short when trailing fields are blank).
fn col(line: &str, lo: usize, hi: usize) -> &str {
    let n = line.len();
    if lo >= n {
        return "";
    }
    &line[lo..hi.min(n)]
}

/// The four 19-character data fields of a RINEX 3 `BROADCAST ORBIT` line, which
/// start after a 4-space indent.
fn orbit_fields(line: &str) -> Result<[f64; 4], String> {
    Ok([
        parse_d(col(line, 4, 23))?,
        parse_d(col(line, 23, 42))?,
        parse_d(col(line, 42, 61))?,
        parse_d(col(line, 61, 80))?,
    ])
}

/// Parse all GPS ephemerides from a RINEX 3 navigation file. The header (up to
/// and including the `END OF HEADER` line) is skipped; each GPS record is eight
/// lines (one SV/epoch line plus seven orbit lines). Records for other satellite
/// systems are skipped along with their orbit lines.
pub fn parse_nav(text: &str) -> Result<Vec<RinexEphemeris>, String> {
    let lines: Vec<&str> = text.lines().collect();
    // Find the end of the header.
    let mut i = 0;
    while i < lines.len() {
        let done = lines[i].contains("END OF HEADER");
        i += 1;
        if done {
            break;
        }
    }
    let mut out = Vec::new();
    while i < lines.len() {
        let head = lines[i];
        if head.trim().is_empty() {
            i += 1;
            continue;
        }
        let system = head.chars().next().unwrap_or(' ');
        // A record is the epoch line plus seven orbit lines.
        if i + 7 >= lines.len() {
            break;
        }
        if system != 'G' {
            // Skip this record (all eight lines) without parsing.
            i += 8;
            continue;
        }
        let prn: u8 = col(head, 1, 3)
            .trim()
            .parse()
            .map_err(|_| format!("bad PRN in {head:?}"))?;
        let toc = EpochUtc {
            year: col(head, 4, 8).trim().parse().map_err(|_| "bad year")?,
            month: col(head, 9, 11).trim().parse().map_err(|_| "bad month")?,
            day: col(head, 12, 14).trim().parse().map_err(|_| "bad day")?,
            hour: col(head, 15, 17).trim().parse().map_err(|_| "bad hour")?,
            minute: col(head, 18, 20).trim().parse().map_err(|_| "bad minute")?,
            second: parse_d(col(head, 21, 23))?,
        };
        let af0 = parse_d(col(head, 23, 42))?;
        let af1 = parse_d(col(head, 42, 61))?;
        let af2 = parse_d(col(head, 61, 80))?;

        let l1 = orbit_fields(lines[i + 1])?;
        let l2 = orbit_fields(lines[i + 2])?;
        let l3 = orbit_fields(lines[i + 3])?;
        let l4 = orbit_fields(lines[i + 4])?;
        let l5 = orbit_fields(lines[i + 5])?;
        let l6 = orbit_fields(lines[i + 6])?;
        let l7 = orbit_fields(lines[i + 7])?;

        out.push(RinexEphemeris {
            system,
            prn,
            toc,
            af0,
            af1,
            af2,
            iode: l1[0],
            crs: l1[1],
            delta_n: l1[2],
            m0: l1[3],
            cuc: l2[0],
            e: l2[1],
            cus: l2[2],
            sqrt_a: l2[3],
            toe: l3[0],
            cic: l3[1],
            omega0: l3[2],
            cis: l3[3],
            i0: l4[0],
            crc: l4[1],
            omega: l4[2],
            omega_dot: l4[3],
            idot: l5[0],
            gps_week: l5[2],
            sv_accuracy: l6[0],
            sv_health: l6[1],
            tgd: l6[2],
            iodc: l6[3],
            trans_time: l7[0],
        });
        i += 8;
    }
    Ok(out)
}

/// WGS-84 / GPS gravitational constant `μ` (m³/s²), the value mandated by
/// IS-GPS-200 for broadcast-ephemeris evaluation (subtly different from the
/// WGS-84 `GM`).
const MU_GPS: f64 = 3.986_005e14;
/// WGS-84 Earth rotation rate `Ω̇ₑ` (rad/s), per IS-GPS-200.
const OMEGA_E_DOT: f64 = 7.292_115_146_7e-5;
/// Relativistic clock-correction constant `F = −2√μ/c²` (s/√m), IS-GPS-200.
const F_REL: f64 = -4.442_807_633e-10;

/// Julian Day Number (integer, civil Gregorian) for `year-month-day` at noon.
fn julian_day_number(year: i64, month: i64, day: i64) -> i64 {
    let a = (14 - month) / 12;
    let y = year + 4800 - a;
    let m = month + 12 * a - 3;
    day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}

impl EpochUtc {
    /// GPS time-of-week (s) for this epoch: the seconds since the start (Sunday
    /// 00:00) of the GPS week the epoch falls in. The GPS time scale has no leap
    /// seconds and the RINEX navigation calendar is already in GPS time, so this
    /// is plain calendar arithmetic from the GPS epoch (1980-01-06, a Sunday).
    pub fn gps_time_of_week(&self) -> f64 {
        let days = julian_day_number(self.year as i64, self.month as i64, self.day as i64)
            - julian_day_number(1980, 1, 6);
        let day_of_week = days.rem_euclid(7) as f64;
        day_of_week * 86_400.0 + self.hour as f64 * 3600.0 + self.minute as f64 * 60.0 + self.second
    }
}

impl RinexEphemeris {
    /// Semi-major axis `A = (√A)²` (m).
    pub fn semi_major_axis(&self) -> f64 {
        self.sqrt_a * self.sqrt_a
    }

    /// Time from the ephemeris reference epoch `Toe` (s), folded for week
    /// rollover, and the eccentric anomaly `Ek` at GPS time-of-week `t_tow_s` —
    /// the shared core of the position and clock evaluations.
    fn tk_and_eccentric_anomaly(&self, t_tow_s: f64) -> (f64, f64) {
        let a = self.semi_major_axis();
        let n0 = (MU_GPS / (a * a * a)).sqrt();
        let mut tk = t_tow_s - self.toe;
        if tk > 302_400.0 {
            tk -= 604_800.0;
        } else if tk < -302_400.0 {
            tk += 604_800.0;
        }
        let mk = self.m0 + (n0 + self.delta_n) * tk;
        // Kepler's equation M = E − e·sin E, solved by Newton iteration.
        let mut ek = mk;
        for _ in 0..30 {
            let d = (ek - self.e * ek.sin() - mk) / (1.0 - self.e * ek.cos());
            ek -= d;
            if d.abs() < 1e-13 {
                break;
            }
        }
        (tk, ek)
    }

    /// The SV clock bias (s) at GPS time-of-week `t_tow_s`: the broadcast clock
    /// polynomial `af0 + af1·Δt + af2·Δt²` about the clock reference time `Toc`,
    /// plus the relativistic eccentricity correction `F·e·√A·sin Ek` (IS-GPS-200
    /// §20.3.3.3.3.1). The group-delay term `TGD` (a single-frequency L1
    /// correction) is *not* applied here; it is available as [`Self::tgd`].
    pub fn sv_clock_bias_s(&self, t_tow_s: f64) -> f64 {
        let toc_tow = self.toc.gps_time_of_week();
        let mut dt = t_tow_s - toc_tow;
        if dt > 302_400.0 {
            dt -= 604_800.0;
        } else if dt < -302_400.0 {
            dt += 604_800.0;
        }
        let (_, ek) = self.tk_and_eccentric_anomaly(t_tow_s);
        let dtr = F_REL * self.e * self.sqrt_a * ek.sin();
        self.af0 + self.af1 * dt + self.af2 * dt * dt + dtr
    }

    /// Evaluate the satellite's ECEF position (m) at GPS time-of-week `t_tow_s`
    /// from the broadcast ephemeris, following the IS-GPS-200 user algorithm
    /// (§20.3.3.4.3.1): solve Kepler's equation for the eccentric anomaly, apply
    /// the second-harmonic argument-of-latitude / radius / inclination
    /// corrections, then rotate the in-plane position into the Earth-fixed frame
    /// accounting for Earth rotation since the reference time.
    pub fn sv_position_ecef(&self, t_tow_s: f64) -> [f64; 3] {
        let a = self.semi_major_axis();
        let (tk, ek) = self.tk_and_eccentric_anomaly(t_tow_s);
        let nu = ((1.0 - self.e * self.e).sqrt() * ek.sin()).atan2(ek.cos() - self.e);
        let phi = nu + self.omega;
        let (s2, c2) = ((2.0 * phi).sin(), (2.0 * phi).cos());
        let du = self.cus * s2 + self.cuc * c2;
        let dr = self.crs * s2 + self.crc * c2;
        let di = self.cis * s2 + self.cic * c2;
        let u = phi + du;
        let r = a * (1.0 - self.e * ek.cos()) + dr;
        let i = self.i0 + di + self.idot * tk;
        let (xp, yp) = (r * u.cos(), r * u.sin());
        // Corrected longitude of the ascending node (Earth-fixed).
        let omega_k = self.omega0 + (self.omega_dot - OMEGA_E_DOT) * tk - OMEGA_E_DOT * self.toe;
        let (so, co) = (omega_k.sin(), omega_k.cos());
        let ci = i.cos();
        [xp * co - yp * ci * so, xp * so + yp * ci * co, yp * i.sin()]
    }
}

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

    // A minimal RINEX 3 GPS navigation file: a two-line header and one GPS
    // ephemeris record (one SV/epoch line + seven BROADCAST ORBIT lines). The
    // field values are representative of a healthy GPS satellite.
    const SAMPLE: &str = "\
     3.04           N: GNSS NAV DATA    G: GPS              RINEX VERSION / TYPE
                                                            END OF HEADER
G01 2023 01 01 00 00 00 4.567890123456D-04 1.136868377216D-12 0.000000000000D+00
     6.500000000000D+01-1.234375000000D+01 4.567890123456D-09-1.234567890123D+00
    -6.146728992462D-07 1.234567890123D-02 7.430091500282D-06 5.153679868698D+03
     1.728000000000D+05 1.117587089539D-08-1.234567890123D+00 7.450580596924D-09
     9.876543210987D-01 2.612500000000D+02 5.678901234567D-01-8.123456789012D-09
    -2.345678901234D-10 1.000000000000D+00 2.244000000000D+03 0.000000000000D+00
     2.000000000000D+00 0.000000000000D+00-1.117587089539D-08 6.500000000000D+01
     1.674000000000D+05 4.000000000000D+00 0.000000000000D+00 0.000000000000D+00";

    #[test]
    fn parse_d_handles_fortran_exponent() {
        assert!((parse_d("-1.234567890123D-09").unwrap() - -1.234_567_890_123e-9).abs() < 1e-24);
        assert!((parse_d(" 5.153679868698D+03").unwrap() - 5153.679868698).abs() < 1e-9);
        assert!((parse_d("4.5678E+04").unwrap() - 45678.0).abs() < 1e-9);
        assert_eq!(parse_d("   ").unwrap(), 0.0);
        assert!(parse_d("not-a-number").is_err());
    }

    #[test]
    fn parses_a_gps_ephemeris_record() {
        let ephs = parse_nav(SAMPLE).expect("parses");
        assert_eq!(ephs.len(), 1);
        let e = &ephs[0];
        assert_eq!(e.system, 'G');
        assert_eq!(e.prn, 1);
        // Epoch.
        assert_eq!(e.toc.year, 2023);
        assert_eq!(e.toc.month, 1);
        assert_eq!(e.toc.day, 1);
        // Clock polynomial.
        assert!((e.af0 - 4.567_890_123_456e-4).abs() < 1e-16);
        assert!((e.af1 - 1.136_868_377_216e-12).abs() < 1e-24);
        assert_eq!(e.af2, 0.0);
        // Keplerian elements (the load-bearing ones).
        assert!((e.sqrt_a - 5153.679868698).abs() < 1e-9);
        assert!((e.e - 1.234_567_890_123e-2).abs() < 1e-14);
        assert!((e.m0 - -1.234_567_890_123).abs() < 1e-12);
        assert!((e.toe - 172_800.0).abs() < 1e-6);
        assert!((e.delta_n - 4.567_890_123_456e-9).abs() < 1e-21);
        assert!((e.omega_dot - -8.123_456_789_012e-9).abs() < 1e-21);
        assert_eq!(e.gps_week, 2244.0);
        // GPS semi-major axis ≈ 26 560 km.
        let a = e.sqrt_a * e.sqrt_a;
        assert!((a - 26_560_000.0).abs() < 50_000.0, "a = {a} m");
    }

    #[test]
    fn skips_non_gps_systems() {
        // Prefix a Galileo record (which this stage does not decode) before the
        // GPS one; only the GPS ephemeris should come back.
        let mixed = SAMPLE.replace(
            "G01 2023 01 01 00 00 00",
            "E11 2023 01 01 00 00 00 0.0D+00 0.0D+00 0.0D+00\n     \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     0.0D+00 0.0D+00 0.0D+00 0.0D+00\n     \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\nG01 2023 01 01 00 00 00",
        );
        let ephs = parse_nav(&mixed).expect("parses");
        assert_eq!(ephs.len(), 1);
        assert_eq!(ephs[0].system, 'G');
    }

    #[test]
    fn sv_position_is_a_gps_orbit() {
        let eph = &parse_nav(SAMPLE).unwrap()[0];
        let p = eph.sv_position_ecef(eph.toe);
        let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
        let a = eph.semi_major_axis();
        // Geocentric radius stays within the eccentric band a(1±e), plus the
        // bounded harmonic radius correction (|Crc|,|Crs| ≈ 260 m here).
        let band = eph.e * a + 400.0;
        assert!(
            (r - a).abs() < band,
            "radius {r:.0} m outside a±band ({a:.0} ± {band:.0})"
        );
        // A GPS satellite is ~26 560 km from the geocentre.
        assert!((r - 26_560_000.0).abs() < 600_000.0, "r = {r:.0} m");
    }

    #[test]
    fn sv_speed_matches_a_gps_orbit() {
        // Finite-difference the ECEF position; a GPS satellite's Earth-fixed speed
        // is ~3.9 km/s (orbital ~3.87 km/s, lightly modified by Earth rotation).
        let eph = &parse_nav(SAMPLE).unwrap()[0];
        let dt = 1.0;
        let a = eph.sv_position_ecef(eph.toe);
        let b = eph.sv_position_ecef(eph.toe + dt);
        let v =
            (((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt()) / dt;
        assert!((3.0e3..4.5e3).contains(&v), "ECEF speed {v:.1} m/s");
    }

    #[test]
    fn gps_time_of_week_for_known_dates() {
        // 2023-01-01 was a Sunday → start of the GPS week, ToW 0.
        let sunday = EpochUtc {
            year: 2023,
            month: 1,
            day: 1,
            hour: 0,
            minute: 0,
            second: 0.0,
        };
        assert_eq!(sunday.gps_time_of_week(), 0.0);
        // Tuesday 12:00:00 → 2·86400 + 43200.
        let tue = EpochUtc {
            day: 3,
            hour: 12,
            ..sunday
        };
        assert_eq!(tue.gps_time_of_week(), 2.0 * 86_400.0 + 43_200.0);
        // Saturday 23:59:59 → the last second of the week.
        let sat = EpochUtc {
            day: 7,
            hour: 23,
            minute: 59,
            second: 59.0,
            ..sunday
        };
        assert_eq!(sat.gps_time_of_week(), 6.0 * 86_400.0 + 86_399.0);
    }

    #[test]
    fn sv_clock_bias_is_af0_dominated_with_a_relativistic_term() {
        let eph = &parse_nav(SAMPLE).unwrap()[0];
        // At the clock reference epoch (Δt = 0) the bias is af0 plus the small
        // relativistic eccentricity term (|F·e·√A| ≈ 2.8e-8 s here).
        let toc_tow = eph.toc.gps_time_of_week();
        let bias = eph.sv_clock_bias_s(toc_tow);
        assert!(bias.is_finite());
        assert!(
            (bias - eph.af0).abs() < 1e-7,
            "bias {bias} vs af0 {}",
            eph.af0
        );
        // The relativistic correction is present (non-zero) and bounded.
        let dtr = bias - eph.af0;
        assert!(dtr != 0.0 && dtr.abs() < 3e-8, "relativistic term {dtr}");
    }

    #[test]
    fn week_rollover_correction_is_symmetric() {
        // Evaluating at toe and at toe ± a full week must give the same position
        // (the tk rollover correction folds it back).
        let eph = &parse_nav(SAMPLE).unwrap()[0];
        let p0 = eph.sv_position_ecef(eph.toe);
        let pw = eph.sv_position_ecef(eph.toe + 604_800.0);
        for k in 0..3 {
            assert!(
                (p0[k] - pw[k]).abs() < 1e-3,
                "axis {k}: {} vs {}",
                p0[k],
                pw[k]
            );
        }
    }

    #[test]
    fn empty_after_header_is_empty() {
        let only_header = "\
     3.04           N: GNSS NAV DATA    G: GPS              RINEX VERSION / TYPE
                                                            END OF HEADER";
        assert!(parse_nav(only_header).unwrap().is_empty());
    }
}