1use 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#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct ClockPoint {
27 pub epoch: Instant,
29 pub bias_s: f64,
31}
32
33impl ClockPoint {
34 pub fn gps_seconds(&self) -> Option<f64> {
36 instant_to_gps_seconds(&self.epoch)
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct ClockEpoch {
43 pub year: i32,
45 pub month: u8,
47 pub day: u8,
49 pub hour: u8,
51 pub minute: u8,
53 pub second: f64,
55}
56
57#[derive(Debug, Clone, PartialEq)]
59pub struct RinexClock {
60 pub time_scale: TimeScale,
62 pub series: BTreeMap<String, Vec<ClockPoint>>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum RinexClockError {
69 MalformedAsRecord {
71 line: usize,
73 reason: &'static str,
75 record: String,
77 },
78 BadField {
80 line: usize,
82 field: &'static str,
84 value: String,
86 },
87 InvalidInput {
89 field: &'static str,
91 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 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 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 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 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 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 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 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 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 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 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
306fn 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 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
320fn 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 if (-1.0 / SECONDS_PER_DAY..0.0).contains(&split.fraction) {
336 return leap_second_civil(split);
337 }
338 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 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
360fn 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; let microsecond = ((1.0 - remaining_s) * 1_000_000.0).round() as i64;
369 (year, month, day, 23, 59, 60 * 1_000_000 + microsecond)
372}
373
374fn nanos_civil_day_microsecond(nanos: i128) -> (i64, i64) {
378 const US_PER_DAY: i128 = SECONDS_PER_DAY_I64 as i128 * 1_000_000;
379 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; 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
390pub 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
413pub 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 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
848fn 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 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 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 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 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 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 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 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 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}