use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;
use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
use crate::constants::{GPS_EPOCH_TO_J2000_S, J2000_JD, SECONDS_PER_DAY};
use crate::validate::{self, FieldError};
const INSTANT_SCALE_ORDER_STRIDE_S: f64 = 1.0e15;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClockPoint {
pub epoch: Instant,
pub bias_s: f64,
}
impl ClockPoint {
pub fn gps_seconds(&self) -> Option<f64> {
instant_to_gps_seconds(&self.epoch)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClockEpoch {
pub year: i32,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RinexClock {
pub time_scale: TimeScale,
pub series: BTreeMap<String, Vec<ClockPoint>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RinexClockError {
MalformedAsRecord {
line: usize,
reason: &'static str,
record: String,
},
BadField {
line: usize,
field: &'static str,
value: String,
},
InvalidInput {
field: &'static str,
reason: &'static str,
},
}
impl fmt::Display for RinexClockError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RinexClockError::MalformedAsRecord {
line,
reason,
record,
} => write!(
f,
"malformed RINEX AS clock record at line {line}: {reason}: {record}"
),
RinexClockError::BadField { line, field, value } => write!(
f,
"bad RINEX AS clock field at line {line}: {field}={value}"
),
RinexClockError::InvalidInput { field, reason } => {
write!(f, "invalid RINEX clock input {field}: {reason}")
}
}
}
}
impl std::error::Error for RinexClockError {}
impl RinexClock {
pub fn parse(text: &str) -> Result<Self, RinexClockError> {
let time_scale = parse_time_scale(text)?;
let lines = data_lines(text);
let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
for (line_number, line) in lines {
if let Some((sat, point)) = parse_record(line_number, line, time_scale)? {
by_sat.entry(sat).or_default().push((point, line_number));
}
}
Ok(Self {
time_scale,
series: build_series(by_sat),
})
}
pub fn parse_lossy(text: &str) -> Self {
let time_scale = parse_time_scale(text).unwrap_or(TimeScale::Gpst);
let lines = data_lines(text);
let mut by_sat = BTreeMap::<String, Vec<(ClockPoint, usize)>>::new();
for (line_number, line) in lines {
if let Ok(Some((sat, point))) = parse_record(line_number, line, time_scale) {
by_sat.entry(sat).or_default().push((point, line_number));
}
}
Self {
time_scale,
series: build_series(by_sat),
}
}
pub fn from_series_rows(rows: Vec<(String, Vec<(f64, f64)>)>) -> Result<Self, RinexClockError> {
let rows = rows
.into_iter()
.map(|(sat, points)| {
validate::require_strictly_increasing(
points.iter().map(|&(gps_seconds, _)| gps_seconds),
"gps_seconds",
)
.map_err(map_manual_order_error)?;
let points = points
.into_iter()
.map(|(gps_seconds, bias_s)| {
validate_finite(bias_s, "bias_s")?;
Ok((gps_seconds_to_instant(gps_seconds), bias_s))
})
.collect::<Result<Vec<_>, RinexClockError>>()?;
Ok((sat, points))
})
.collect::<Result<Vec<_>, RinexClockError>>()?;
Self::from_instant_series_rows(TimeScale::Gpst, rows)
}
pub fn from_instant_series_rows(
time_scale: TimeScale,
rows: Vec<(String, Vec<(Instant, f64)>)>,
) -> Result<Self, RinexClockError> {
let mut series = BTreeMap::new();
for (sat, points) in rows {
let mut indexed = points
.into_iter()
.enumerate()
.map(|(idx, (epoch, bias_s))| {
let point = ClockPoint { epoch, bias_s };
validate_clock_point(point)?;
Ok((point, idx))
})
.collect::<Result<Vec<_>, RinexClockError>>()?;
validate_instant_series_order(&indexed)?;
indexed.sort_by(|(a, ai), (b, bi)| {
compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
});
series.insert(sat, dedup_by_time(indexed));
}
Ok(Self { time_scale, series })
}
pub fn series_rows(&self) -> Vec<(String, Vec<(f64, f64)>)> {
self.series
.iter()
.map(|(sat, points)| {
(
sat.clone(),
points
.iter()
.filter_map(|point| Some((point.gps_seconds()?, point.bias_s)))
.collect(),
)
})
.collect()
}
pub fn instant_series_rows(&self) -> Vec<(String, Vec<(Instant, f64)>)> {
self.series
.iter()
.map(|(sat, points)| {
(
sat.clone(),
points
.iter()
.map(|point| (point.epoch, point.bias_s))
.collect(),
)
})
.collect()
}
pub fn clock_s(
&self,
satellite_id: &str,
epoch: ClockEpoch,
) -> Result<Option<f64>, RinexClockError> {
let epoch = civil_to_clock_instant(
self.time_scale,
epoch.year,
epoch.month,
epoch.day,
epoch.hour,
epoch.minute,
epoch.second,
)
.ok_or_else(|| invalid_input("epoch", "invalid civil clock epoch"))?;
self.clock_s_at_instant(satellite_id, epoch)
}
pub fn clock_s_at_instant(
&self,
satellite_id: &str,
epoch: Instant,
) -> Result<Option<f64>, RinexClockError> {
validate_instant(epoch, "epoch")?;
let Some(records) = self.series.get(satellite_id) else {
return Ok(None);
};
Ok(interpolate(records, epoch))
}
pub fn clock_s_at_gps_seconds(
&self,
satellite_id: &str,
gps_seconds: f64,
) -> Result<Option<f64>, RinexClockError> {
validate_finite(gps_seconds, "gps_seconds")?;
self.clock_s_at_instant(satellite_id, gps_seconds_to_instant(gps_seconds))
}
}
pub fn civil_to_clock_instant(
scale: TimeScale,
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: f64,
) -> Option<Instant> {
let civil = validate::civil_datetime_with_fractional_second_policy(
i64::from(year),
i64::from(month),
i64::from(day),
i64::from(hour),
i64::from(minute),
second,
civil_second_policy_for_time_scale(scale),
)
.ok()?;
civil_microsecond_to_instant(scale, civil).ok()
}
pub fn civil_to_gps_seconds(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: f64,
) -> Option<f64> {
let civil = validate::civil_datetime_with_fractional_second_policy(
i64::from(year),
i64::from(month),
i64::from(day),
i64::from(hour),
i64::from(minute),
second,
validate::CivilSecondPolicy::Continuous,
)
.ok()?;
gps_seconds_from_civil(civil)
}
fn parse_time_scale(text: &str) -> Result<TimeScale, RinexClockError> {
let mut time_scale = TimeScale::Gpst;
for (idx, line) in text.lines().enumerate() {
if line.contains("END OF HEADER") {
break;
}
if line.contains("TIME SYSTEM ID") {
let label = line
.split("TIME SYSTEM ID")
.next()
.unwrap_or(line)
.split_whitespace()
.next()
.unwrap_or("");
if label.is_empty() {
time_scale = TimeScale::Gpst;
} else {
time_scale = crate::parse::time_scale_label(label).ok_or_else(|| {
RinexClockError::BadField {
line: idx + 1,
field: "time_system",
value: label.to_string(),
}
})?;
}
}
}
Ok(time_scale)
}
fn gps_seconds_to_instant(gps_seconds: f64) -> Instant {
let gps_epoch_jd = J2000_JD - GPS_EPOCH_TO_J2000_S / SECONDS_PER_DAY;
let days = (gps_seconds / SECONDS_PER_DAY).floor();
let seconds_of_day = gps_seconds - days * SECONDS_PER_DAY;
Instant::from_julian_date(
TimeScale::Gpst,
JulianDateSplit::new(gps_epoch_jd + days, seconds_of_day / SECONDS_PER_DAY)
.expect("valid split Julian date"),
)
}
fn validate_clock_point(point: ClockPoint) -> Result<(), RinexClockError> {
validate_instant(point.epoch, "epoch")?;
validate_finite(point.bias_s, "bias_s")
}
fn validate_instant(epoch: Instant, field: &'static str) -> Result<(), RinexClockError> {
match epoch.repr {
InstantRepr::JulianDate(split) => {
validate_finite(split.jd_whole, field)?;
validate_finite(split.fraction, field)?;
if !(-1.0..=1.0).contains(&split.fraction) {
return Err(invalid_input(field, "Julian-date fraction out of range"));
}
Ok(())
}
InstantRepr::Nanos(_) => Ok(()),
}
}
fn validate_finite(value: f64, field: &'static str) -> Result<(), RinexClockError> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_input(field, "must be finite"))
}
}
fn invalid_input(field: &'static str, reason: &'static str) -> RinexClockError {
RinexClockError::InvalidInput { field, reason }
}
fn map_manual_order_error(error: FieldError) -> RinexClockError {
match error {
FieldError::NonFinite { field } => invalid_input(field, "must be finite"),
FieldError::OutOfRange { field, .. } => invalid_input(field, "must be strictly increasing"),
_ => invalid_input(error.field(), error.reason()),
}
}
fn validate_instant_series_order(points: &[(ClockPoint, usize)]) -> Result<(), RinexClockError> {
validate::require_strictly_increasing(
points
.iter()
.map(|(point, _)| instant_order_key(&point.epoch)),
"epoch",
)
.map_err(map_manual_order_error)
}
fn instant_order_key(epoch: &Instant) -> f64 {
let offset_s = time_scale_rank(epoch.scale) as f64 * INSTANT_SCALE_ORDER_STRIDE_S;
let instant_s = match epoch.repr {
InstantRepr::JulianDate(split) => {
split.jd_whole * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY
}
InstantRepr::Nanos(nanos) => nanos as f64 / 1.0e9,
};
offset_s + instant_s
}
fn instant_to_gps_seconds(epoch: &Instant) -> Option<f64> {
if epoch.scale != TimeScale::Gpst {
return None;
}
instant_to_j2000_seconds(epoch).map(|seconds| seconds + GPS_EPOCH_TO_J2000_S)
}
fn instant_to_j2000_seconds(epoch: &Instant) -> Option<f64> {
match epoch.repr {
InstantRepr::JulianDate(split) => {
Some((split.jd_whole - J2000_JD) * SECONDS_PER_DAY + split.fraction * SECONDS_PER_DAY)
}
InstantRepr::Nanos(_) => None,
}
}
fn data_lines(text: &str) -> Vec<(usize, &str)> {
drop_header(
text.lines()
.enumerate()
.map(|(idx, line)| (idx + 1, line))
.collect(),
)
}
fn drop_header(lines: Vec<(usize, &str)>) -> Vec<(usize, &str)> {
match lines
.iter()
.position(|(_, line)| line.contains("END OF HEADER"))
{
Some(idx) => lines.into_iter().skip(idx + 1).collect(),
None => lines,
}
}
#[derive(Debug, Clone, Copy)]
struct ClockEpochFields<'a> {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: &'a str,
}
fn parse_record(
line_number: usize,
line: &str,
time_scale: TimeScale,
) -> Result<Option<(String, ClockPoint)>, RinexClockError> {
let fields = line.split_whitespace().collect::<Vec<_>>();
if fields.first() != Some(&"AS") {
return Ok(None);
}
if fields.len() < 10 {
return Err(RinexClockError::MalformedAsRecord {
line: line_number,
reason: "expected at least 10 fields",
record: line.trim().to_string(),
});
}
let sat = validate::strict_gnss_satellite_id(fields[1], "satellite")
.map_err(|error| map_field_error(line_number, error, fields[1]))?
.to_string();
let year = parse_int_field::<i32>(line_number, "year", fields[2])?;
let month = parse_int_field::<u8>(line_number, "month", fields[3])?;
let day = parse_int_field::<u8>(line_number, "day", fields[4])?;
let hour = parse_int_field::<u8>(line_number, "hour", fields[5])?;
let minute = parse_int_field::<u8>(line_number, "minute", fields[6])?;
let epoch = ClockEpochFields {
year,
month,
day,
hour,
minute,
second: fields[7],
};
let bias_s = parse_f64_field(line_number, "bias", fields[9])?;
let epoch = civil_decimal_second_to_instant(time_scale, epoch)
.map_err(|error| map_epoch_error(line_number, error, epoch))?;
Ok(Some((sat, ClockPoint { epoch, bias_s })))
}
fn parse_int_field<T>(
line_number: usize,
field: &'static str,
value: &str,
) -> Result<T, RinexClockError>
where
T: std::str::FromStr,
{
validate::strict_int(value, field).map_err(|error| map_field_error(line_number, error, value))
}
fn parse_f64_field(
line_number: usize,
field: &'static str,
value: &str,
) -> Result<f64, RinexClockError> {
validate::strict_f64(value, field).map_err(|error| map_field_error(line_number, error, value))
}
fn civil_decimal_second_to_instant(
scale: TimeScale,
epoch: ClockEpochFields<'_>,
) -> Result<Instant, FieldError> {
let civil = validate::civil_datetime_with_decimal_second_policy(
i64::from(epoch.year),
i64::from(epoch.month),
i64::from(epoch.day),
i64::from(epoch.hour),
i64::from(epoch.minute),
epoch.second,
civil_second_policy_for_time_scale(scale),
)?;
civil_microsecond_to_instant(scale, civil)
}
fn civil_microsecond_to_instant(
scale: TimeScale,
civil: validate::ValidCivilMicrosecond,
) -> Result<Instant, FieldError> {
let split = civil_microsecond_to_julian_split(scale, civil)?;
Ok(Instant::from_julian_date(scale, split))
}
fn civil_microsecond_to_julian_split(
scale: TimeScale,
civil: validate::ValidCivilMicrosecond,
) -> Result<JulianDateSplit, FieldError> {
if civil.year < 1 {
return Err(FieldError::InvalidCivilDate {
field: "civil datetime",
year: civil.year,
month: i64::from(civil.month),
day: i64::from(civil.day),
});
}
let month = i64::from(civil.month);
let day = i64::from(civil.day);
let a = (14 - month) / 12;
let y = civil.year + 4800 - a;
let m = month + 12 * a - 3;
let jdn = day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32_045;
let jd_whole = jdn as f64 - 0.5;
if scale == TimeScale::Utc && civil.second == 60 {
let remaining_s = 1.0 - civil.microsecond as f64 / 1_000_000.0;
return Ok(
JulianDateSplit::new(jd_whole + 1.0, -remaining_s / SECONDS_PER_DAY)
.expect("valid leap-second split Julian date"),
);
}
let day_seconds = civil.hour as f64 * 3600.0
+ civil.minute as f64 * 60.0
+ civil.second as f64
+ civil.microsecond as f64 / 1_000_000.0;
Ok(
JulianDateSplit::new(jd_whole, day_seconds / SECONDS_PER_DAY)
.expect("valid split Julian date"),
)
}
fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
match scale {
TimeScale::Utc => validate::CivilSecondPolicy::UtcLike,
TimeScale::Tai
| TimeScale::Tt
| TimeScale::Tdb
| TimeScale::Gpst
| TimeScale::Gst
| TimeScale::Bdt => validate::CivilSecondPolicy::Continuous,
}
}
fn gps_seconds_from_civil(civil: validate::ValidCivilMicrosecond) -> Option<f64> {
if civil.year < 1 {
return None;
}
let days = days_since_gps_epoch(civil.year as i32, civil.month as u8, civil.day as u8);
let whole = days as f64 * SECONDS_PER_DAY
+ (i64::from(civil.hour) * 3_600 + i64::from(civil.minute) * 60 + i64::from(civil.second))
as f64;
Some(whole + f64::from(civil.microsecond) / 1_000_000.0)
}
fn map_field_error(line_number: usize, error: FieldError, value: &str) -> RinexClockError {
RinexClockError::BadField {
line: line_number,
field: error.field(),
value: value.to_string(),
}
}
fn map_epoch_error(
line_number: usize,
error: FieldError,
epoch: ClockEpochFields<'_>,
) -> RinexClockError {
match error {
FieldError::FloatParse { .. }
| FieldError::Missing { .. }
| FieldError::NonFinite { .. } => RinexClockError::BadField {
line: line_number,
field: "second",
value: epoch.second.to_string(),
},
_ => RinexClockError::BadField {
line: line_number,
field: "epoch",
value: format!(
"{} {} {} {} {} {}",
epoch.year,
epoch.month,
epoch.day,
epoch.hour,
epoch.minute,
normalized_second_text(epoch.second)
),
},
}
}
fn normalized_second_text(second: &str) -> String {
validate::strict_f64(second, "second")
.map_or_else(|_| second.to_string(), |value| value.to_string())
}
fn build_series(
by_sat: BTreeMap<String, Vec<(ClockPoint, usize)>>,
) -> BTreeMap<String, Vec<ClockPoint>> {
by_sat
.into_iter()
.map(|(sat, mut points)| {
points.sort_by(|(a, ai), (b, bi)| {
compare_instants(&a.epoch, &b.epoch).then_with(|| ai.cmp(bi))
});
(sat, dedup_by_time(points))
})
.collect()
}
fn dedup_by_time(points: Vec<(ClockPoint, usize)>) -> Vec<ClockPoint> {
let mut deduped = Vec::<ClockPoint>::new();
for (point, _) in points {
match deduped.last_mut() {
Some(prev) if prev.epoch == point.epoch => *prev = point,
_ => deduped.push(point),
}
}
deduped
}
fn interpolate(records: &[ClockPoint], epoch: Instant) -> Option<f64> {
let mut prev: Option<ClockPoint> = None;
for point in records {
match compare_instants_same_scale(&point.epoch, &epoch)? {
Ordering::Equal => return Some(point.bias_s),
Ordering::Greater => {
let p0 = prev?;
let p1 = *point;
let span_s = seconds_between(&p1.epoch, &p0.epoch)?;
if span_s <= 0.0 {
return None;
}
let query_s = seconds_between(&epoch, &p0.epoch)?;
if query_s < 0.0 {
return None;
}
return Some(p0.bias_s + (p1.bias_s - p0.bias_s) * query_s / span_s);
}
Ordering::Less => prev = Some(*point),
}
}
None
}
fn compare_instants(a: &Instant, b: &Instant) -> Ordering {
time_scale_rank(a.scale)
.cmp(&time_scale_rank(b.scale))
.then_with(|| match (a.julian_date(), b.julian_date()) {
(Some(a), Some(b)) => compare_julian_splits(a, b),
_ => Ordering::Equal,
})
}
fn compare_instants_same_scale(a: &Instant, b: &Instant) -> Option<Ordering> {
if a.scale != b.scale {
return None;
}
Some(compare_julian_splits(a.julian_date()?, b.julian_date()?))
}
fn compare_julian_splits(a: JulianDateSplit, b: JulianDateSplit) -> Ordering {
a.jd_whole
.partial_cmp(&b.jd_whole)
.unwrap_or(Ordering::Equal)
.then_with(|| {
a.fraction
.partial_cmp(&b.fraction)
.unwrap_or(Ordering::Equal)
})
}
fn seconds_between(later: &Instant, earlier: &Instant) -> Option<f64> {
if later.scale != earlier.scale {
return None;
}
let later = later.julian_date()?;
let earlier = earlier.julian_date()?;
let seconds = ((later.jd_whole - earlier.jd_whole) + (later.fraction - earlier.fraction))
* SECONDS_PER_DAY;
seconds.is_finite().then_some(seconds)
}
fn time_scale_rank(scale: TimeScale) -> u8 {
match scale {
TimeScale::Utc => 0,
TimeScale::Tai => 1,
TimeScale::Tt => 2,
TimeScale::Tdb => 3,
TimeScale::Gpst => 4,
TimeScale::Gst => 5,
TimeScale::Bdt => 6,
}
}
fn days_since_gps_epoch(year: i32, month: u8, day: u8) -> i64 {
days_before_date(year, month, day) - days_before_date(1980, 1, 6)
}
fn days_before_date(year: i32, month: u8, day: u8) -> i64 {
let mut days = days_before_year(year);
for m in 1..month {
days += i64::from(days_in_month(year, m));
}
days + i64::from(day - 1)
}
fn days_before_year(year: i32) -> i64 {
let y = i64::from(year - 1);
y * 365 + y / 4 - y / 100 + y / 400
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn as_record(satellite: &str, bias: &str) -> String {
format!("AS {satellite} 2020 01 01 00 00 00.000000 1 {bias}")
}
#[test]
fn parse_rejects_non_finite_as_bias() {
let err = RinexClock::parse(&as_record("G01", "NaN")).unwrap_err();
assert_eq!(
err,
RinexClockError::BadField {
line: 1,
field: "bias",
value: "NaN".to_string(),
}
);
}
#[test]
fn parse_rejects_malformed_as_satellite_token() {
let err = RinexClock::parse(&as_record("X01", "1.0e-9")).unwrap_err();
assert_eq!(
err,
RinexClockError::BadField {
line: 1,
field: "satellite",
value: "X01".to_string(),
}
);
}
#[test]
fn explicit_utc_time_system_preserves_clock_epoch_scale() {
let text = " 3.00 C RINEX VERSION / TYPE\n\
UTC TIME SYSTEM ID\n\
END OF HEADER\n\
AS G05 2017 01 01 00 00 0.000000 1 1.0e-04\n\
AS G05 2017 01 01 00 00 30.000000 1 2.0e-04\n";
let clock = RinexClock::parse(text).expect("UTC RINEX clock");
assert_eq!(clock.time_scale, TimeScale::Utc);
assert_eq!(clock.series["G05"][0].epoch.scale, TimeScale::Utc);
let interpolated = clock
.clock_s(
"G05",
ClockEpoch {
year: 2017,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 15.0,
},
)
.expect("valid clock query")
.expect("UTC interpolated clock");
assert!((interpolated - 1.5e-4).abs() < 1.0e-18);
let gpst_query =
civil_to_clock_instant(TimeScale::Gpst, 2017, 1, 1, 0, 0, 15.0).expect("GPST instant");
assert_eq!(
clock
.clock_s_at_instant("G05", gpst_query)
.expect("valid clock query"),
None
);
let rows = clock.instant_series_rows();
assert_eq!(rows[0].1[0].0.scale, TimeScale::Utc);
let rebuilt = RinexClock::from_instant_series_rows(clock.time_scale, rows)
.expect("valid manual RINEX clock rows");
assert_eq!(rebuilt, clock);
}
#[test]
fn manual_series_rows_reject_non_finite_inputs() {
assert_eq!(
RinexClock::from_series_rows(vec![("G05".to_string(), vec![(f64::NAN, 1.0e-4)])])
.unwrap_err(),
RinexClockError::InvalidInput {
field: "gps_seconds",
reason: "must be finite",
}
);
assert_eq!(
RinexClock::from_series_rows(vec![(
"G05".to_string(),
vec![(1_463_904_000.0, f64::INFINITY)]
)])
.unwrap_err(),
RinexClockError::InvalidInput {
field: "bias_s",
reason: "must be finite",
}
);
}
#[test]
fn manual_series_rows_reject_unsorted_gps_seconds() {
assert_eq!(
RinexClock::from_series_rows(vec![(
"G05".to_string(),
vec![(1_463_904_030.0, 1.0e-4), (1_463_904_000.0, 2.0e-4)]
)])
.unwrap_err(),
RinexClockError::InvalidInput {
field: "gps_seconds",
reason: "must be strictly increasing",
}
);
}
#[test]
fn manual_instant_rows_reject_non_finite_inputs() {
let bad_epoch = Instant::from_julian_date(
TimeScale::Gpst,
JulianDateSplit {
jd_whole: f64::NAN,
fraction: 0.0,
},
);
assert_eq!(
RinexClock::from_instant_series_rows(
TimeScale::Gpst,
vec![("G05".to_string(), vec![(bad_epoch, 1.0e-4)])],
)
.unwrap_err(),
RinexClockError::InvalidInput {
field: "epoch",
reason: "must be finite",
}
);
let good_epoch =
civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("GPST instant");
assert_eq!(
RinexClock::from_instant_series_rows(
TimeScale::Gpst,
vec![("G05".to_string(), vec![(good_epoch, f64::NAN)])],
)
.unwrap_err(),
RinexClockError::InvalidInput {
field: "bias_s",
reason: "must be finite",
}
);
}
#[test]
fn manual_instant_rows_reject_unsorted_epochs() {
let later =
civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 30.0).expect("later epoch");
let earlier =
civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 0.0).expect("earlier epoch");
assert_eq!(
RinexClock::from_instant_series_rows(
TimeScale::Gpst,
vec![("G05".to_string(), vec![(later, 1.0e-4), (earlier, 2.0e-4)])],
)
.unwrap_err(),
RinexClockError::InvalidInput {
field: "epoch",
reason: "must be strictly increasing",
}
);
}
#[test]
fn rinex_clock_queries_reject_non_finite_inputs() {
let clock = RinexClock::from_series_rows(vec![(
"G05".to_string(),
vec![(1_463_904_000.0, 1.0e-4)],
)])
.expect("valid manual RINEX clock rows");
let bad_epoch = Instant::from_julian_date(
TimeScale::Gpst,
JulianDateSplit {
jd_whole: f64::INFINITY,
fraction: 0.0,
},
);
assert_eq!(
clock.clock_s_at_instant("G05", bad_epoch).unwrap_err(),
RinexClockError::InvalidInput {
field: "epoch",
reason: "must be finite",
}
);
assert_eq!(
clock.clock_s_at_gps_seconds("G05", f64::NAN).unwrap_err(),
RinexClockError::InvalidInput {
field: "gps_seconds",
reason: "must be finite",
}
);
assert_eq!(
clock
.clock_s(
"G05",
ClockEpoch {
year: 2026,
month: 5,
day: 13,
hour: 0,
minute: 0,
second: f64::NAN,
},
)
.unwrap_err(),
RinexClockError::InvalidInput {
field: "epoch",
reason: "invalid civil clock epoch",
}
);
}
#[test]
fn interpolation_rejects_non_positive_bracket_span() {
let day = 2_457_753.5;
let p0 = Instant::from_julian_date(
TimeScale::Utc,
JulianDateSplit::new(day, 1.0).expect("valid split Julian date"),
);
let p1 = Instant::from_julian_date(
TimeScale::Utc,
JulianDateSplit::new(day + 1.0, 0.0).expect("valid split Julian date"),
);
let query = Instant::from_julian_date(
TimeScale::Utc,
JulianDateSplit::new(day + 1.0, 0.5 / SECONDS_PER_DAY)
.expect("valid split Julian date"),
);
let records = [
ClockPoint {
epoch: p0,
bias_s: 1.0e-4,
},
ClockPoint {
epoch: p1,
bias_s: 2.0e-4,
},
];
assert_eq!(interpolate(&records, query), None);
}
}