sidereon-core 0.10.1

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
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
//! IONEX vertical-TEC grid parser.
//!
//! Parses an IONEX (IONosphere map EXchange) ASCII product into the vertical-TEC
//! grids and the geometry needed to interpolate them: the latitude and longitude
//! node axes, the per-map TEC and (optionally) RMS grids, and the map epochs as
//! UTC instants. The float math that turns the grid into a slant delay lives in
//! [`super::slant`]; this module is the deterministic byte/record reader.
//!
//! IONEX stores TEC as a scaled integer field: the printed value times
//! `10^EXPONENT`. Latitude bands are written north-to-south (a negative `DLAT`),
//! longitude nodes west-to-east (a positive `DLON`), and the longitude span
//! includes the `+180` wrap-seam column. The node axes are reconstructed as
//! `v1 + i * step` so they match the producer's `arange`-style construction
//! bit-for-bit, and the TEC scaling is `field * 10^EXPONENT` formed as a single
//! multiply.

use super::{ionex_epoch_from_j2000_seconds, j2000_seconds_from_instant};
use crate::astro::constants::time::SECONDS_PER_DAY_I64;
use crate::astro::time::civil::j2000_seconds;
use crate::astro::time::model::Instant;
use crate::error::{Error, Result};
use crate::format::columns::{raw_field, raw_field_from};
use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
use crate::validate;

const IONEX_AXIS_DEG_LIMIT: f64 = 360.0;
const IONEX_AXIS_MAX_NODES: usize = 10_000;
const IONEX_AXIS_MAX_SPAN: f64 = (IONEX_AXIS_MAX_NODES - 1) as f64;

/// A parsed IONEX vertical-TEC product.
///
/// The grids are indexed `[map][i_lat][i_lon]`, with `lat_nodes_deg` descending
/// (north-to-south) and `lon_nodes_deg` ascending (west-to-east). TEC and RMS are
/// in TECU after the `10^EXPONENT` scaling. Map epochs are UTC instants.
#[derive(Debug, Clone, PartialEq)]
pub struct Ionex {
    /// Latitude node values in degrees, descending (north-to-south).
    lat_nodes_deg: Vec<f64>,
    /// Longitude node values in degrees, ascending (west-to-east).
    lon_nodes_deg: Vec<f64>,
    /// Signed latitude step in degrees (negative for the standard ordering).
    dlat_deg: f64,
    /// Signed longitude step in degrees (positive for the standard ordering).
    dlon_deg: f64,
    /// Single-layer shell height in kilometers.
    shell_height_km: f64,
    /// Mean earth radius used by the geometry, in kilometers.
    base_radius_km: f64,
    /// The integer `EXPONENT` header field (TEC scale is `10^EXPONENT`).
    exponent: i32,
    /// Map epochs as UTC instants, ascending.
    map_epochs: Vec<Instant>,
    /// Per-map vertical-TEC grids, indexed `[map][i_lat][i_lon]` (TECU).
    tec_maps: Vec<Vec<Vec<f64>>>,
    /// Per-map RMS grids, indexed `[map][i_lat][i_lon]` (TECU); empty if absent.
    rms_maps: Vec<Vec<Vec<f64>>>,
    /// Count of records skipped during a forgiving parse (e.g. an unsupported
    /// `START OF AUX DATA` block). Lets callers tell a clean product
    /// (`skipped_records == 0`) apart from one carrying records outside this
    /// reader's grid subset, without aborting the whole parse. Mirrors
    /// [`crate::ephemeris::Sp3::skipped_records`].
    skipped_records: usize,
}

impl Ionex {
    /// Latitude node values in degrees (descending, north-to-south).
    pub fn lat_nodes_deg(&self) -> &[f64] {
        &self.lat_nodes_deg
    }

    /// Longitude node values in degrees (ascending, west-to-east).
    pub fn lon_nodes_deg(&self) -> &[f64] {
        &self.lon_nodes_deg
    }

