use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt::{self, Write as _};
use crate::astro::constants::time::SECONDS_PER_DAY_I64;
use crate::astro::math::interp::lerp_ratio;
use crate::astro::time::civil::{
civil_from_julian_day_number, j2000_seconds_from_split, seconds_between_splits,
J2000_JULIAN_DAY_NUMBER, J2000_NOON_OFFSET_S,
};
use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
use crate::astro::time::scales::julian_day_number;
use crate::constants::{
GPS_EPOCH_TO_J2000_S, J2000_JD, MICROSECONDS_PER_SECOND, SECONDS_PER_DAY, SECONDS_PER_HOUR,
};
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 to_rinex_string(&self) -> String {
let mut out = String::new();
let label = crate::rinex_common::time_scale_rinex_label(self.time_scale);
let _ = writeln!(out, "{:<60}RINEX VERSION / TYPE", " 3.00 C");
let _ = writeln!(out, "{label:<60}TIME SYSTEM ID");
let _ = writeln!(out, "{:<60}END OF HEADER", "");
for (satellite, points) in &self.series {
for point in points {
write_as_record(&mut out, satellite, point);
}
}
out
}
}
fn write_as_record(out: &mut String, satellite: &str, point: &ClockPoint) {
let (year, month, day, hour, minute, second_us) = instant_civil_microsecond(&point.epoch);
let second = second_us / 1_000_000;
let microsecond = second_us % 1_000_000;
let _ = writeln!(
out,
"AS {satellite:<3} {year:04} {month:02} {day:02} {hour:02} {minute:02} {second:2}.{microsecond:06} 1 {bias}",
bias = point.bias_s,
);
}
fn instant_civil_microsecond(epoch: &Instant) -> (i64, i64, i64, i64, i64, i64) {
let (day_number, total_us) = match epoch.repr {
InstantRepr::JulianDate(split) => {
if (-1.0 / SECONDS_PER_DAY..0.0).contains(&split.fraction) {
return leap_second_civil(split);
}
let day_number = (split.jd_whole + 0.5).round() as i64;
let total_us =
(split.fraction * SECONDS_PER_DAY * MICROSECONDS_PER_SECOND).round() as i64;
(day_number, total_us)
}
InstantRepr::Nanos(nanos) => nanos_civil_day_microsecond(nanos),
};
let (year, month, day) = civil_from_julian_day_number(day_number);
let hour = total_us / 3_600_000_000;
let rem = total_us % 3_600_000_000;
let minute = rem / 60_000_000;
let second_us = rem % 60_000_000;
(year, month, day, hour, minute, second_us)
}
fn leap_second_civil(split: JulianDateSplit) -> (i64, i64, i64, i64, i64, i64) {
let next_day_number = (split.jd_whole + 0.5).round() as i64;
let (year, month, day) = civil_from_julian_day_number(next_day_number - 1);
let remaining_s = -split.fraction * SECONDS_PER_DAY; let microsecond = ((1.0 - remaining_s) * 1_000_000.0).round() as i64;
(year, month, day, 23, 59, 60 * 1_000_000 + microsecond)
}
fn nanos_civil_day_microsecond(nanos: i128) -> (i64, i64) {
const US_PER_DAY: i128 = SECONDS_PER_DAY_I64 as i128 * 1_000_000;
const J2000_NOON_US: i128 = J2000_NOON_OFFSET_S as i128 * 1_000_000;
const J2000_DAY_NUMBER: i128 = J2000_JULIAN_DAY_NUMBER as i128;
let micros = (nanos + nanos.signum() * 500) / 1_000; let from_midnight = J2000_NOON_US + micros;
let day_offset = from_midnight.div_euclid(US_PER_DAY);
let us_of_day = from_midnight.rem_euclid(US_PER_DAY);
((J2000_DAY_NUMBER + day_offset) as i64, us_of_day as i64)
}
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::rinex_common::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(j2000_seconds_from_split(split.jd_whole, split.fraction))
}
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 mut fields = line.split_whitespace();
if fields.next() != Some("AS") {
return Ok(None);
}
let sat_field = next_as_field(&mut fields, line_number, line)?;
let year_field = next_as_field(&mut fields, line_number, line)?;
let month_field = next_as_field(&mut fields, line_number, line)?;
let day_field = next_as_field(&mut fields, line_number, line)?;
let hour_field = next_as_field(&mut fields, line_number, line)?;
let minute_field = next_as_field(&mut fields, line_number, line)?;
let second_field = next_as_field(&mut fields, line_number, line)?;
let _value_count_field = next_as_field(&mut fields, line_number, line)?;
let bias_field = next_as_field(&mut fields, line_number, line)?;
let sat = validate::strict_gnss_satellite_id(sat_field, "satellite")
.map_err(|error| map_field_error(line_number, error, sat_field))?
.to_string();
let year = parse_int_field::<i32>(line_number, "year", year_field)?;
let month = parse_int_field::<u8>(line_number, "month", month_field)?;
let day = parse_int_field::<u8>(line_number, "day", day_field)?;
let hour = parse_int_field::<u8>(line_number, "hour", hour_field)?;
let minute = parse_int_field::<u8>(line_number, "minute", minute_field)?;
let epoch = ClockEpochFields {
year,
month,
day,
hour,
minute,
second: second_field,
};
let bias_s = parse_f64_field(line_number, "bias", bias_field)?;
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 next_as_field<'a, I>(
fields: &mut I,
line_number: usize,
line: &str,
) -> Result<&'a str, RinexClockError>
where
I: Iterator<Item = &'a str>,
{
fields
.next()
.ok_or_else(|| RinexClockError::MalformedAsRecord {
line: line_number,
reason: "expected at least 10 fields",
record: line.trim().to_string(),
})
}
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 jdn = julian_day_number(civil.year as i32, civil.month as i32, civil.day as i32);
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 * SECONDS_PER_HOUR
+ 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::Glonasst
| TimeScale::Tai
| TimeScale::Tt
| TimeScale::Tdb
| TimeScale::Gpst
| TimeScale::Gst
| TimeScale::Bdt
| TimeScale::Qzsst => 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(lerp_ratio(p0.bias_s, p1.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 clock_timeline(scale: TimeScale) -> TimeScale {
match scale {
TimeScale::Qzsst => TimeScale::Gpst,
other => other,
}
}
fn compare_instants_same_scale(a: &Instant, b: &Instant) -> Option<Ordering> {
if clock_timeline(a.scale) != clock_timeline(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 clock_timeline(later.scale) != clock_timeline(earlier.scale) {
return None;
}
let later = later.julian_date()?;
let earlier = earlier.julian_date()?;
let seconds = seconds_between_splits(
later.jd_whole,
later.fraction,
earlier.jd_whole,
earlier.fraction,
);
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,
TimeScale::Glonasst => 7,
TimeScale::Qzsst => 8,
}
}
fn days_since_gps_epoch(year: i32, month: u8, day: u8) -> i64 {
julian_day_number(year, i32::from(month), i32::from(day)) - julian_day_number(1980, 1, 6)
}
#[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);
}
#[test]
fn qzsst_rows_are_queryable_on_the_gpst_timeline() {
let p0 = civil_to_clock_instant(TimeScale::Qzsst, 2026, 5, 13, 0, 0, 0.0)
.expect("QZSST instant");
let p1 = civil_to_clock_instant(TimeScale::Qzsst, 2026, 5, 13, 0, 0, 30.0)
.expect("QZSST instant");
let clock = RinexClock::from_instant_series_rows(
TimeScale::Qzsst,
vec![("J02".to_string(), vec![(p0, 1.0e-4), (p1, 3.0e-4)])],
)
.expect("QZSST clock builds");
let mid = civil_to_gps_seconds(2026, 5, 13, 0, 0, 15.0).expect("gps seconds");
let bias = clock
.clock_s_at_gps_seconds("J02", mid)
.expect("query succeeds")
.expect("QZSST row interpolates on the GPST timeline");
assert!(
(bias - 2.0e-4).abs() < 1.0e-12,
"expected midpoint interpolation 2.0e-4, got {bias}"
);
let start = civil_to_gps_seconds(2026, 5, 13, 0, 0, 0.0).expect("gps seconds");
assert_eq!(
clock
.clock_s_at_gps_seconds("J02", start)
.expect("query succeeds"),
Some(1.0e-4)
);
}
#[test]
fn to_rinex_string_round_trips_through_parse() {
let text =
" 3.00 C RINEX VERSION / TYPE\n\
GPS TIME SYSTEM ID\n\
END OF HEADER\n\
AS G05 2026 05 13 00 00 0.000000 1 -2.000000000000e-04\n\
AS G05 2026 05 13 00 00 30.500000 1 -2.000000600000e-04\n\
AS G24 2026 05 13 00 01 0.000000 1 5.000000000000e-05\n\
AS E11 2026 05 13 00 00 0.000000 1 1.234500000000e-09\n";
let clock = RinexClock::parse(text).expect("parse GPST RINEX clock");
let reparsed = RinexClock::parse(&clock.to_rinex_string()).expect("re-parse serialized");
assert_eq!(reparsed, clock, "serializer must round-trip through parse");
assert_eq!(reparsed.to_rinex_string(), clock.to_rinex_string());
}
#[test]
fn to_rinex_string_round_trips_utc_time_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.000000000000e-04\n\
AS G05 2017 01 01 00 00 30.000000 1 2.000000000000e-04\n";
let clock = RinexClock::parse(text).expect("parse UTC RINEX clock");
assert_eq!(clock.time_scale, TimeScale::Utc);
let reparsed = RinexClock::parse(&clock.to_rinex_string()).expect("re-parse serialized");
assert_eq!(reparsed.time_scale, TimeScale::Utc);
assert_eq!(reparsed, clock);
}
#[test]
fn nanos_repr_epoch_serializes_to_true_civil_time() {
let jd_epoch =
civil_to_clock_instant(TimeScale::Gpst, 2026, 5, 13, 0, 0, 30.0).expect("GPST instant");
let j2000_s = instant_to_j2000_seconds(&jd_epoch).expect("J2000 seconds");
let nanos = (j2000_s * 1.0e9).round() as i128;
let nanos_epoch = Instant::from_nanos(TimeScale::Gpst, nanos);
let nanos_clock = RinexClock::from_instant_series_rows(
TimeScale::Gpst,
vec![("G05".to_string(), vec![(nanos_epoch, 1.0e-4)])],
)
.expect("nanos clock builds");
let jd_clock = RinexClock::from_instant_series_rows(
TimeScale::Gpst,
vec![("G05".to_string(), vec![(jd_epoch, 1.0e-4)])],
)
.expect("jd clock builds");
let serialized = nanos_clock.to_rinex_string();
assert!(
serialized.contains("2026 05 13 00 00 30.000000"),
"Nanos epoch must serialize to its true civil time, got:\n{serialized}"
);
assert_eq!(
serialized,
jd_clock.to_rinex_string(),
"Nanos- and Julian-date-repr epochs of the same instant must serialize identically"
);
let reparsed = RinexClock::parse(&serialized).expect("re-parse serialized Nanos product");
assert_eq!(reparsed, jd_clock);
}
#[test]
fn to_rinex_string_round_trips_utc_leap_second_epoch() {
let text =
" 3.00 C RINEX VERSION / TYPE\n\
UTC TIME SYSTEM ID\n\
END OF HEADER\n\
AS G05 2016 12 31 23 59 60.000000 1 1.000000000000e-04\n\
AS G05 2016 12 31 23 59 60.500000 1 2.000000000000e-04\n";
let clock = RinexClock::parse(text).expect("parse UTC leap-second RINEX clock");
let serialized = clock.to_rinex_string();
assert!(
serialized.contains("23 59 60.000000"),
"leap-second label must round-trip, got:\n{serialized}"
);
assert!(
serialized.contains("23 59 60.500000"),
"fractional leap second must round-trip, got:\n{serialized}"
);
let reparsed = RinexClock::parse(&serialized).expect("re-parse serialized leap second");
assert_eq!(
reparsed, clock,
"leap-second epoch must round-trip bit-exact"
);
}
}