Skip to main content

sidereon_core/
rinex_clock.rs

1//! RINEX clock (`.CLK`) satellite-clock parser and interpolation.
2//!
3//! The parser owns the product grammar for `AS` satellite clock-bias records.
4//! The strict parser reports malformed `AS` rows. Use
5//! [`RinexClock::parse_lossy`] only when best-effort input recovery is intended.
6
7use std::cmp::Ordering;
8use std::collections::BTreeMap;
9use std::fmt::{self, Write as _};
10
11use crate::astro::constants::time::SECONDS_PER_DAY_I64;
12use crate::astro::math::interp::lerp_ratio;
13use crate::astro::time::civil::{
14    civil_from_julian_day_number, j2000_seconds_from_split, seconds_between_splits,
15    J2000_JULIAN_DAY_NUMBER, J2000_NOON_OFFSET_S,
16};
17use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
18use crate::astro::time::scales::julian_day_number;
19use crate::constants::{GPS_EPOCH_TO_J2000_S, J2000_JD, SECONDS_PER_DAY};
20use crate::validate::{self, FieldError};
21
22const INSTANT_SCALE_ORDER_STRIDE_S: f64 = 1.0e15;
23
24/// One satellite clock-bias sample.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ClockPoint {
27    /// Scale-tagged epoch from the RINEX clock file's declared time system.
28    pub epoch: Instant,
29    /// Satellite clock bias in seconds.
30    pub bias_s: f64,
31}
32
33impl ClockPoint {
34    /// This sample's epoch as GPS seconds, when the sample is actually GPST.
35    pub fn gps_seconds(&self) -> Option<f64> {
36        instant_to_gps_seconds(&self.epoch)
37    }
38}
39
40/// Civil epoch tag used by RINEX clock records, interpreted in the file's time scale.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct ClockEpoch {
43    /// Four-digit calendar year.
44    pub year: i32,
45    /// Calendar month, 1..=12.
46    pub month: u8,
47    /// Calendar day of month, 1..=31.
48    pub day: u8,
49    /// Hour of day, 0..=23.
50    pub hour: u8,
51    /// Minute of hour, 0..=59.
52    pub minute: u8,
53    /// Seconds of minute, including fractional seconds.
54    pub second: f64,
55}
56
57/// Parsed RINEX clock product.
58#[derive(Debug, Clone, PartialEq)]
59pub struct RinexClock {
60    /// Time scale declared by the RINEX clock header. Missing headers default to GPST.
61    pub time_scale: TimeScale,
62    /// Per-satellite, strictly time-ordered clock-bias series.
63    pub series: BTreeMap<String, Vec<ClockPoint>>,
64}
65
66/// RINEX clock parse error.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum RinexClockError {
69    /// An `AS` satellite clock row is too short to carry the required bias.
70    MalformedAsRecord {
71        /// One-based input line number.
72        line: usize,
73        /// Human-readable parse failure.
74        reason: &'static str,
75        /// The full record text.
76        record: String,
77    },
78    /// A required `AS` field could not be parsed or was out of range.
79    BadField {
80        /// One-based input line number.
81        line: usize,
82        /// Field name.
83        field: &'static str,
84        /// Source field value.
85        value: String,
86    },
87    /// Public manual input or query parameter was invalid.
88    InvalidInput {
89        /// Field name.
90        field: &'static str,
91        /// Human-readable validation failure.
92        reason: &'static str,
93    },
94}
95
96impl fmt::Display for RinexClockError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            RinexClockError::MalformedAsRecord {
100                line,
101                reason,
102                record,
103            } => write!(
104                f,
105                "malformed RINEX AS clock record at line {line}: {reason}: {record}"
106            ),
107            RinexClockError::BadField { line, field, value } => write!(
108                f,
109                "bad RINEX AS clock field at line {line}: {field}={value}"
110            ),
111            RinexClockError::InvalidInput { field, reason } => {
112                write!(f, "invalid RINEX clock input {field}: {reason}")
113            }
114        }
115    }
116}
117
118impl std::error::Error for RinexClockError {}
119
120impl RinexClock {
121    /// Parse a RINEX clock text into per-satellite `AS` records.
122    pub fn parse(text: &str) -> Result<Self, RinexClockError> {
123        let time_scale = parse_time_scale(text)?;
124        let lines = data_lines(text);
125        let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
126
127        for (line_number, line) in lines {
128            if let Some((sat, point)) = parse_record(line_number, line, time_scale)? {
129                by_sat.entry(sat).or_default().push((point, line_number));
130            }
131        }
132
133        Ok(Self {
134            time_scale,
135            series: build_series(by_sat),
136        })
137    }
138
139    /// Parse a RINEX clock text while skipping malformed and non-`AS` records.
140    pub fn parse_lossy(text: &str) -> Self {
141        let time_scale = parse_time_scale(text).unwrap_or(TimeScale::Gpst);
142        let lines = data_lines(text);
143        let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
144
145        for (line_number, line) in lines {
146            if let Ok(Some((sat, point))) = parse_record(line_number, line, time_scale) {
147                by_sat.entry(sat).or_default().push((point, line_number));
148            }
149        }
150
151        Self {
152            time_scale,
153            series: build_series(by_sat),
154        }
155    }
156
157    /// Rebuild a GPST product from the legacy public GPS-second row shape.
158    pub fn from_series_rows(rows: Vec<(String, Vec<(f64, f64)>)>) -> Result<Self, RinexClockError> {
159        let rows = rows
160            .into_iter()
161            .map(|(sat, points)| {
162                validate::require_strictly_increasing(
163                    points.iter().map(|&(gps_seconds, _)| gps_seconds),
164                    "gps_seconds",
165                )
166                .map_err(map_manual_order_error)?;
167                let points = points
168                    .into_iter()
169                    .map(|(gps_seconds, bias_s)| {
170                        validate_finite(bias_s, "bias_s")?;
171                        Ok((gps_seconds_to_instant(gps_seconds), bias_s))
172                    })
173                    .collect::<Result<Vec<_>, RinexClockError>>()?;
174                Ok((sat, points))
175            })
176            .collect::<Result<Vec<_>, RinexClockError>>()?;
177        Self::from_instant_series_rows(TimeScale::Gpst, rows)
178    }
179
180    /// Rebuild a parsed product from scale-tagged instant rows.
181    pub fn from_instant_series_rows(
182        time_scale: TimeScale,
183        rows: Vec<(String, Vec<(Instant, f64)>)>,
184    ) -> Result<Self, RinexClockError> {
185        let mut series = BTreeMap::new();
186        for (sat, points) in rows {
187            let mut indexed = points
188                .into_iter()
189                .enumerate()
190                .map(|(idx, (epoch, bias_s))| {
191                    let point = ClockPoint { epoch, bias_s };
192                    validate_clock_point(point)?;
193                    Ok((point, idx))
194                })
195                .collect::<Result<Vec<_>, RinexClockError>>()?;
196            validate_instant_series_order(&indexed)?;
197            indexed.sort_by(|(a, ai), (b, bi)| {
198                compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
199            });
200            series.insert(sat, dedup_by_time(indexed));
201        }
202        Ok(Self { time_scale, series })
203    }
204
205    /// Export GPST samples as `[(satellite, [(gps_seconds, bias_s), ...]), ...]`.
206    ///
207    /// Non-GPST samples are not coerced into GPS seconds and are omitted.
208    pub fn series_rows(&self) -> Vec<(String, Vec<(f64, f64)>)> {
209        self.series
210            .iter()
211            .map(|(sat, points)| {
212                (
213                    sat.clone(),
214                    points
215                        .iter()
216                        .filter_map(|point| Some((point.gps_seconds()?, point.bias_s)))
217                        .collect(),
218                )
219            })
220            .collect()
221    }
222
223    /// Export the product as scale-tagged instant rows.
224    pub fn instant_series_rows(&self) -> Vec<(String, Vec<(Instant, f64)>)> {
225        self.series
226            .iter()
227            .map(|(sat, points)| {
228                (
229                    sat.clone(),
230                    points
231                        .iter()
232                        .map(|point| (point.epoch, point.bias_s))
233                        .collect(),
234                )
235            })
236            .collect()
237    }
238
239    /// Interpolate one satellite clock bias at a civil epoch in this file's scale.
240    pub fn clock_s(
241        &self,
242        satellite_id: &str,
243        epoch: ClockEpoch,
244    ) -> Result<Option<f64>, RinexClockError> {
245        let epoch = civil_to_clock_instant(
246            self.time_scale,
247            epoch.year,
248            epoch.month,
249            epoch.day,
250            epoch.hour,
251            epoch.minute,
252            epoch.second,
253        )
254        .ok_or_else(|| invalid_input("epoch", "invalid civil clock epoch"))?;
255        self.clock_s_at_instant(satellite_id, epoch)
256    }
257
258    /// Interpolate one satellite clock bias at a scale-tagged instant.
259    pub fn clock_s_at_instant(
260        &self,
261        satellite_id: &str,
262        epoch: Instant,
263    ) -> Result<Option<f64>, RinexClockError> {
264        validate_instant(epoch, "epoch")?;
265        let Some(records) = self.series.get(satellite_id) else {
266            return Ok(None);
267        };
268        Ok(interpolate(records, epoch))
269    }
270
271    /// Interpolate one satellite clock bias at GPS seconds.
272    pub fn clock_s_at_gps_seconds(
273        &self,
274        satellite_id: &str,
275        gps_seconds: f64,
276    ) -> Result<Option<f64>, RinexClockError> {
277        validate_finite(gps_seconds, "gps_seconds")?;
278        self.clock_s_at_instant(satellite_id, gps_seconds_to_instant(gps_seconds))
279    }
280
281    /// Serialize this product to standard RINEX clock text - the inverse of
282    /// [`RinexClock::parse`].
283    ///
284    /// Pure and deterministic: the same product always produces byte-identical
285    /// text and no I/O is performed. The header declares the product time system
286    /// and each sample is written as an `AS` satellite clock-bias record, so
287    /// re-parsing the output reproduces the same time scale and per-satellite
288    /// series. Epoch components are written on the microsecond civil grid the
289    /// parser reads, and bias values use their shortest round-tripping decimal,
290    /// so a parsed product re-encodes to the same `f64`s.
291    pub fn to_rinex_string(&self) -> String {
292        let mut out = String::new();
293        let label = crate::rinex_common::time_scale_rinex_label(self.time_scale);
294        let _ = writeln!(out, "{:<60}RINEX VERSION / TYPE", "     3.00           C");
295        let _ = writeln!(out, "{label:<60}TIME SYSTEM ID");
296        let _ = writeln!(out, "{:<60}END OF HEADER", "");
297        for (satellite, points) in &self.series {
298            for point in points {
299                write_as_record(&mut out, satellite, point);
300            }
301        }
302        out
303    }
304}
305
306/// Append one `AS` satellite clock-bias record for a sample.
307fn write_as_record(out: &mut String, satellite: &str, point: &ClockPoint) {
308    let (year, month, day, hour, minute, second_us) = instant_civil_microsecond(&point.epoch);
309    let second = second_us / 1_000_000;
310    let microsecond = second_us % 1_000_000;
311    // RINEX clock epochs are space-delimited (the parser splits on whitespace),
312    // and one data value (the bias) is written.
313    let _ = writeln!(
314        out,
315        "AS {satellite:<3} {year:04} {month:02} {day:02} {hour:02} {minute:02} {second:2}.{microsecond:06}  1  {bias}",
316        bias = point.bias_s,
317    );
318}
319
320/// Decompose a clock-sample instant into civil `(year, month, day, hour, minute,
321/// total-microseconds-of-minute)` on the microsecond grid the parser reads.
322///
323/// This inverts [`civil_microsecond_to_julian_split`]: the standard epoch grid
324/// from its split Julian date, a UTC `:60` leap-second epoch from its stored
325/// sub-midnight fraction, and a nanosecond-repr instant from its J2000 offset.
326fn instant_civil_microsecond(epoch: &Instant) -> (i64, i64, i64, i64, i64, i64) {
327    let (day_number, total_us) = match epoch.repr {
328        InstantRepr::JulianDate(split) => {
329            // A UTC leap-second epoch is stored by the parser as `remaining_s`
330            // seconds before the next day's midnight (see
331            // civil_microsecond_to_julian_split): a small negative fraction on the
332            // next day's whole JD. Rebuild the `23:59:60.xxxxxx` label on the
333            // previous civil day so it round-trips, rather than emitting a wrong
334            // time from a negative time-of-day.
335            if (-1.0 / SECONDS_PER_DAY..0.0).contains(&split.fraction) {
336                return leap_second_civil(split);
337            }
338            // The parser stores `jd_whole = JDN - 0.5` (civil-day midnight
339            // boundary) and carries the time-of-day as `fraction`. Read the day
340            // number and the time-of-day from each part separately: recombining
341            // into a single JD and subtracting the seven-digit day number would
342            // lose microsecond precision to catastrophic cancellation.
343            let day_number = (split.jd_whole + 0.5).round() as i64;
344            let total_us = (split.fraction * 86_400.0 * 1_000_000.0).round() as i64;
345            (day_number, total_us)
346        }
347        // Nanoseconds count from J2000 (2000-01-01 12:00:00) in the instant's own
348        // scale, matching the IONEX/SP3 convention. Convert the actual epoch
349        // rather than fabricating J2000.
350        InstantRepr::Nanos(nanos) => nanos_civil_day_microsecond(nanos),
351    };
352    let (year, month, day) = civil_from_julian_day_number(day_number);
353    let hour = total_us / 3_600_000_000;
354    let rem = total_us % 3_600_000_000;
355    let minute = rem / 60_000_000;
356    let second_us = rem % 60_000_000;
357    (year, month, day, hour, minute, second_us)
358}
359
360/// Civil decomposition of a UTC leap-second instant whose `fraction` lies in
361/// `[-1/86400, 0)` on the next day's whole JD. The instant sits `remaining_s`
362/// seconds before the next day's midnight - inside the `23:59:60` leap second of
363/// the previous civil day - so rebuild that label on the microsecond grid.
364fn leap_second_civil(split: JulianDateSplit) -> (i64, i64, i64, i64, i64, i64) {
365    let next_day_number = (split.jd_whole + 0.5).round() as i64;
366    let (year, month, day) = civil_from_julian_day_number(next_day_number - 1);
367    let remaining_s = -split.fraction * SECONDS_PER_DAY; // in (0, 1]
368    let microsecond = ((1.0 - remaining_s) * 1_000_000.0).round() as i64;
369    // Encode the `:60` second as total microseconds of minute so the shared
370    // `write_as_record` split (`second_us / 1_000_000`) yields `second == 60`.
371    (year, month, day, 23, 59, 60 * 1_000_000 + microsecond)
372}
373
374/// Decompose a J2000-nanosecond instant into the civil-midnight `(day number,
375/// microseconds of day)` the shared decomposition consumes. Nanoseconds are
376/// rounded to the microsecond grid the RINEX clock epoch field carries.
377fn nanos_civil_day_microsecond(nanos: i128) -> (i64, i64) {
378    const US_PER_DAY: i128 = SECONDS_PER_DAY_I64 as i128 * 1_000_000;
379    // J2000 is noon (12:00:00) of 2000-01-01, whose civil-midnight day number is
380    // JD 2_451_545 (jd_whole 2_451_544.5 + 0.5).
381    const J2000_NOON_US: i128 = J2000_NOON_OFFSET_S as i128 * 1_000_000;
382    const J2000_DAY_NUMBER: i128 = J2000_JULIAN_DAY_NUMBER as i128;
383    let micros = (nanos + nanos.signum() * 500) / 1_000; // round to nearest us
384    let from_midnight = J2000_NOON_US + micros;
385    let day_offset = from_midnight.div_euclid(US_PER_DAY);
386    let us_of_day = from_midnight.rem_euclid(US_PER_DAY);
387    ((J2000_DAY_NUMBER + day_offset) as i64, us_of_day as i64)
388}
389
390/// Convert a civil clock tag in the given scale into a scale-tagged instant.
391pub fn civil_to_clock_instant(
392    scale: TimeScale,
393    year: i32,
394    month: u8,
395    day: u8,
396    hour: u8,
397    minute: u8,
398    second: f64,
399) -> Option<Instant> {
400    let civil = validate::civil_datetime_with_fractional_second_policy(
401        i64::from(year),
402        i64::from(month),
403        i64::from(day),
404        i64::from(hour),
405        i64::from(minute),
406        second,
407        civil_second_policy_for_time_scale(scale),
408    )
409    .ok()?;
410    civil_microsecond_to_instant(scale, civil).ok()
411}
412
413/// Convert a civil GPS-time tag into seconds since 1980-01-06 00:00:00.
414pub fn civil_to_gps_seconds(
415    year: i32,
416    month: u8,
417    day: u8,
418    hour: u8,
419    minute: u8,
420    second: f64,
421) -> Option<f64> {
422    let civil = validate::civil_datetime_with_fractional_second_policy(
423        i64::from(year),
424        i64::from(month),
425        i64::from(day),
426        i64::from(hour),
427        i64::from(minute),
428        second,
429        validate::CivilSecondPolicy::Continuous,
430    )
431    .ok()?;
432    gps_seconds_from_civil(civil)
433}
434
435fn parse_time_scale(text: &str) -> Result<TimeScale, RinexClockError> {
436    let mut time_scale = TimeScale::Gpst;
437    for (idx, line) in text.lines().enumerate() {
438        if line.contains("END OF HEADER") {
439            break;
440        }
441        if line.contains("TIME SYSTEM ID") {
442            let label = line
443                .split("TIME SYSTEM ID")
444                .next()
445                .unwrap_or(line)
446                .split_whitespace()
447                .next()
448                .unwrap_or("");
449            if label.is_empty() {
450                time_scale = TimeScale::Gpst;
451            } else {
452                time_scale = crate::rinex_common::time_scale_label(label).ok_or_else(|| {
453                    RinexClockError::BadField {
454                        line: idx + 1,
455                        field: "time_system",
456                        value: label.to_string(),
457                    }
458                })?;
459            }
460        }
461    }
462    Ok(time_scale)
463}
464
465fn gps_seconds_to_instant(gps_seconds: f64) -> Instant {
466    let gps_epoch_jd = J2000_JD - GPS_EPOCH_TO_J2000_S / SECONDS_PER_DAY;
467    let days = (gps_seconds / SECONDS_PER_DAY).floor();
468    let seconds_of_day = gps_seconds - days * SECONDS_PER_DAY;
469    Instant::from_julian_date(
470        TimeScale::Gpst,
471        JulianDateSplit::new(gps_epoch_jd + days, seconds_of_day / SECONDS_PER_DAY)
472            .expect("valid split Julian date"),
473    )
474}
475
476fn validate_clock_point(point: ClockPoint) -> Result<(), RinexClockError> {
477    validate_instant(point.epoch, "epoch")?;
478    validate_finite(point.bias_s, "bias_s")
479}
480
481fn validate_instant(epoch: Instant, field: &'static str) -> Result<(), RinexClockError> {
482    match epoch.repr {
483        InstantRepr::JulianDate(split) => {
484            validate_finite(split.jd_whole, field)?;
485            validate_finite(split.fraction, field)?;
486            if !(-1.0..=1.0).contains(&split.fraction) {
487                return Err(invalid_input(field, "Julian-date fraction out of range"));
488            }
489            Ok(())
490        }
491        InstantRepr::Nanos(_) => Ok(()),
492    }
493}
494
495fn validate_finite(value: f64, field: &'static str) -> Result<(), RinexClockError> {
496    if value.is_finite() {
497        Ok(())
498    } else {
499        Err(invalid_input(field, "must be finite"))
500    }
501}
502
503fn invalid_input(field: &'static str, reason: &'static str) -> RinexClockError {
504    RinexClockError::InvalidInput { field, reason }
505}
506
507fn map_manual_order_error(error: FieldError) -> RinexClockError {
508    match error {
509        FieldError::NonFinite { field } => invalid_input(field, "must be finite"),
510        FieldError::OutOfRange { field, .. } => invalid_input(field, "must be strictly increasing"),
511        _ => invalid_input(error.field(), error.reason()),
512    }
513}
514
515fn validate_instant_series_order(points: &[(ClockPoint, usize)]) -> Result<(), RinexClockError> {
516    validate::require_strictly_increasing(
517        points
518            .iter()
519            .map(|(point, _)| instant_order_key(&point.epoch)),
520        "epoch",
521    )
522    .map_err(map_manual_order_error)
523}
524
525fn instant_order_key(epoch: &Instant) -> f64 {
526    let offset_s = time_scale_rank(epoch.scale) as f64 * INSTANT_SCALE_ORDER_STRIDE_S;
527    let instant_s = match epoch.repr {
528        InstantRepr::JulianDate(split) => {
529            split.jd_whole * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY
530        }
531        InstantRepr::Nanos(nanos) => nanos as f64 / 1.0e9,
532    };
533    offset_s + instant_s
534}
535
536fn instant_to_gps_seconds(epoch: &Instant) -> Option<f64> {
537    if epoch.scale != TimeScale::Gpst {
538        return None;
539    }
540    instant_to_j2000_seconds(epoch).map(|seconds| seconds + GPS_EPOCH_TO_J2000_S)
541}
542
543fn instant_to_j2000_seconds(epoch: &Instant) -> Option<f64> {
544    match epoch.repr {
545        InstantRepr::JulianDate(split) => {
546            Some(j2000_seconds_from_split(split.jd_whole, split.fraction))
547        }
548        InstantRepr::Nanos(_) => None,
549    }
550}
551
552fn data_lines(text: &str) -> Vec<(usize, &str)> {
553    drop_header(
554        text.lines()
555            .enumerate()
556            .map(|(idx, line)| (idx + 1, line))
557            .collect(),
558    )
559}
560
561fn drop_header(lines: Vec<(usize, &str)>) -> Vec<(usize, &str)> {
562    match lines
563        .iter()
564        .position(|(_, line)| line.contains("END OF HEADER"))
565    {
566        Some(idx) => lines.into_iter().skip(idx + 1).collect(),
567        None => lines,
568    }
569}
570
571#[derive(Debug, Clone, Copy)]
572struct ClockEpochFields<'a> {
573    year: i32,
574    month: u8,
575    day: u8,
576    hour: u8,
577    minute: u8,
578    second: &'a str,
579}
580
581fn parse_record(
582    line_number: usize,
583    line: &str,
584    time_scale: TimeScale,
585) -> Result<Option<(String, ClockPoint)>, RinexClockError> {
586    let mut fields = line.split_whitespace();
587    if fields.next() != Some("AS") {
588        return Ok(None);
589    }
590
591    let sat_field = next_as_field(&mut fields, line_number, line)?;
592    let year_field = next_as_field(&mut fields, line_number, line)?;
593    let month_field = next_as_field(&mut fields, line_number, line)?;
594    let day_field = next_as_field(&mut fields, line_number, line)?;
595    let hour_field = next_as_field(&mut fields, line_number, line)?;
596    let minute_field = next_as_field(&mut fields, line_number, line)?;
597    let second_field = next_as_field(&mut fields, line_number, line)?;
598    let _value_count_field = next_as_field(&mut fields, line_number, line)?;
599    let bias_field = next_as_field(&mut fields, line_number, line)?;
600
601    let sat = validate::strict_gnss_satellite_id(sat_field, "satellite")
602        .map_err(|error| map_field_error(line_number, error, sat_field))?
603        .to_string();
604    let year = parse_int_field::<i32>(line_number, "year", year_field)?;
605    let month = parse_int_field::<u8>(line_number, "month", month_field)?;
606    let day = parse_int_field::<u8>(line_number, "day", day_field)?;
607    let hour = parse_int_field::<u8>(line_number, "hour", hour_field)?;
608    let minute = parse_int_field::<u8>(line_number, "minute", minute_field)?;
609    let epoch = ClockEpochFields {
610        year,
611        month,
612        day,
613        hour,
614        minute,
615        second: second_field,
616    };
617    let bias_s = parse_f64_field(line_number, "bias", bias_field)?;
618    let epoch = civil_decimal_second_to_instant(time_scale, epoch)
619        .map_err(|error| map_epoch_error(line_number, error, epoch))?;
620
621    Ok(Some((sat, ClockPoint { epoch, bias_s })))
622}
623
624fn next_as_field<'a, I>(
625    fields: &mut I,
626    line_number: usize,
627    line: &str,
628) -> Result<&'a str, RinexClockError>
629where
630    I: Iterator<Item = &'a str>,
631{
632    fields
633        .next()
634        .ok_or_else(|| RinexClockError::MalformedAsRecord {
635            line: line_number,
636            reason: "expected at least 10 fields",
637            record: line.trim().to_string(),
638        })
639}
640
641fn parse_int_field<T>(
642    line_number: usize,
643    field: &'static str,
644    value: &str,
645) -> Result<T, RinexClockError>
646where
647    T: std::str::FromStr,
648{
649    validate::strict_int(value, field).map_err(|error| map_field_error(line_number, error, value))
650}
651
652fn parse_f64_field(
653    line_number: usize,
654    field: &'static str,
655    value: &str,
656) -> Result<f64, RinexClockError> {
657    validate::strict_f64(value, field).map_err(|error| map_field_error(line_number, error, value))
658}
659
660fn civil_decimal_second_to_instant(
661    scale: TimeScale,
662    epoch: ClockEpochFields<'_>,
663) -> Result<Instant, FieldError> {
664    let civil = validate::civil_datetime_with_decimal_second_policy(
665        i64::from(epoch.year),
666        i64::from(epoch.month),
667        i64::from(epoch.day),
668        i64::from(epoch.hour),
669        i64::from(epoch.minute),
670        epoch.second,
671        civil_second_policy_for_time_scale(scale),
672    )?;
673    civil_microsecond_to_instant(scale, civil)
674}
675
676fn civil_microsecond_to_instant(
677    scale: TimeScale,
678    civil: validate::ValidCivilMicrosecond,
679) -> Result<Instant, FieldError> {
680    let split = civil_microsecond_to_julian_split(scale, civil)?;
681    Ok(Instant::from_julian_date(scale, split))
682}
683
684fn civil_microsecond_to_julian_split(
685    scale: TimeScale,
686    civil: validate::ValidCivilMicrosecond,
687) -> Result<JulianDateSplit, FieldError> {
688    if civil.year < 1 {
689        return Err(FieldError::InvalidCivilDate {
690            field: "civil datetime",
691            year: civil.year,
692            month: i64::from(civil.month),
693            day: i64::from(civil.day),
694        });
695    }
696
697    let jdn = julian_day_number(civil.year as i32, civil.month as i32, civil.day as i32);
698    let jd_whole = jdn as f64 - 0.5;
699    if scale == TimeScale::Utc && civil.second == 60 {
700        let remaining_s = 1.0 - civil.microsecond as f64 / 1_000_000.0;
701        return Ok(
702            JulianDateSplit::new(jd_whole + 1.0, -remaining_s / SECONDS_PER_DAY)
703                .expect("valid leap-second split Julian date"),
704        );
705    }
706
707    let day_seconds = civil.hour as f64 * 3600.0
708        + civil.minute as f64 * 60.0
709        + civil.second as f64
710        + civil.microsecond as f64 / 1_000_000.0;
711    Ok(
712        JulianDateSplit::new(jd_whole, day_seconds / SECONDS_PER_DAY)
713            .expect("valid split Julian date"),
714    )
715}
716
717fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
718    match scale {
719        TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
720        // GLONASST is UTC(SU)-based, but a civil GLONASST leap-second (:60) label
721        // is not a supported civil input: no time-system label parses to
722        // GLONASST (RINEX/SP3 "GLO" is UTC), and GLONASST is reached numerically
723        // via `timescale_offset_at_s`. Treat it as Continuous so a stray :60
724        // GLONASST label is rejected, not silently rolled into the next minute.
725        TimeScale::Glonasst
726        | TimeScale::Tai
727        | TimeScale::Tt
728        | TimeScale::Tdb
729        | TimeScale::Gpst
730        | TimeScale::Gst
731        | TimeScale::Bdt
732        | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
733    }
734}
735
736fn gps_seconds_from_civil(civil: validate::ValidCivilMicrosecond) -> Option<f64> {
737    if civil.year < 1 {
738        return None;
739    }
740
741    let days = days_since_gps_epoch(civil.year as i32, civil.month as u8, civil.day as u8);
742    let whole = days as f64 * SECONDS_PER_DAY
743        + (i64::from(civil.hour) * 3_600 + i64::from(civil.minute) * 60 + i64::from(civil.second))
744            as f64;
745    Some(whole + f64::from(civil.microsecond) / 1_000_000.0)
746}
747
748fn map_field_error(line_number: usize, error: FieldError, value: &str) -> RinexClockError {
749    RinexClockError::BadField {
750        line: line_number,
751        field: error.field(),
752        value: value.to_string(),
753    }
754}
755
756fn map_epoch_error(
757    line_number: usize,
758    error: FieldError,
759    epoch: ClockEpochFields<'_>,
760) -> RinexClockError {
761    match error {
762        FieldError::FloatParse { .. }
763        | FieldError::Missing { .. }
764        | FieldError::NonFinite { .. } => RinexClockError::BadField {
765            line: line_number,
766            field: "second",
767            value: epoch.second.to_string(),
768        },
769        _ => RinexClockError::BadField {
770            line: line_number,
771            field: "epoch",
772            value: format!(
773                "{} {} {} {} {} {}",
774                epoch.year,
775                epoch.month,
776                epoch.day,
777                epoch.hour,
778                epoch.minute,
779                normalized_second_text(epoch.second)
780            ),
781        },
782    }
783}
784
785fn normalized_second_text(second: &str) -> String {
786    validate::strict_f64(second, "second")
787        .map_or_else(|_| second.to_string(), |value| value.to_string())
788}
789
790fn build_series(
791    by_sat: BTreeMap<String, Vec<(ClockPoint, usize)>>,
792) -> BTreeMap<String, Vec<ClockPoint>> {
793    by_sat
794        .into_iter()
795        .map(|(sat, mut points)| {
796            points.sort_by(|(a, ai), (b, bi)| {
797                compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
798            });
799            (sat, dedup_by_time(points))
800        })
801        .collect()
802}
803
804fn dedup_by_time(points: Vec<(ClockPoint, usize)>) -> Vec<ClockPoint> {
805    let mut deduped = Vec::<ClockPoint>::new();
806    for (point, _) in points {
807        match deduped.last_mut() {
808            Some(prev) if prev.epoch == point.epoch => *prev = point,
809            _ => deduped.push(point),
810        }
811    }
812    deduped
813}
814
815fn interpolate(records: &[ClockPoint], epoch: Instant) -> Option<f64> {
816    let mut prev: Option<ClockPoint> = None;
817    for point in records {
818        match compare_instants_same_scale(&point.epoch, &epoch)? {
819            Ordering::Equal => return Some(point.bias_s),
820            Ordering::Greater => {
821                let p0 = prev?;
822                let p1 = *point;
823                let span_s = seconds_between(&p1.epoch, &p0.epoch)?;
824                if span_s <= 0.0 {
825                    return None;
826                }
827                let query_s = seconds_between(&epoch, &p0.epoch)?;
828                if query_s < 0.0 {
829                    return None;
830                }
831                return Some(lerp_ratio(p0.bias_s, p1.bias_s, query_s, span_s));
832            }
833            Ordering::Less => prev = Some(*point),
834        }
835    }
836    None
837}
838
839fn compare_instants(a: &Instant, b: &Instant) -> Ordering {
840    time_scale_rank(a.scale)
841        .cmp(&time_scale_rank(b.scale))
842        .then_with(|| match (a.julian_date(), b.julian_date()) {
843            (Some(a), Some(b)) => compare_julian_splits(a, b),
844            _ => Ordering::Equal,
845        })
846}
847
848/// Canonical clock timeline for a scale.
849///
850/// QZSST is synchronous with GPST (IS-QZSS-PNT sec. 3.2.2; both read TAI - 19 s),
851/// so a clock file whose header tags it QZSST lives on the GPST timeline. Mapping
852/// QZSST -> GPST here lets a GPST-built query instant (e.g. from
853/// [`RinexClock::clock_s_at_gps_seconds`]) interpolate QZSST rows, which an
854/// exact-scale match would otherwise reject. No other scale is collapsed: GST
855/// carries a broadcast GGTO and the leap-second scales are genuinely distinct.
856fn clock_timeline(scale: TimeScale) -> TimeScale {
857    match scale {
858        TimeScale::Qzsst => TimeScale::Gpst,
859        other => other,
860    }
861}
862
863fn compare_instants_same_scale(a: &Instant, b: &Instant) -> Option<Ordering> {
864    if clock_timeline(a.scale) != clock_timeline(b.scale) {
865        return None;
866    }
867    Some(compare_julian_splits(a.julian_date()?, b.julian_date()?))
868}
869
870fn compare_julian_splits(a: JulianDateSplit, b: JulianDateSplit) -> Ordering {
871    a.jd_whole
872        .partial_cmp(&b.jd_whole)
873        .unwrap_or(Ordering::Equal)
874        .then_with(|| {
875            a.fraction
876                .partial_cmp(&b.fraction)
877                .unwrap_or(Ordering::Equal)
878        })
879}
880
881fn seconds_between(later: &Instant, earlier: &Instant) -> Option<f64> {
882    if clock_timeline(later.scale) != clock_timeline(earlier.scale) {
883        return None;
884    }
885    let later = later.julian_date()?;
886    let earlier = earlier.julian_date()?;
887    let seconds = seconds_between_splits(
888        later.jd_whole,
889        later.fraction,
890        earlier.jd_whole,
891        earlier.fraction,
892    );
893    seconds.is_finite().then_some(seconds)
894}
895
896fn time_scale_rank(scale: TimeScale) -> u8 {
897    match scale {
898        TimeScale::Utc => 0,
899        TimeScale::Tai => 1,
900        TimeScale::Tt => 2,
901        TimeScale::Tdb => 3,
902        TimeScale::Gpst => 4,
903        TimeScale::Gst => 5,
904        TimeScale::Bdt => 6,
905        TimeScale::Glonasst => 7,
906        TimeScale::Qzsst => 8,
907    }
908}
909
910fn days_since_gps_epoch(year: i32, month: u8, day: u8) -> i64 {
911    julian_day_number(year, i32::from(month), i32::from(day)) - julian_day_number(1980, 1, 6)
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917
918    fn as_record(satellite: &str, bias: &str) -> String {
919        format!("AS {satellite} 2020 01 01 00 00 00.000000 1 {bias}")
920    }
921
922    #[test]
923    fn parse_rejects_non_finite_as_bias() {
924        let err = RinexClock::parse(&as_record("G01", "NaN")).unwrap_err();
925        assert_eq!(
926            err,
927            RinexClockError::BadField {
928                line: 1,
929                field: "bias",
930                value: "NaN".to_string(),
931            }
932        );
933    }
934
935    #[test]
936    fn parse_rejects_malformed_as_satellite_token() {
937        let err = RinexClock::parse(&as_record("X01", "1.0e-9")).unwrap_err();
938        assert_eq!(
939            err,
940            RinexClockError::BadField {
941                line: 1,
942                field: "satellite",
943                value: "X01".to_string(),
944            }
945        );
946    }
947
948    #[test]
949    fn explicit_utc_time_system_preserves_clock_epoch_scale() {
950        let text = " 3.00           C                                       RINEX VERSION / TYPE\n\
951                    UTC                                                     TIME SYSTEM ID\n\
952                                                                        END OF HEADER\n\
953                    AS G05  2017 01 01 00 00  0.000000  1   1.0e-04\n\
954                    AS G05  2017 01 01 00 00 30.000000  1   2.0e-04\n";
955        let clock = RinexClock::parse(text).expect("UTC RINEX clock");
956
957        assert_eq!(clock.time_scale, TimeScale::Utc);
958        assert_eq!(clock.series["G05"][0].epoch.scale, TimeScale::Utc);
959        let interpolated = clock
960            .clock_s(
961                "G05",
962                ClockEpoch {
963                    year: 2017,
964                    month: 1,
965                    day: 1,
966                    hour: 0,
967                    minute: 0,
968                    second: 15.0,
969                },
970            )
971            .expect("valid clock query")
972            .expect("UTC interpolated clock");
973        assert!((interpolated - 1.5e-4).abs() < 1.0e-18);
974
975        let gpst_query =
976            civil_to_clock_instant(TimeScale::Gpst, 2017, 1, 1, 0, 0, 15.0).expect("GPST instant");
977        assert_eq!(
978            clock
979                .clock_s_at_instant("G05", gpst_query)
980                .expect("valid clock query"),
981            None
982        );
983
984        let rows = clock.instant_series_rows();
985        assert_eq!(rows[0].1[0].0.scale, TimeScale::Utc);
986        let rebuilt = RinexClock::from_instant_series_rows(clock.time_scale, rows)
987            .expect("valid manual RINEX clock rows");
988        assert_eq!(rebuilt, clock);
989    }
990
991    #[test]
992    fn manual_series_rows_reject_non_finite_inputs() {
993        assert_eq!(
994            RinexClock::from_series_rows(vec![("G05".to_string(), vec![(f64::NAN, 1.0e-4)])])
995                .unwrap_err(),
996            RinexClockError::InvalidInput {
997                field: "gps_seconds",
998                reason: "must be finite",
999            }
1000        );
1001        assert_eq!(
1002            RinexClock::from_series_rows(vec![(
1003                "G05".to_string(),
1004                vec![(1_463_904_000.0, f64::INFINITY)]
1005            )])
1006            .unwrap_err(),
1007            RinexClockError::InvalidInput {
1008                field: "bias_s",
1009                reason: "must be finite",
1010            }
1011        );
1012    }
1013
1014    #[test]
1015    fn manual_series_rows_reject_unsorted_gps_seconds() {
1016        assert_eq!(
1017            RinexClock::from_series_rows(vec![(
1018                "G05".to_string(),
1019                vec![(1_463_904_030.0, 1.0e-4), (1_463_904_000.0, 2.0e-4)]
1020            )])
1021            .unwrap_err(),
1022            RinexClockError::InvalidInput {
1023                field: "gps_seconds",
1024                reason: "must be strictly increasing",
1025            }
1026        );
1027    }
1028
1029    #[test]
1030    fn manual_instant_rows_reject_non_finite_inputs() {
1031        let bad_epoch = Instant::from_julian_date(
1032            TimeScale::Gpst,
1033            JulianDateSplit {
1034                jd_whole: f64::NAN,
1035                fraction: 0.0,
1036            },
1037        );
1038        assert_eq!(
1039            RinexClock::from_instant_series_rows(
1040                TimeScale::Gpst,
1041                vec![("G05".to_string(), vec![(bad_epoch, 1.0e-4)])],
1042            )
1043            .unwrap_err(),
1044            RinexClockError::InvalidInput {
1045                field: "epoch",
1046                reason: "must be finite",
1047            }
1048        );
1049
1050        let good_epoch =
1051            civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("GPST instant");
1052        assert_eq!(
1053            RinexClock::from_instant_series_rows(
1054                TimeScale::Gpst,
1055                vec![("G05".to_string(), vec![(good_epoch, f64::NAN)])],
1056            )
1057            .unwrap_err(),
1058            RinexClockError::InvalidInput {
1059                field: "bias_s",
1060                reason: "must be finite",
1061            }
1062        );
1063    }
1064
1065    #[test]
1066    fn manual_instant_rows_reject_unsorted_epochs() {
1067        let later =
1068            civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 30.0).expect("later epoch");
1069        let earlier =
1070            civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("earlier epoch");
1071
1072        assert_eq!(
1073            RinexClock::from_instant_series_rows(
1074                TimeScale::Gpst,
1075                vec![("G05".to_string(), vec![(later, 1.0e-4), (earlier, 2.0e-4)])],
1076            )
1077            .unwrap_err(),
1078            RinexClockError::InvalidInput {
1079                field: "epoch",
1080                reason: "must be strictly increasing",
1081            }
1082        );
1083    }
1084
1085    #[test]
1086    fn rinex_clock_queries_reject_non_finite_inputs() {
1087        let clock = RinexClock::from_series_rows(vec![(
1088            "G05".to_string(),
1089            vec![(1_463_904_000.0, 1.0e-4)],
1090        )])
1091        .expect("valid manual RINEX clock rows");
1092        let bad_epoch = Instant::from_julian_date(
1093            TimeScale::Gpst,
1094            JulianDateSplit {
1095                jd_whole: f64::INFINITY,
1096                fraction: 0.0,
1097            },
1098        );
1099        assert_eq!(
1100            clock.clock_s_at_instant("G05", bad_epoch).unwrap_err(),
1101            RinexClockError::InvalidInput {
1102                field: "epoch",
1103                reason: "must be finite",
1104            }
1105        );
1106        assert_eq!(
1107            clock.clock_s_at_gps_seconds("G05", f64::NAN).unwrap_err(),
1108            RinexClockError::InvalidInput {
1109                field: "gps_seconds",
1110                reason: "must be finite",
1111            }
1112        );
1113        assert_eq!(
1114            clock
1115                .clock_s(
1116                    "G05",
1117                    ClockEpoch {
1118                        year: 2026,
1119                        month: 5,
1120                        day: 13,
1121                        hour: 0,
1122                        minute: 0,
1123                        second: f64::NAN,
1124                    },
1125                )
1126                .unwrap_err(),
1127            RinexClockError::InvalidInput {
1128                field: "epoch",
1129                reason: "invalid civil clock epoch",
1130            }
1131        );
1132    }
1133
1134    #[test]
1135    fn interpolation_rejects_non_positive_bracket_span() {
1136        let day = 2_457_753.5;
1137        let p0 = Instant::from_julian_date(
1138            TimeScale::Utc,
1139            JulianDateSplit::new(day, 1.0).expect("valid split Julian date"),
1140        );
1141        let p1 = Instant::from_julian_date(
1142            TimeScale::Utc,
1143            JulianDateSplit::new(day + 1.0, 0.0).expect("valid split Julian date"),
1144        );
1145        let query = Instant::from_julian_date(
1146            TimeScale::Utc,
1147            JulianDateSplit::new(day + 1.0, 0.5 / SECONDS_PER_DAY)
1148                .expect("valid split Julian date"),
1149        );
1150        let records = [
1151            ClockPoint {
1152                epoch: p0,
1153                bias_s: 1.0e-4,
1154            },
1155            ClockPoint {
1156                epoch: p1,
1157                bias_s: 2.0e-4,
1158            },
1159        ];
1160
1161        assert_eq!(interpolate(&records, query), None);
1162    }
1163
1164    #[test]
1165    fn qzsst_rows_are_queryable_on_the_gpst_timeline() {
1166        // A QZSS clock file is tagged QZSST, which is synchronous with GPST. A
1167        // GPST-built query (clock_s_at_gps_seconds) must interpolate those rows;
1168        // an exact-scale match previously rejected them, returning None.
1169        let p0 = civil_to_clock_instant(TimeScale::Qzsst, 2026, 5, 13, 0, 0, 0.0)
1170            .expect("QZSST instant");
1171        let p1 = civil_to_clock_instant(TimeScale::Qzsst, 2026, 5, 13, 0, 0, 30.0)
1172            .expect("QZSST instant");
1173        let clock = RinexClock::from_instant_series_rows(
1174            TimeScale::Qzsst,
1175            vec![("J02".to_string(), vec![(p0, 1.0e-4), (p1, 3.0e-4)])],
1176        )
1177        .expect("QZSST clock builds");
1178
1179        // QZSST civil time equals GPST civil time, so this is the GPS-seconds tag
1180        // of the bracket midpoint (00:00:15).
1181        let mid = civil_to_gps_seconds(2026, 5, 13, 0, 0, 15.0).expect("gps seconds");
1182        let bias = clock
1183            .clock_s_at_gps_seconds("J02", mid)
1184            .expect("query succeeds")
1185            .expect("QZSST row interpolates on the GPST timeline");
1186        assert!(
1187            (bias - 2.0e-4).abs() < 1.0e-12,
1188            "expected midpoint interpolation 2.0e-4, got {bias}"
1189        );
1190
1191        // An exact-epoch GPST query returns the stored bias.
1192        let start = civil_to_gps_seconds(2026, 5, 13, 0, 0, 0.0).expect("gps seconds");
1193        assert_eq!(
1194            clock
1195                .clock_s_at_gps_seconds("J02", start)
1196                .expect("query succeeds"),
1197            Some(1.0e-4)
1198        );
1199    }
1200
1201    #[test]
1202    fn to_rinex_string_round_trips_through_parse() {
1203        // The canonical IR is the parsed product (time scale + per-satellite
1204        // series). Serializing it and re-parsing must reproduce both, across
1205        // multiple satellites and epochs with fractional seconds.
1206        let text =
1207            "     3.00           C                                       RINEX VERSION / TYPE\n\
1208                    GPS                                                         TIME SYSTEM ID\n\
1209                                                                        END OF HEADER\n\
1210                    AS G05  2026 05 13 00 00  0.000000  1   -2.000000000000e-04\n\
1211                    AS G05  2026 05 13 00 00 30.500000  1   -2.000000600000e-04\n\
1212                    AS G24  2026 05 13 00 01  0.000000  1    5.000000000000e-05\n\
1213                    AS E11  2026 05 13 00 00  0.000000  1    1.234500000000e-09\n";
1214        let clock = RinexClock::parse(text).expect("parse GPST RINEX clock");
1215        let reparsed = RinexClock::parse(&clock.to_rinex_string()).expect("re-parse serialized");
1216        assert_eq!(reparsed, clock, "serializer must round-trip through parse");
1217        // Deterministic output.
1218        assert_eq!(reparsed.to_rinex_string(), clock.to_rinex_string());
1219    }
1220
1221    #[test]
1222    fn to_rinex_string_round_trips_utc_time_scale() {
1223        // The time-system label round-trips: a UTC product re-parses as UTC.
1224        let text =
1225            "     3.00           C                                       RINEX VERSION / TYPE\n\
1226                    UTC                                                         TIME SYSTEM ID\n\
1227                                                                        END OF HEADER\n\
1228                    AS G05  2017 01 01 00 00  0.000000  1    1.000000000000e-04\n\
1229                    AS G05  2017 01 01 00 00 30.000000  1    2.000000000000e-04\n";
1230        let clock = RinexClock::parse(text).expect("parse UTC RINEX clock");
1231        assert_eq!(clock.time_scale, TimeScale::Utc);
1232        let reparsed = RinexClock::parse(&clock.to_rinex_string()).expect("re-parse serialized");
1233        assert_eq!(reparsed.time_scale, TimeScale::Utc);
1234        assert_eq!(reparsed, clock);
1235    }
1236
1237    #[test]
1238    fn nanos_repr_epoch_serializes_to_true_civil_time() {
1239        // A `Nanos`-repr instant counts from J2000 in its own scale. The
1240        // serializer must render its actual civil time, not a fabricated J2000
1241        // (2000-01-01 12:00:00). Build the same epoch in both reprs and confirm
1242        // they serialize identically and the Nanos product re-parses to the
1243        // (Julian-date) parsed product.
1244        let jd_epoch =
1245            civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 30.0).expect("GPST instant");
1246        let j2000_s = instant_to_j2000_seconds(&jd_epoch).expect("J2000 seconds");
1247        let nanos = (j2000_s * 1.0e9).round() as i128;
1248        let nanos_epoch = Instant::from_nanos(TimeScale::Gpst, nanos);
1249
1250        let nanos_clock = RinexClock::from_instant_series_rows(
1251            TimeScale::Gpst,
1252            vec![("G05".to_string(), vec![(nanos_epoch, 1.0e-4)])],
1253        )
1254        .expect("nanos clock builds");
1255        let jd_clock = RinexClock::from_instant_series_rows(
1256            TimeScale::Gpst,
1257            vec![("G05".to_string(), vec![(jd_epoch, 1.0e-4)])],
1258        )
1259        .expect("jd clock builds");
1260
1261        let serialized = nanos_clock.to_rinex_string();
1262        assert!(
1263            serialized.contains("2026 05 13 00 00 30.000000"),
1264            "Nanos epoch must serialize to its true civil time, got:\n{serialized}"
1265        );
1266        assert_eq!(
1267            serialized,
1268            jd_clock.to_rinex_string(),
1269            "Nanos- and Julian-date-repr epochs of the same instant must serialize identically"
1270        );
1271
1272        let reparsed = RinexClock::parse(&serialized).expect("re-parse serialized Nanos product");
1273        assert_eq!(reparsed, jd_clock);
1274    }
1275
1276    #[test]
1277    fn to_rinex_string_round_trips_utc_leap_second_epoch() {
1278        // The parser accepts a UTC `23:59:60.x` leap-second label, storing it as a
1279        // sub-midnight fraction on the next day's whole JD. The serializer must
1280        // reproduce that `:60` label exactly, not a wrong time from the negative
1281        // time-of-day.
1282        let text =
1283            "     3.00           C                                       RINEX VERSION / TYPE\n\
1284                    UTC                                                         TIME SYSTEM ID\n\
1285                                                                        END OF HEADER\n\
1286                    AS G05  2016 12 31 23 59 60.000000  1    1.000000000000e-04\n\
1287                    AS G05  2016 12 31 23 59 60.500000  1    2.000000000000e-04\n";
1288        let clock = RinexClock::parse(text).expect("parse UTC leap-second RINEX clock");
1289        let serialized = clock.to_rinex_string();
1290        assert!(
1291            serialized.contains("23 59 60.000000"),
1292            "leap-second label must round-trip, got:\n{serialized}"
1293        );
1294        assert!(
1295            serialized.contains("23 59 60.500000"),
1296            "fractional leap second must round-trip, got:\n{serialized}"
1297        );
1298        let reparsed = RinexClock::parse(&serialized).expect("re-parse serialized leap second");
1299        assert_eq!(
1300            reparsed, clock,
1301            "leap-second epoch must round-trip bit-exact"
1302        );
1303    }
1304}