    /// Signed latitude step in degrees (negative for the standard ordering).
    pub fn dlat_deg(&self) -> f64 {
        self.dlat_deg
    }

    /// Signed longitude step in degrees.
    pub fn dlon_deg(&self) -> f64 {
        self.dlon_deg
    }

    /// Single-layer shell height in kilometers.
    pub fn shell_height_km(&self) -> f64 {
        self.shell_height_km
    }

    /// Mean earth radius used by the geometry, in kilometers.
    pub fn base_radius_km(&self) -> f64 {
        self.base_radius_km
    }

    /// The IONEX `EXPONENT` header field; the TEC scale is `10^EXPONENT`.
    pub fn exponent(&self) -> i32 {
        self.exponent
    }

    /// Map epochs as UTC instants (ascending).
    pub fn map_epochs(&self) -> &[Instant] {
        &self.map_epochs
    }

    /// Map epochs projected onto the J2000-second axis (ascending).
    ///
    /// This is a compatibility view for parity tests and callers that need the
    /// integer IONEX epoch axis; the canonical stored representation is
    /// [`Instant`].
    pub fn map_epochs_s(&self) -> Vec<i64> {
        self.map_epochs
            .iter()
            .map(|epoch| {
                j2000_seconds_from_instant(*epoch)
                    .expect("IONEX map epoch is convertible to J2000 seconds")
            })
            .collect()
    }

    /// Per-map vertical-TEC grids, indexed `[map][i_lat][i_lon]` (TECU).
    pub fn tec_maps(&self) -> &[Vec<Vec<f64>>] {
        &self.tec_maps
    }

    /// Per-map RMS grids, indexed `[map][i_lat][i_lon]` (TECU); empty if the
    /// product carries no RMS maps.
    pub fn rms_maps(&self) -> &[Vec<Vec<f64>>] {
        &self.rms_maps
    }

    /// Number of records skipped during a forgiving parse (see the field docs).
    pub fn skipped_records(&self) -> usize {
        self.skipped_records
    }

    /// Return a copy of this product with every map epoch advanced by `days`
    /// whole days (the ionospheric diurnal-persistence shift).
    ///
    /// Only the epoch axis moves; the TEC and RMS grids and all geometry are
    /// copied verbatim. TEC is approximately 24-hour periodic, so re-stamping a
    /// prior day's grids onto a later day reuses the same time-of-day VTEC field
    /// for that later day. The shift is whole days only (`days * 86400 s`); no
    /// value is interpolated across the diurnal cycle. Used by the product
    /// selection layer when the exact day's product is absent.
    pub(crate) fn with_map_epochs_shifted_days(&self, days: i64) -> Result<Self> {
        let shift_s = days.checked_mul(SECONDS_PER_DAY_I64).ok_or_else(|| {
            Error::InvalidInput("IONEX diurnal-shift day count overflows seconds".into())
        })?;
        let mut shifted = self.clone();
        for epoch in &mut shifted.map_epochs {
            let seconds = j2000_seconds_from_instant(*epoch).ok_or_else(|| {
                Error::Parse("IONEX map epoch cannot be projected onto J2000 seconds".into())
            })?;
            let target = seconds.checked_add(shift_s).ok_or_else(|| {
                Error::InvalidInput("IONEX diurnal-shifted map epoch overflows".into())
            })?;
            *epoch = ionex_epoch_from_j2000_seconds(target);
        }
        Ok(shifted)
    }

    /// Parse an IONEX product from its bytes.
    pub fn parse(bytes: &[u8]) -> Result<Self> {
        let text = core::str::from_utf8(bytes)
            .map_err(|_| Error::Parse("IONEX is not valid UTF-8".into()))?;
        Self::parse_str(text)
    }

