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