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::{
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#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct ClockPoint {
29 pub epoch: Instant,
31 pub bias_s: f64,
33}
34
35impl ClockPoint {
36 pub fn gps_seconds(&self) -> Option<f64> {
38 instant_to_gps_seconds(&self.epoch)
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct ClockEpoch {
45 pub year: i32,
47 pub month: u8,
49 pub day: u8,
51 pub hour: u8,
53 pub minute: u8,
55 pub second: f64,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct RinexClock {
62 pub time_scale: TimeScale,
64 pub series: BTreeMap<String, Vec<ClockPoint>>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum RinexClockError {
71 MalformedAsRecord {
73 line: usize,
75 reason: &'static str,
77 record: String,
79 },
80 BadField {
82 line: usize,
84 field: &'static str,
86 value: String,
88 },
89 InvalidInput {
91 field: &'static str,
93 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 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 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 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 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 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 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 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 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 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 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
308fn 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 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
322fn 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 if (-1.0 / SECONDS_PER_DAY..0.0).contains(&split.fraction) {
338 return leap_second_civil(split);
339 }
340 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 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
363fn 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; let microsecond = ((1.0 - remaining_s) * 1_000_000.0).round() as i64;
372 (year, month, day, 23, 59, 60 * 1_000_000 + microsecond)
375}
376
377fn nanos_civil_day_microsecond(nanos: i128) -> (i64, i64) {
381 const US_PER_DAY: i128 = SECONDS_PER_DAY_I64 as i128 * 1_000_000;
382 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; 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
393pub 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
416pub 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 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
851fn 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 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 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 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 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 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 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 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 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}