    /// Parse an IONEX product from its text.
    pub fn parse_str(text: &str) -> Result<Self> {
        let mut lines = text.lines();
        let mut diagnostics = Diagnostics::new();
        let mut line_number = 0usize;

        // ---- Header ----
        let mut lat1 = None;
        let mut lat2 = None;
        let mut dlat = None;
        let mut lon1 = None;
        let mut lon2 = None;
        let mut dlon = None;
        let mut shell_height_km = None;
        let mut base_radius_km = None;
        let mut exponent: i32 = -1; // IONEX default when no EXPONENT record.

        for line in lines.by_ref() {
            line_number += 1;
            let label = label_of(line);
            match label {
                "LAT1 / LAT2 / DLAT" => {
                    let (a, b, c) = three_fields(line, "LAT1 / LAT2 / DLAT")?;
                    lat1 = Some(a);
                    lat2 = Some(b);
                    dlat = Some(c);
                }
                "LON1 / LON2 / DLON" => {
                    let (a, b, c) = three_fields(line, "LON1 / LON2 / DLON")?;
                    lon1 = Some(a);
                    lon2 = Some(b);
                    dlon = Some(c);
                }
                "HGT1 / HGT2 / DHGT" => {
                    let (a, _b, _c) = three_fields(line, "HGT1 / HGT2 / DHGT")?;
                    shell_height_km = Some(a);
                }
                "BASE RADIUS" => {
                    base_radius_km = Some(first_field(line, "BASE RADIUS")?);
                }
                "EXPONENT" => {
                    exponent = first_field_int(line, "EXPONENT")?;
                }
                "END OF HEADER" => break,
                _ => {}
            }
        }

        let lat1 = lat1.ok_or_else(|| Error::Parse("IONEX missing LAT1 / LAT2 / DLAT".into()))?;
        let lat2 = lat2.unwrap();
        let dlat = dlat.unwrap();
        let lon1 = lon1.ok_or_else(|| Error::Parse("IONEX missing LON1 / LON2 / DLON".into()))?;
        let lon2 = lon2.unwrap();
        let dlon = dlon.unwrap();
        let shell_height_km = shell_height_km
            .ok_or_else(|| Error::Parse("IONEX missing HGT1 / HGT2 / DHGT".into()))?;
        let base_radius_km =
            base_radius_km.ok_or_else(|| Error::Parse("IONEX missing BASE RADIUS".into()))?;

        let lat_nodes_deg = node_axis(lat1, lat2, dlat)?;
        let lon_nodes_deg = node_axis(lon1, lon2, dlon)?;
        let nlat = lat_nodes_deg.len();
        let nlon = lon_nodes_deg.len();

        // TEC scale `10^EXPONENT`, formed as a single multiply per field.
        let scale = 10f64.powi(exponent);

        // ---- Body ----
        let mut map_epochs = Vec::new();
        let mut tec_maps: Vec<Vec<Vec<f64>>> = Vec::new();
        let mut rms_maps: Vec<Vec<Vec<f64>>> = Vec::new();

        // The reader is line-driven: a START record opens a map, an EPOCH record
        // sets its time, each LAT/LON1/LON2/DLON/H record opens a latitude band
        // whose TEC values are read from the following continuation lines.
        let mut cur_kind: Option<MapKind> = None;
        let mut cur_grid: Vec<Vec<f64>> = Vec::new();
        let mut cur_epoch: Option<Instant> = None;
        let mut band_vals: Vec<f64> = Vec::new();
        let mut reading_band = false;

        for line in lines {
            line_number += 1;
            let label = label_of(line);
            match label {
                "START OF AUX DATA" => {
                    // Auxiliary data blocks (e.g. satellite/station DCBs) are
                    // outside this reader's vertical-TEC grid subset. Record a
                    // typed skip rather than silently dropping the block; the body
                    // lines that follow carry no recognized grid label and are
                    // tolerated by the catch-all arm below.
                    diagnostics.push_skip(Skip {
                        at: RecordRef::at_line(line_number),
                        reason: SkipReason::UnsupportedRecordType("AUX DATA"),
                    });
                }
                "END OF AUX DATA" => {}
                "START OF TEC MAP" => {
                    cur_kind = Some(MapKind::Tec);
                    cur_grid = Vec::with_capacity(nlat);
                    cur_epoch = None;
                }
                "START OF RMS MAP" => {
                    cur_kind = Some(MapKind::Rms);
                    cur_grid = Vec::with_capacity(nlat);
                    cur_epoch = None;
                }
                "EPOCH OF CURRENT MAP" => {
                    cur_epoch = Some(parse_epoch_instant(line)?);
                }
                "LAT/LON1/LON2/DLON/H" => {
                    if reading_band {
                        finish_band(&mut cur_grid, &mut band_vals, nlon)?;
                    }
                    reading_band = true;
                    band_vals = Vec::with_capacity(nlon);
                }
                "END OF TEC MAP" | "END OF RMS MAP" => {
                    if reading_band {
                        finish_band(&mut cur_grid, &mut band_vals, nlon)?;
                        reading_band = false;
                    }
                    if cur_grid.len() != nlat {
                        return Err(Error::Parse(format!(
                            "IONEX map has {} latitude bands, expected {nlat}",
                            cur_grid.len()
                        )));
                    }
                    match cur_kind {
                        Some(MapKind::Tec) => {
                            let ep = cur_epoch.ok_or_else(|| {
                                Error::Parse("IONEX TEC map missing EPOCH OF CURRENT MAP".into())
                            })?;
                            map_epochs.push(ep);
                            tec_maps.push(core::mem::take(&mut cur_grid));
                        }
                        Some(MapKind::Rms) => {
                            rms_maps.push(core::mem::take(&mut cur_grid));
                        }
                        None => {
                            return Err(Error::Parse("IONEX END OF MAP without START".into()));
                        }
                    }
                    cur_kind = None;
                }
                _ => {
                    // A continuation line holding the TEC/RMS integer fields of
                    // the current latitude band, or an unrecognized header line.
                    if reading_band {
                        for tok in line.split_whitespace() {
                            let v: i64 = tok.parse().map_err(|_| {
                                Error::Parse(format!("IONEX TEC field unparsable: {tok:?}"))
                            })?;
                            band_vals.push(v as f64 * scale);
                        }
                    }
                }
            }
        }

        if reading_band {
            finish_band(&mut cur_grid, &mut band_vals, nlon)?;
        }
        if let Some(kind) = cur_kind {
            return Err(Error::Parse(format!(
                "IONEX {} map truncated before END OF {} MAP",
                kind.label(),
                kind.label()
            )));
        }

        if tec_maps.is_empty() {
            return Err(Error::Parse("IONEX has no TEC maps".into()));
        }
        // Bilinear interpolation brackets a cell with `node[i+1]` / `node[j+1]`,
        // so each axis needs at least two nodes. Reject a degenerate grid here
        // rather than letting evaluation index past the end.
        if lat_nodes_deg.len() < 2 || lon_nodes_deg.len() < 2 {
            return Err(Error::Parse(format!(
                "IONEX grid needs >= 2 nodes per axis (got {} lat, {} lon)",
                lat_nodes_deg.len(),
                lon_nodes_deg.len()
            )));
        }
        if !rms_maps.is_empty() && rms_maps.len() != tec_maps.len() {
            return Err(Error::Parse(
                "IONEX RMS map count does not match TEC map count".into(),
            ));
        }
        validate_map_epochs_strictly_increasing(&map_epochs)?;

        Ok(Self {
            lat_nodes_deg,
            lon_nodes_deg,
            dlat_deg: dlat,
            dlon_deg: dlon,
            shell_height_km,
            base_radius_km,
            exponent,
            map_epochs,
            tec_maps,
            rms_maps,
            skipped_records: diagnostics.skips.len(),
        })
    }
}

