1use std::cmp::Ordering;
8use std::collections::BTreeMap;
9use std::fmt;
10
11use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
12use crate::constants::{GPS_EPOCH_TO_J2000_S, J2000_JD, SECONDS_PER_DAY};
13use crate::validate::{self, FieldError};
14
15const INSTANT_SCALE_ORDER_STRIDE_S: f64 = 1.0e15;
16
17#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct ClockPoint {
20 pub epoch: Instant,
22 pub bias_s: f64,
24}
25
26impl ClockPoint {
27 pub fn gps_seconds(&self) -> Option<f64> {
29 instant_to_gps_seconds(&self.epoch)
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ClockEpoch {
36 pub year: i32,
38 pub month: u8,
40 pub day: u8,
42 pub hour: u8,
44 pub minute: u8,
46 pub second: f64,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct RinexClock {
53 pub time_scale: TimeScale,
55 pub series: BTreeMap<String, Vec<ClockPoint>>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum RinexClockError {
62 MalformedAsRecord {
64 line: usize,
66 reason: &'static str,
68 record: String,
70 },
71 BadField {
73 line: usize,
75 field: &'static str,
77 value: String,
79 },
80 InvalidInput {
82 field: &'static str,
84 reason: &'static str,
86 },
87}
88
89impl fmt::Display for RinexClockError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 RinexClockError::MalformedAsRecord {
93 line,
94 reason,
95 record,
96 } => write!(
97 f,
98 "malformed RINEX AS clock record at line {line}: {reason}: {record}"
99 ),
100 RinexClockError::BadField { line, field, value } => write!(
101 f,
102 "bad RINEX AS clock field at line {line}: {field}={value}"
103 ),
104 RinexClockError::InvalidInput { field, reason } => {
105 write!(f, "invalid RINEX clock input {field}: {reason}")
106 }
107 }
108 }
109}
110
111impl std::error::Error for RinexClockError {}
112
113impl RinexClock {
114 pub fn parse(text: &str) -> Result<Self, RinexClockError> {
116 let time_scale = parse_time_scale(text)?;
117 let lines = data_lines(text);
118 let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
119
120 for (line_number, line) in lines {
121 if let Some((sat, point)) = parse_record(line_number, line, time_scale)? {
122 by_sat.entry(sat).or_default().push((point, line_number));
123 }
124 }
125
126 Ok(Self {
127 time_scale,
128 series: build_series(by_sat),
129 })
130 }
131
132 pub fn parse_lossy(text: &str) -> Self {
134 let time_scale = parse_time_scale(text).unwrap_or(TimeScale::Gpst);
135 let lines = data_lines(text);
136 let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
137
138 for (line_number, line) in lines {
139 if let Ok(Some((sat, point))) = parse_record(line_number, line, time_scale) {
140 by_sat.entry(sat).or_default().push((point, line_number));
141 }
142 }
143
144 Self {
145 time_scale,
146 series: build_series(by_sat),
147 }
148 }
149
150 pub fn from_series_rows(rows: Vec<(String, Vec<(f64, f64)>)>) -> Result<Self, RinexClockError> {
152 let rows = rows
153 .into_iter()
154 .map(|(sat, points)| {
155 validate::require_strictly_increasing(
156 points.iter().map(|&(gps_seconds, _)| gps_seconds),
157 "gps_seconds",
158 )
159 .map_err(map_manual_order_error)?;
160 let points = points
161 .into_iter()
162 .map(|(gps_seconds, bias_s)| {
163 validate_finite(bias_s, "bias_s")?;
164 Ok((gps_seconds_to_instant(gps_seconds), bias_s))
165 })
166 .collect::<Result<Vec<_>, RinexClockError>>()?;
167 Ok((sat, points))
168 })
169 .collect::<Result<Vec<_>, RinexClockError>>()?;
170 Self::from_instant_series_rows(TimeScale::Gpst, rows)
171 }
172
173 pub fn from_instant_series_rows(
175 time_scale: TimeScale,
176 rows: Vec<(String, Vec<(Instant, f64)>)>,
177 ) -> Result<Self, RinexClockError> {
178 let mut series = BTreeMap::new();
179 for (sat, points) in rows {
180 let mut indexed = points
181 .into_iter()
182 .enumerate()
183 .map(|(idx, (epoch, bias_s))| {
184 let point = ClockPoint { epoch, bias_s };
185 validate_clock_point(point)?;
186 Ok((point, idx))
187 })
188 .collect::<Result<Vec<_>, RinexClockError>>()?;
189 validate_instant_series_order(&indexed)?;
190 indexed.sort_by(|(a, ai), (b, bi)| {
191 compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
192 });
193 series.insert(sat, dedup_by_time(indexed));
194 }
195 Ok(Self { time_scale, series })
196 }
197
198 pub fn series_rows(&self) -> Vec<(String, Vec<(f64, f64)>)> {
202 self.series
203 .iter()
204 .map(|(sat, points)| {
205 (
206 sat.clone(),
207 points
208 .iter()
209 .filter_map(|point| Some((point.gps_seconds()?, point.bias_s)))
210 .collect(),
211 )
212 })
213 .collect()
214 }
215
216 pub fn instant_series_rows(&self) -> Vec<(String, Vec<(Instant, f64)>)> {
218 self.series
219 .iter()
220 .map(|(sat, points)| {
221 (
222 sat.clone(),
223 points
224 .iter()
225 .map(|point| (point.epoch, point.bias_s))
226 .collect(),
227 )
228 })
229 .collect()
230 }
231
232 pub fn clock_s(
234 &self,
235 satellite_id: &str,
236 epoch: ClockEpoch,
237 ) -> Result<Option<f64>, RinexClockError> {
238 let epoch = civil_to_clock_instant(
239 self.time_scale,
240 epoch.year,
241 epoch.month,
242 epoch.day,
243 epoch.hour,
244 epoch.minute,
245 epoch.second,
246 )
247 .ok_or_else(|| invalid_input("epoch", "invalid civil clock epoch"))?;
248 self.clock_s_at_instant(satellite_id, epoch)
249 }
250
251 pub fn clock_s_at_instant(
253 &self,
254 satellite_id: &str,
255 epoch: Instant,
256 ) -> Result<Option<f64>, RinexClockError> {
257 validate_instant(epoch, "epoch")?;
258 let Some(records) = self.series.get(satellite_id) else {
259 return Ok(None);
260 };
261 Ok(interpolate(records, epoch))
262 }
263
264 pub fn clock_s_at_gps_seconds(
266 &self,
267 satellite_id: &str,
268 gps_seconds: f64,
269 ) -> Result<Option<f64>, RinexClockError> {
270 validate_finite(gps_seconds, "gps_seconds")?;
271 self.clock_s_at_instant(satellite_id, gps_seconds_to_instant(gps_seconds))
272 }
273}
274
275pub fn civil_to_clock_instant(
277 scale: TimeScale,
278 year: i32,
279 month: u8,
280 day: u8,
281 hour: u8,
282 minute: u8,
283 second: f64,
284) -> Option<Instant> {
285 let civil = validate::civil_datetime_with_fractional_second_policy(
286 i64::from(year),
287 i64::from(month),
288 i64::from(day),
289 i64::from(hour),
290 i64::from(minute),
291 second,
292 civil_second_policy_for_time_scale(scale),
293 )
294 .ok()?;
295 civil_microsecond_to_instant(scale, civil).ok()
296}
297
298pub fn civil_to_gps_seconds(
300 year: i32,
301 month: u8,
302 day: u8,
303 hour: u8,
304 minute: u8,
305 second: f64,
306) -> Option<f64> {
307 let civil = validate::civil_datetime_with_fractional_second_policy(
308 i64::from(year),
309 i64::from(month),
310 i64::from(day),
311 i64::from(hour),
312 i64::from(minute),
313 second,
314 validate::CivilSecondPolicy::Continuous,
315 )
316 .ok()?;
317 gps_seconds_from_civil(civil)
318}
319
320fn parse_time_scale(text: &str) -> Result<TimeScale, RinexClockError> {
321 let mut time_scale = TimeScale::Gpst;
322 for (idx, line) in text.lines().enumerate() {
323 if line.contains("END OF HEADER") {
324 break;
325 }
326 if line.contains("TIME SYSTEM ID") {
327 let label = line
328 .split("TIME SYSTEM ID")
329 .next()
330 .unwrap_or(line)
331 .split_whitespace()
332 .next()
333 .unwrap_or("");
334 if label.is_empty() {
335 time_scale = TimeScale::Gpst;
336 } else {
337 time_scale = crate::parse::time_scale_label(label).ok_or_else(|| {
338 RinexClockError::BadField {
339 line: idx + 1,
340 field: "time_system",
341 value: label.to_string(),
342 }
343 })?;
344 }
345 }
346 }
347 Ok(time_scale)
348}
349
350fn gps_seconds_to_instant(gps_seconds: f64) -> Instant {
351 let gps_epoch_jd = J2000_JD - GPS_EPOCH_TO_J2000_S / SECONDS_PER_DAY;
352 let days = (gps_seconds / SECONDS_PER_DAY).floor();
353 let seconds_of_day = gps_seconds - days * SECONDS_PER_DAY;
354 Instant::from_julian_date(
355 TimeScale::Gpst,
356 JulianDateSplit::new(gps_epoch_jd + days, seconds_of_day / SECONDS_PER_DAY)
357 .expect("valid split Julian date"),
358 )
359}
360
361fn validate_clock_point(point: ClockPoint) -> Result<(), RinexClockError> {
362 validate_instant(point.epoch, "epoch")?;
363 validate_finite(point.bias_s, "bias_s")
364}
365
366fn validate_instant(epoch: Instant, field: &'static str) -> Result<(), RinexClockError> {
367 match epoch.repr {
368 InstantRepr::JulianDate(split) => {
369 validate_finite(split.jd_whole, field)?;
370 validate_finite(split.fraction, field)?;
371 if !(-1.0..=1.0).contains(&split.fraction) {
372 return Err(invalid_input(field, "Julian-date fraction out of range"));
373 }
374 Ok(())
375 }
376 InstantRepr::Nanos(_) => Ok(()),
377 }
378}
379
380fn validate_finite(value: f64, field: &'static str) -> Result<(), RinexClockError> {
381 if value.is_finite() {
382 Ok(())
383 } else {
384 Err(invalid_input(field, "must be finite"))
385 }
386}
387
388fn invalid_input(field: &'static str, reason: &'static str) -> RinexClockError {
389 RinexClockError::InvalidInput { field, reason }
390}
391
392fn map_manual_order_error(error: FieldError) -> RinexClockError {
393 match error {
394 FieldError::NonFinite { field } => invalid_input(field, "must be finite"),
395 FieldError::OutOfRange { field, .. } => invalid_input(field, "must be strictly increasing"),
396 _ => invalid_input(error.field(), error.reason()),
397 }
398}
399
400fn validate_instant_series_order(points: &[(ClockPoint, usize)]) -> Result<(), RinexClockError> {
401 validate::require_strictly_increasing(
402 points
403 .iter()
404 .map(|(point, _)| instant_order_key(&point.epoch)),
405 "epoch",
406 )
407 .map_err(map_manual_order_error)
408}
409
410fn instant_order_key(epoch: &Instant) -> f64 {
411 let offset_s = time_scale_rank(epoch.scale) as f64 * INSTANT_SCALE_ORDER_STRIDE_S;
412 let instant_s = match epoch.repr {
413 InstantRepr::JulianDate(split) => {
414 split.jd_whole * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY
415 }
416 InstantRepr::Nanos(nanos) => nanos as f64 / 1.0e9,
417 };
418 offset_s + instant_s
419}
420
421fn instant_to_gps_seconds(epoch: &Instant) -> Option<f64> {
422 if epoch.scale != TimeScale::Gpst {
423 return None;
424 }
425 instant_to_j2000_seconds(epoch).map(|seconds| seconds + GPS_EPOCH_TO_J2000_S)
426}
427
428fn instant_to_j2000_seconds(epoch: &Instant) -> Option<f64> {
429 match epoch.repr {
430 InstantRepr::JulianDate(split) => {
431 Some((split.jd_whole - J2000_JD) * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY)
432 }
433 InstantRepr::Nanos(_) => None,
434 }
435}
436
437fn data_lines(text: &str) -> Vec<(usize, &str)> {
438 drop_header(
439 text.lines()
440 .enumerate()
441 .map(|(idx, line)| (idx + 1, line))
442 .collect(),
443 )
444}
445
446fn drop_header(lines: Vec<(usize, &str)>) -> Vec<(usize, &str)> {
447 match lines
448 .iter()
449 .position(|(_, line)| line.contains("END OF HEADER"))
450 {
451 Some(idx) => lines.into_iter().skip(idx + 1).collect(),
452 None => lines,
453 }
454}
455
456#[derive(Debug, Clone, Copy)]
457struct ClockEpochFields<'a> {
458 year: i32,
459 month: u8,
460 day: u8,
461 hour: u8,
462 minute: u8,
463 second: &'a str,
464}
465
466fn parse_record(
467 line_number: usize,
468 line: &str,
469 time_scale: TimeScale,
470) -> Result<Option<(String, ClockPoint)>, RinexClockError> {
471 let fields = line.split_whitespace().collect::<Vec<_>>();
472 if fields.first() != Some(&"AS") {
473 return Ok(None);
474 }
475 if fields.len() < 10 {
476 return Err(RinexClockError::MalformedAsRecord {
477 line: line_number,
478 reason: "expected at least 10 fields",
479 record: line.trim().to_string(),
480 });
481 }
482
483 let sat = validate::strict_gnss_satellite_id(fields[1], "satellite")
484 .map_err(|error| map_field_error(line_number, error, fields[1]))?
485 .to_string();
486 let year = parse_int_field::<i32>(line_number, "year", fields[2])?;
487 let month = parse_int_field::<u8>(line_number, "month", fields[3])?;
488 let day = parse_int_field::<u8>(line_number, "day", fields[4])?;
489 let hour = parse_int_field::<u8>(line_number, "hour", fields[5])?;
490 let minute = parse_int_field::<u8>(line_number, "minute", fields[6])?;
491 let epoch = ClockEpochFields {
492 year,
493 month,
494 day,
495 hour,
496 minute,
497 second: fields[7],
498 };
499 let bias_s = parse_f64_field(line_number, "bias", fields[9])?;
500 let epoch = civil_decimal_second_to_instant(time_scale, epoch)
501 .map_err(|error| map_epoch_error(line_number, error, epoch))?;
502
503 Ok(Some((sat, ClockPoint { epoch, bias_s })))
504}
505
506fn parse_int_field<T>(
507 line_number: usize,
508 field: &'static str,
509 value: &str,
510) -> Result<T, RinexClockError>
511where
512 T: std::str::FromStr,
513{
514 validate::strict_int(value, field).map_err(|error| map_field_error(line_number, error, value))
515}
516
517fn parse_f64_field(
518 line_number: usize,
519 field: &'static str,
520 value: &str,
521) -> Result<f64, RinexClockError> {
522 validate::strict_f64(value, field).map_err(|error| map_field_error(line_number, error, value))
523}
524
525fn civil_decimal_second_to_instant(
526 scale: TimeScale,
527 epoch: ClockEpochFields<'_>,
528) -> Result<Instant, FieldError> {
529 let civil = validate::civil_datetime_with_decimal_second_policy(
530 i64::from(epoch.year),
531 i64::from(epoch.month),
532 i64::from(epoch.day),
533 i64::from(epoch.hour),
534 i64::from(epoch.minute),
535 epoch.second,
536 civil_second_policy_for_time_scale(scale),
537 )?;
538 civil_microsecond_to_instant(scale, civil)
539}
540
541fn civil_microsecond_to_instant(
542 scale: TimeScale,
543 civil: validate::ValidCivilMicrosecond,
544) -> Result<Instant, FieldError> {
545 let split = civil_microsecond_to_julian_split(scale, civil)?;
546 Ok(Instant::from_julian_date(scale, split))
547}
548
549fn civil_microsecond_to_julian_split(
550 scale: TimeScale,
551 civil: validate::ValidCivilMicrosecond,
552) -> Result<JulianDateSplit, FieldError> {
553 if civil.year < 1 {
554 return Err(FieldError::InvalidCivilDate {
555 field: "civil datetime",
556 year: civil.year,
557 month: i64::from(civil.month),
558 day: i64::from(civil.day),
559 });
560 }
561
562 let month = i64::from(civil.month);
563 let day = i64::from(civil.day);
564 let a = (14 - month) / 12;
565 let y = civil.year + 4800 - a;
566 let m = month + 12 * a - 3;
567 let jdn = day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32_045;
568 let jd_whole = jdn as f64 - 0.5;
569 if scale == TimeScale::Utc && civil.second == 60 {
570 let remaining_s = 1.0 - civil.microsecond as f64 / 1_000_000.0;
571 return Ok(
572 JulianDateSplit::new(jd_whole + 1.0, -remaining_s / SECONDS_PER_DAY)
573 .expect("valid leap-second split Julian date"),
574 );
575 }
576
577 let day_seconds = civil.hour as f64 * 3600.0
578 + civil.minute as f64 * 60.0
579 + civil.second as f64
580 + civil.microsecond as f64 / 1_000_000.0;
581 Ok(
582 JulianDateSplit::new(jd_whole, day_seconds / SECONDS_PER_DAY)
583 .expect("valid split Julian date"),
584 )
585}
586
587fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
588 match scale {
589 TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
590 TimeScale::Tai
591 | TimeScale::Tt
592 | TimeScale::Tdb
593 | TimeScale::Gpst
594 | TimeScale::Gst
595 | TimeScale::Bdt => validate::CivilSecondPolicy::Continuous,
596 }
597}
598
599fn gps_seconds_from_civil(civil: validate::ValidCivilMicrosecond) -> Option<f64> {
600 if civil.year < 1 {
601 return None;
602 }
603
604 let days = days_since_gps_epoch(civil.year as i32, civil.month as u8, civil.day as u8);
605 let whole = days as f64 * SECONDS_PER_DAY
606 + (i64::from(civil.hour) * 3_600 + i64::from(civil.minute) * 60 + i64::from(civil.second))
607 as f64;
608 Some(whole + f64::from(civil.microsecond) / 1_000_000.0)
609}
610
611fn map_field_error(line_number: usize, error: FieldError, value: &str) -> RinexClockError {
612 RinexClockError::BadField {
613 line: line_number,
614 field: error.field(),
615 value: value.to_string(),
616 }
617}
618
619fn map_epoch_error(
620 line_number: usize,
621 error: FieldError,
622 epoch: ClockEpochFields<'_>,
623) -> RinexClockError {
624 match error {
625 FieldError::FloatParse { .. }
626 | FieldError::Missing { .. }
627 | FieldError::NonFinite { .. } => RinexClockError::BadField {
628 line: line_number,
629 field: "second",
630 value: epoch.second.to_string(),
631 },
632 _ => RinexClockError::BadField {
633 line: line_number,
634 field: "epoch",
635 value: format!(
636 "{} {} {} {} {} {}",
637 epoch.year,
638 epoch.month,
639 epoch.day,
640 epoch.hour,
641 epoch.minute,
642 normalized_second_text(epoch.second)
643 ),
644 },
645 }
646}
647
648fn normalized_second_text(second: &str) -> String {
649 validate::strict_f64(second, "second")
650 .map_or_else(|_| second.to_string(), |value| value.to_string())
651}
652
653fn build_series(
654 by_sat: BTreeMap<String, Vec<(ClockPoint, usize)>>,
655) -> BTreeMap<String, Vec<ClockPoint>> {
656 by_sat
657 .into_iter()
658 .map(|(sat, mut points)| {
659 points.sort_by(|(a, ai), (b, bi)| {
660 compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
661 });
662 (sat, dedup_by_time(points))
663 })
664 .collect()
665}
666
667fn dedup_by_time(points: Vec<(ClockPoint, usize)>) -> Vec<ClockPoint> {
668 let mut deduped = Vec::<ClockPoint>::new();
669 for (point, _) in points {
670 match deduped.last_mut() {
671 Some(prev) if prev.epoch == point.epoch => *prev = point,
672 _ => deduped.push(point),
673 }
674 }
675 deduped
676}
677
678fn interpolate(records: &[ClockPoint], epoch: Instant) -> Option<f64> {
679 let mut prev: Option<ClockPoint> = None;
680 for point in records {
681 match compare_instants_same_scale(&point.epoch, &epoch)? {
682 Ordering::Equal => return Some(point.bias_s),
683 Ordering::Greater => {
684 let p0 = prev?;
685 let p1 = *point;
686 let span_s = seconds_between(&p1.epoch, &p0.epoch)?;
687 if span_s <= 0.0 {
688 return None;
689 }
690 let query_s = seconds_between(&epoch, &p0.epoch)?;
691 if query_s < 0.0 {
692 return None;
693 }
694 return Some(p0.bias_s + (p1.bias_s - p0.bias_s) * query_s / span_s);
695 }
696 Ordering::Less => prev = Some(*point),
697 }
698 }
699 None
700}
701
702fn compare_instants(a: &Instant, b: &Instant) -> Ordering {
703 time_scale_rank(a.scale)
704 .cmp(&time_scale_rank(b.scale))
705 .then_with(|| match (a.julian_date(), b.julian_date()) {
706 (Some(a), Some(b)) => compare_julian_splits(a, b),
707 _ => Ordering::Equal,
708 })
709}
710
711fn compare_instants_same_scale(a: &Instant, b: &Instant) -> Option<Ordering> {
712 if a.scale != b.scale {
713 return None;
714 }
715 Some(compare_julian_splits(a.julian_date()?, b.julian_date()?))
716}
717
718fn compare_julian_splits(a: JulianDateSplit, b: JulianDateSplit) -> Ordering {
719 a.jd_whole
720 .partial_cmp(&b.jd_whole)
721 .unwrap_or(Ordering::Equal)
722 .then_with(|| {
723 a.fraction
724 .partial_cmp(&b.fraction)
725 .unwrap_or(Ordering::Equal)
726 })
727}
728
729fn seconds_between(later: &Instant, earlier: &Instant) -> Option<f64> {
730 if later.scale != earlier.scale {
731 return None;
732 }
733 let later = later.julian_date()?;
734 let earlier = earlier.julian_date()?;
735 let seconds = ((later.jd_whole - earlier.jd_whole) + (later.fraction - earlier.fraction))
736 * SECONDS_PER_DAY;
737 seconds.is_finite().then_some(seconds)
738}
739
740fn time_scale_rank(scale: TimeScale) -> u8 {
741 match scale {
742 TimeScale::Utc => 0,
743 TimeScale::Tai => 1,
744 TimeScale::Tt => 2,
745 TimeScale::Tdb => 3,
746 TimeScale::Gpst => 4,
747 TimeScale::Gst => 5,
748 TimeScale::Bdt => 6,
749 }
750}
751
752fn days_since_gps_epoch(year: i32, month: u8, day: u8) -> i64 {
753 days_before_date(year, month, day) - days_before_date(1980, 1, 6)
754}
755
756fn days_before_date(year: i32, month: u8, day: u8) -> i64 {
757 let mut days = days_before_year(year);
758 for m in 1..month {
759 days += i64::from(days_in_month(year, m));
760 }
761 days + i64::from(day - 1)
762}
763
764fn days_before_year(year: i32) -> i64 {
765 let y = i64::from(year - 1);
766 y * 365 + y / 4 - y / 100 + y / 400
767}
768
769fn days_in_month(year: i32, month: u8) -> u8 {
770 match month {
771 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
772 4 | 6 | 9 | 11 => 30,
773 2 if is_leap_year(year) => 29,
774 2 => 28,
775 _ => 0,
776 }
777}
778
779fn is_leap_year(year: i32) -> bool {
780 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 fn as_record(satellite: &str, bias: &str) -> String {
788 format!("AS {satellite} 2020 01 01 00 00 00.000000 1 {bias}")
789 }
790
791 #[test]
792 fn parse_rejects_non_finite_as_bias() {
793 let err = RinexClock::parse(&as_record("G01", "NaN")).unwrap_err();
794 assert_eq!(
795 err,
796 RinexClockError::BadField {
797 line: 1,
798 field: "bias",
799 value: "NaN".to_string(),
800 }
801 );
802 }
803
804 #[test]
805 fn parse_rejects_malformed_as_satellite_token() {
806 let err = RinexClock::parse(&as_record("X01", "1.0e-9")).unwrap_err();
807 assert_eq!(
808 err,
809 RinexClockError::BadField {
810 line: 1,
811 field: "satellite",
812 value: "X01".to_string(),
813 }
814 );
815 }
816
817 #[test]
818 fn explicit_utc_time_system_preserves_clock_epoch_scale() {
819 let text = " 3.00 C RINEX VERSION / TYPE\n\
820 UTC TIME SYSTEM ID\n\
821 END OF HEADER\n\
822 AS G05 2017 01 01 00 00 0.000000 1 1.0e-04\n\
823 AS G05 2017 01 01 00 00 30.000000 1 2.0e-04\n";
824 let clock = RinexClock::parse(text).expect("UTC RINEX clock");
825
826 assert_eq!(clock.time_scale, TimeScale::Utc);
827 assert_eq!(clock.series["G05"][0].epoch.scale, TimeScale::Utc);
828 let interpolated = clock
829 .clock_s(
830 "G05",
831 ClockEpoch {
832 year: 2017,
833 month: 1,
834 day: 1,
835 hour: 0,
836 minute: 0,
837 second: 15.0,
838 },
839 )
840 .expect("valid clock query")
841 .expect("UTC interpolated clock");
842 assert!((interpolated - 1.5e-4).abs() < 1.0e-18);
843
844 let gpst_query =
845 civil_to_clock_instant(TimeScale::Gpst, 2017, 1, 1, 0, 0, 15.0).expect("GPST instant");
846 assert_eq!(
847 clock
848 .clock_s_at_instant("G05", gpst_query)
849 .expect("valid clock query"),
850 None
851 );
852
853 let rows = clock.instant_series_rows();
854 assert_eq!(rows[0].1[0].0.scale, TimeScale::Utc);
855 let rebuilt = RinexClock::from_instant_series_rows(clock.time_scale, rows)
856 .expect("valid manual RINEX clock rows");
857 assert_eq!(rebuilt, clock);
858 }
859
860 #[test]
861 fn manual_series_rows_reject_non_finite_inputs() {
862 assert_eq!(
863 RinexClock::from_series_rows(vec![("G05".to_string(), vec![(f64::NAN, 1.0e-4)])])
864 .unwrap_err(),
865 RinexClockError::InvalidInput {
866 field: "gps_seconds",
867 reason: "must be finite",
868 }
869 );
870 assert_eq!(
871 RinexClock::from_series_rows(vec![(
872 "G05".to_string(),
873 vec![(1_463_904_000.0, f64::INFINITY)]
874 )])
875 .unwrap_err(),
876 RinexClockError::InvalidInput {
877 field: "bias_s",
878 reason: "must be finite",
879 }
880 );
881 }
882
883 #[test]
884 fn manual_series_rows_reject_unsorted_gps_seconds() {
885 assert_eq!(
886 RinexClock::from_series_rows(vec![(
887 "G05".to_string(),
888 vec![(1_463_904_030.0, 1.0e-4), (1_463_904_000.0, 2.0e-4)]
889 )])
890 .unwrap_err(),
891 RinexClockError::InvalidInput {
892 field: "gps_seconds",
893 reason: "must be strictly increasing",
894 }
895 );
896 }
897
898 #[test]
899 fn manual_instant_rows_reject_non_finite_inputs() {
900 let bad_epoch = Instant::from_julian_date(
901 TimeScale::Gpst,
902 JulianDateSplit {
903 jd_whole: f64::NAN,
904 fraction: 0.0,
905 },
906 );
907 assert_eq!(
908 RinexClock::from_instant_series_rows(
909 TimeScale::Gpst,
910 vec![("G05".to_string(), vec![(bad_epoch, 1.0e-4)])],
911 )
912 .unwrap_err(),
913 RinexClockError::InvalidInput {
914 field: "epoch",
915 reason: "must be finite",
916 }
917 );
918
919 let good_epoch =
920 civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("GPST instant");
921 assert_eq!(
922 RinexClock::from_instant_series_rows(
923 TimeScale::Gpst,
924 vec![("G05".to_string(), vec![(good_epoch, f64::NAN)])],
925 )
926 .unwrap_err(),
927 RinexClockError::InvalidInput {
928 field: "bias_s",
929 reason: "must be finite",
930 }
931 );
932 }
933
934 #[test]
935 fn manual_instant_rows_reject_unsorted_epochs() {
936 let later =
937 civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 30.0).expect("later epoch");
938 let earlier =
939 civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("earlier epoch");
940
941 assert_eq!(
942 RinexClock::from_instant_series_rows(
943 TimeScale::Gpst,
944 vec![("G05".to_string(), vec![(later, 1.0e-4), (earlier, 2.0e-4)])],
945 )
946 .unwrap_err(),
947 RinexClockError::InvalidInput {
948 field: "epoch",
949 reason: "must be strictly increasing",
950 }
951 );
952 }
953
954 #[test]
955 fn rinex_clock_queries_reject_non_finite_inputs() {
956 let clock = RinexClock::from_series_rows(vec![(
957 "G05".to_string(),
958 vec![(1_463_904_000.0, 1.0e-4)],
959 )])
960 .expect("valid manual RINEX clock rows");
961 let bad_epoch = Instant::from_julian_date(
962 TimeScale::Gpst,
963 JulianDateSplit {
964 jd_whole: f64::INFINITY,
965 fraction: 0.0,
966 },
967 );
968 assert_eq!(
969 clock.clock_s_at_instant("G05", bad_epoch).unwrap_err(),
970 RinexClockError::InvalidInput {
971 field: "epoch",
972 reason: "must be finite",
973 }
974 );
975 assert_eq!(
976 clock.clock_s_at_gps_seconds("G05", f64::NAN).unwrap_err(),
977 RinexClockError::InvalidInput {
978 field: "gps_seconds",
979 reason: "must be finite",
980 }
981 );
982 assert_eq!(
983 clock
984 .clock_s(
985 "G05",
986 ClockEpoch {
987 year: 2026,
988 month: 5,
989 day: 13,
990 hour: 0,
991 minute: 0,
992 second: f64::NAN,
993 },
994 )
995 .unwrap_err(),
996 RinexClockError::InvalidInput {
997 field: "epoch",
998 reason: "invalid civil clock epoch",
999 }
1000 );
1001 }
1002
1003 #[test]
1004 fn interpolation_rejects_non_positive_bracket_span() {
1005 let day = 2_457_753.5;
1006 let p0 = Instant::from_julian_date(
1007 TimeScale::Utc,
1008 JulianDateSplit::new(day, 1.0).expect("valid split Julian date"),
1009 );
1010 let p1 = Instant::from_julian_date(
1011 TimeScale::Utc,
1012 JulianDateSplit::new(day + 1.0, 0.0).expect("valid split Julian date"),
1013 );
1014 let query = Instant::from_julian_date(
1015 TimeScale::Utc,
1016 JulianDateSplit::new(day + 1.0, 0.5 / SECONDS_PER_DAY)
1017 .expect("valid split Julian date"),
1018 );
1019 let records = [
1020 ClockPoint {
1021 epoch: p0,
1022 bias_s: 1.0e-4,
1023 },
1024 ClockPoint {
1025 epoch: p1,
1026 bias_s: 2.0e-4,
1027 },
1028 ];
1029
1030 assert_eq!(interpolate(&records, query), None);
1031 }
1032}