fn validate_map_epochs_strictly_increasing(map_epochs: &[Instant]) -> Result<()> {
    let mut previous_s = None;
    for (index, &epoch) in map_epochs.iter().enumerate() {
        let seconds = j2000_seconds_from_instant(epoch).ok_or_else(|| {
            Error::Parse(format!(
                "IONEX map epoch {index} cannot be projected onto J2000 seconds"
            ))
        })?;
        if previous_s.is_some_and(|previous| seconds <= previous) {
            return Err(Error::Parse(
                "IONEX map epochs must be strictly increasing".into(),
            ));
        }
        previous_s = Some(seconds);
    }
    Ok(())
}

/// Whether the current map being read is a TEC or an RMS map.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MapKind {
    Tec,
    Rms,
}

impl MapKind {
    fn label(self) -> &'static str {
        match self {
            Self::Tec => "TEC",
            Self::Rms => "RMS",
        }
    }
}

/// Move an accumulated latitude band into the current grid, validating width.
fn finish_band(grid: &mut Vec<Vec<f64>>, band: &mut Vec<f64>, nlon: usize) -> Result<()> {
    if band.len() != nlon {
        return Err(Error::Parse(format!(
            "IONEX latitude band has {} values, expected {nlon}",
            band.len()
        )));
    }
    grid.push(core::mem::take(band));
    Ok(())
}

/// The 20-character label field of an IONEX record (columns 60..80), trimmed.
///
/// Uses [`crate::format::columns::raw_field_from`] for the column-60 window so a
/// multibyte character before the offset is floored to a char boundary rather than
/// panicking on a non-boundary byte slice; on valid ASCII records the window is
/// unchanged.
fn label_of(line: &str) -> &str {
    if line.len() <= 60 {
        line.trim()
    } else {
        raw_field_from(line, 60).trim()
    }
}

/// Parse the first whitespace-delimited float of the data portion of a record.
fn first_field(line: &str, label: &str) -> Result<f64> {
    let data = data_of(line);
    data.split_whitespace()
        .next()
        .ok_or_else(|| Error::Parse(format!("IONEX {label} record empty")))?
        .parse()
        .map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}

/// Parse the first whitespace-delimited integer of the data portion.
fn first_field_int(line: &str, label: &str) -> Result<i32> {
    let data = data_of(line);
    data.split_whitespace()
        .next()
        .ok_or_else(|| Error::Parse(format!("IONEX {label} record empty")))?
        .parse()
        .map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}

/// Parse the first three whitespace-delimited floats of the data portion.
fn three_fields(line: &str, label: &str) -> Result<(f64, f64, f64)> {
    let data = data_of(line);
    let mut it = data.split_whitespace();
    let a = next_float(&mut it, label)?;
    let b = next_float(&mut it, label)?;
    let c = next_float(&mut it, label)?;
    Ok((a, b, c))
}

fn next_float<'a>(it: &mut impl Iterator<Item = &'a str>, label: &str) -> Result<f64> {
    it.next()
        .ok_or_else(|| Error::Parse(format!("IONEX {label} record short")))?
        .parse()
        .map_err(|_| Error::Parse(format!("IONEX {label} field unparsable")))
}

/// The data portion of a record (columns 0..60), or the whole short line.
///
/// Uses [`crate::format::columns::raw_field`] so the column-60 cut is floored to a
/// char boundary rather than panicking on a multibyte character straddling the
/// offset; a short line returns whole and a valid ASCII line cuts at byte 60 as
/// before.
fn data_of(line: &str) -> &str {
    raw_field(line, 0, 60)
}

/// Build a node axis `v1 + i * step`, with the count taken from the inclusive
/// `[v1, v2]` span (a half-step guard on the end matches the producer's
/// `arange`-style construction).
fn node_axis(v1: f64, v2: f64, step: f64) -> Result<Vec<f64>> {
    let v1 = validate_axis_degree(v1, "IONEX grid axis start")?;
    let v2 = validate_axis_degree(v2, "IONEX grid axis end")?;
    let step = validate_axis_degree(step, "IONEX grid step")?;
    if step == 0.0 {
        return Err(Error::Parse("IONEX grid step is zero".into()));
    }
    let guard = 0.5 * step;
    let span = validate::finite((v2 + guard - v1) / step, "IONEX grid span")
        .map_err(map_axis_field_error)?;
    if span < 0.0 {
        return Err(Error::Parse("IONEX grid span has the wrong sign".into()));
    }
    validate::finite_in_range(span, 0.0, IONEX_AXIS_MAX_SPAN, "IONEX grid span")
        .map_err(map_axis_field_error)?;
    let n = span.floor() as usize + 1;
    Ok((0..n).map(|i| v1 + (i as f64) * step).collect())
}

fn validate_axis_degree(value: f64, field: &'static str) -> Result<f64> {
    validate::finite_in_range(value, -IONEX_AXIS_DEG_LIMIT, IONEX_AXIS_DEG_LIMIT, field)
        .map_err(map_axis_field_error)
}

fn map_axis_field_error(error: validate::FieldError) -> Error {
    Error::Parse(format!("IONEX {error}"))
}

/// Parse an `EPOCH OF CURRENT MAP` record into a UTC instant.
fn parse_epoch_instant(line: &str) -> Result<Instant> {
    let seconds = parse_epoch_j2000_s(line)?;
    Ok(ionex_epoch_from_j2000_seconds(seconds))
}

/// Parse an `EPOCH OF CURRENT MAP` record into J2000 seconds.
///
/// The record carries `year month day hour minute second`; the result is the
/// integer number of seconds from the J2000 epoch (2000-01-01 12:00:00).
fn parse_epoch_j2000_s(line: &str) -> Result<i64> {
    let data = data_of(line);
    let mut it = data.split_whitespace();
    let mut next_int = |what: &'static str| -> Result<i64> {
        let token = it
            .next()
            .ok_or_else(|| Error::Parse(format!("IONEX epoch missing {what}")))?;
        validate::strict_int::<i64>(token, what)
            .map_err(|_| Error::Parse(format!("IONEX epoch {what} unparsable")))
    };
    let year = next_int("year")?;
    let month = next_int("month")?;
    let day = next_int("day")?;
    let hour = next_int("hour")?;
    let minute = next_int("minute")?;
    let second = next_int("second")?;
    let civil = validate::civil_datetime_with_second_policy(
        year,
        month,
        day,
        hour,
        minute,
        second as f64,
        validate::CivilSecondPolicy::Continuous,
    )
    .map_err(|error| Error::Parse(format!("IONEX epoch {error}")))?;

    // Canonical continuous-seconds-since-J2000 conversion. IONEX epochs are
    // whole-second, so the integer-second result is exact and narrows back to
    // i64 bit-identically to the previous open-coded day-count arithmetic.
    Ok(j2000_seconds(
        civil.year as i32,
        civil.month as i32,
        civil.day as i32,
        civil.hour as i32,
        civil.minute as i32,
        civil.second,
    ) as i64)
}

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

    #[test]
    fn parse_epoch_rejects_invalid_civil_datetime() {
        for line in [
            "  2020     2    30     0     0     0                        EPOCH OF CURRENT MAP",
            "  2020     6    25    24     0     0                        EPOCH OF CURRENT MAP",
            "  2020     6    25    23    59    60                        EPOCH OF CURRENT MAP",
        ] {
            assert!(matches!(parse_epoch_instant(line), Err(Error::Parse(_))));
        }
    }

    #[test]
    fn parse_epoch_rejects_years_outside_civil_product_range() {
        for line in [
            "  100000000000000     1     1     0     0     0              EPOCH OF CURRENT MAP",
            " -100000000000000     1     1     0     0     0              EPOCH OF CURRENT MAP",
        ] {
            assert!(matches!(parse_epoch_instant(line), Err(Error::Parse(_))));
        }
    }

    #[test]
    fn parse_epoch_accepts_valid_civil_datetime() {
        assert_eq!(
            j2000_seconds_from_instant(
                parse_epoch_instant(
                    "  2020     6    25     0     0     0                        EPOCH OF CURRENT MAP"
                )
                .expect("valid IONEX epoch")
            )
            .expect("J2000 seconds"),
            646_315_200
        );
    }
}