#![allow(
clippy::many_single_char_names,
clippy::doc_markdown,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
use std::collections::BTreeMap;
pub use spacelab_core::{Itrs, ReferenceFrame};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SatId {
pub system: char,
pub prn: u8,
}
pub(crate) fn jdn(y: i64, m: i64, d: i64) -> i64 {
let a = (14 - m) / 12;
let yy = y + 4800 - a;
let mm = m + 12 * a - 3;
d + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32045
}
pub(crate) fn parse_sat_id(tok: &str) -> Option<SatId> {
let mut chars = tok.chars();
let system = chars.next()?;
let prn = tok.get(1..)?.trim().parse().ok()?;
Some(SatId { system, prn })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimeSystem {
#[default]
Gps,
Glo,
Gal,
Tai,
Utc,
Qzs,
Bdt,
Irn,
}
impl TimeSystem {
#[must_use]
pub const fn tai_offset_seconds(self) -> Option<f64> {
match self {
Self::Gps | Self::Gal | Self::Qzs | Self::Irn => Some(19.0),
Self::Bdt => Some(33.0), Self::Tai => Some(0.0),
Self::Glo | Self::Utc => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Version {
#[default]
Unstated,
A,
B,
C,
D,
Unrecognised(char),
}
impl Version {
fn from_char(c: char) -> Self {
match c {
'a' => Self::A,
'b' => Self::B,
'c' => Self::C,
'd' => Self::D,
other => Self::Unrecognised(other),
}
}
}
pub(crate) fn parse_reference_frame(tok: &str) -> ReferenceFrame {
match tok.trim() {
"" | "ccccc" => ReferenceFrame::Unstated,
"ITR08" | "ITRF08" | "ITRF2008" => ReferenceFrame::Itrs(Itrs::Itrf2008),
"ITR14" | "ITRF14" | "ITRF2014" => ReferenceFrame::Itrs(Itrs::Itrf2014),
"ITR20" | "ITRF20" | "ITRF2020" => ReferenceFrame::Itrs(Itrs::Itrf2020),
"IGS08" | "IGb08" | "IGB08" => ReferenceFrame::Itrs(Itrs::Igs08),
"IGS14" | "IGb14" | "IGB14" => ReferenceFrame::Itrs(Itrs::Igs14),
"IGS20" | "IGb20" | "IGB20" | "IGc20" | "IGC20" => ReferenceFrame::Itrs(Itrs::Igs20),
"SLR20" | "SLRF20" | "SLRF2020" => ReferenceFrame::Itrs(Itrs::Slrf2020),
"PZ90" | "PZ-90" | "PZ9011" => ReferenceFrame::Pz90,
"WGS84" | "WGS-84" => ReferenceFrame::Wgs84,
_ => ReferenceFrame::Unrecognised,
}
}
pub(crate) fn parse_time_system(tok: &str) -> Option<TimeSystem> {
Some(match tok {
"GPS" | "ccc" => TimeSystem::Gps, "GLO" => TimeSystem::Glo,
"GAL" => TimeSystem::Gal,
"TAI" => TimeSystem::Tai,
"UTC" => TimeSystem::Utc,
"QZS" => TimeSystem::Qzs,
"BDT" => TimeSystem::Bdt,
"IRN" => TimeSystem::Irn,
_ => return None,
})
}
pub(crate) const JDN_SECONDS_AT_J2000: f64 = 2_451_545.0 * 86_400.0 + 43_200.0;
#[derive(Debug, Clone, Default)]
pub struct Sp3 {
start_epoch: Option<f64>,
time_system: TimeSystem,
reference_frame: ReferenceFrame,
version: Version,
epochs: Vec<f64>,
positions: BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
clocks: BTreeMap<SatId, Vec<Option<f64>>>,
velocities: BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
}
const BAD_CLOCK_US: f64 = 999_999.0;
fn set_sample<T: Copy>(
map: &mut BTreeMap<SatId, Vec<Option<T>>>,
id: SatId,
ei: usize,
n_epochs: usize,
val: T,
) {
let series = map.entry(id).or_insert_with(|| vec![None; n_epochs]);
if series.len() < n_epochs {
series.resize(n_epochs, None);
}
series[ei] = Some(val);
}
fn neville(xs: &[f64], ys: &[f64], t: f64) -> f64 {
let n = xs.len();
let mut c = ys.to_vec();
for k in 1..n {
for i in (k..n).rev() {
c[i] = ((t - xs[i - k]) * c[i] - (t - xs[i]) * c[i - 1]) / (xs[i] - xs[i - k]);
}
}
c[n - 1]
}
fn window(epochs: &[f64], t: f64, order: usize) -> Option<(usize, usize)> {
if epochs.len() < 2 {
return None;
}
let want = (order + 1).min(epochs.len());
let nearest = epochs
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| libm::fabs(**a - t).total_cmp(&libm::fabs(**b - t)))
.map(|(i, _)| i)?;
let start = nearest.saturating_sub(want / 2).min(epochs.len() - want);
Some((start, want))
}
impl Sp3 {
pub fn satellites(&self) -> impl Iterator<Item = SatId> + '_ {
self.positions.keys().copied()
}
#[must_use]
pub fn epoch_count(&self) -> usize {
self.epochs.len()
}
#[must_use]
pub fn epochs(&self) -> &[f64] {
&self.epochs
}
#[must_use]
pub fn positions(&self, sat: SatId) -> Option<&[Option<[f64; 3]>]> {
self.positions.get(&sat).map(Vec::as_slice)
}
#[must_use]
pub fn start_epoch(&self) -> Option<f64> {
self.start_epoch
}
#[must_use]
pub const fn version(&self) -> Version {
self.version
}
#[must_use]
pub const fn reference_frame(&self) -> ReferenceFrame {
self.reference_frame
}
#[must_use]
pub fn time_system(&self) -> TimeSystem {
self.time_system
}
#[must_use]
pub fn parse(text: &str) -> Self {
let mut epochs: Vec<f64> = Vec::new();
let mut positions: BTreeMap<SatId, Vec<Option<[f64; 3]>>> = BTreeMap::new();
let mut clocks: BTreeMap<SatId, Vec<Option<f64>>> = BTreeMap::new();
let mut velocities: BTreeMap<SatId, Vec<Option<[f64; 3]>>> = BTreeMap::new();
let mut t0: Option<f64> = None;
let mut time_system: Option<TimeSystem> = None;
let mut reference_frame = ReferenceFrame::Unstated;
let mut version = Version::Unstated;
for line in text.lines() {
if line.starts_with('#') && !line.starts_with("##") {
if reference_frame == ReferenceFrame::Unstated {
reference_frame = line
.get(46..51)
.map_or(ReferenceFrame::Unstated, parse_reference_frame);
}
if version == Version::Unstated {
version = line
.chars()
.nth(1)
.map_or(Version::Unstated, Version::from_char);
}
} else if line.starts_with("%c") {
if time_system.is_none() {
time_system = line.get(9..12).and_then(parse_time_system);
}
} else if let Some(rest) = line.strip_prefix('*') {
let f: Vec<f64> = rest
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
if f.len() < 6 {
continue;
}
let (y, m, d) = (f[0] as i64, f[1] as i64, f[2] as i64);
let secs = (jdn(y, m, d) as f64) * 86_400.0 + f[3] * 3600.0 + f[4] * 60.0 + f[5];
let t = *t0.get_or_insert(secs);
epochs.push(secs - t);
for s in positions.values_mut() {
s.push(None);
}
for s in clocks.values_mut() {
s.push(None);
}
for s in velocities.values_mut() {
s.push(None);
}
} else if line.starts_with('P') || line.starts_with('V') {
let idx = epochs.len().checked_sub(1);
let (Some(ei), Some(id)) = (idx, parse_sat_id(line.get(1..4).unwrap_or("").trim()))
else {
continue;
};
let vals: Vec<f64> = line[4..]
.split_whitespace()
.filter_map(|t| t.parse().ok())
.collect();
if vals.len() < 3 {
continue;
}
let n = epochs.len();
if line.starts_with('P') {
set_sample(
&mut positions,
id,
ei,
n,
[vals[0] * 1000.0, vals[1] * 1000.0, vals[2] * 1000.0],
);
if let Some(&clk_us) = vals.get(3)
&& libm::fabs(clk_us) < BAD_CLOCK_US
{
set_sample(&mut clocks, id, ei, n, clk_us * 1.0e-6);
}
} else {
set_sample(
&mut velocities,
id,
ei,
n,
[vals[0] * 0.1, vals[1] * 0.1, vals[2] * 0.1],
);
}
}
}
Sp3 {
start_epoch: t0.map(|t| t - JDN_SECONDS_AT_J2000),
time_system: time_system.unwrap_or_default(),
reference_frame,
version,
epochs,
positions,
clocks,
velocities,
}
}
#[must_use]
pub fn interpolate(&self, sat: SatId, t: f64, order: usize) -> Option<[f64; 3]> {
self.interp_vec3(&self.positions, sat, t, order)
}
#[must_use]
pub fn interpolate_velocity(&self, sat: SatId, t: f64, order: usize) -> Option<[f64; 3]> {
self.interp_vec3(&self.velocities, sat, t, order)
}
#[must_use]
pub fn interpolate_clock(&self, sat: SatId, t: f64, order: usize) -> Option<f64> {
let series = self.clocks.get(&sat)?;
let pts: Vec<(f64, f64)> = self
.epochs
.iter()
.zip(series)
.filter_map(|(&e, c)| c.map(|v| (e, v)))
.collect();
let epochs: Vec<f64> = pts.iter().map(|(e, _)| *e).collect();
let (start, want) = window(&epochs, t, order)?;
let ys: Vec<f64> = pts[start..start + want].iter().map(|(_, v)| *v).collect();
Some(neville(&epochs[start..start + want], &ys, t))
}
fn interp_vec3(
&self,
map: &BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
sat: SatId,
t: f64,
order: usize,
) -> Option<[f64; 3]> {
let series = map.get(&sat)?;
let pts: Vec<(f64, [f64; 3])> = self
.epochs
.iter()
.zip(series)
.filter_map(|(&e, p)| p.map(|xyz| (e, xyz)))
.collect();
let epochs: Vec<f64> = pts.iter().map(|(e, _)| *e).collect();
let (start, want) = window(&epochs, t, order)?;
let xs = &epochs[start..start + want];
let mut out = [0.0; 3];
for axis in 0..3 {
let ys: Vec<f64> = pts[start..start + want]
.iter()
.map(|(_, p)| p[axis])
.collect();
out[axis] = neville(xs, &ys, t);
}
Some(out)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::format_push_string)]
use super::*;
fn cubic(t: f64) -> [f64; 3] {
[
1000.0 + 2.0 * t - 0.001 * t * t + 1.0e-7 * t * t * t,
-500.0 + 1.5 * t + 0.002 * t * t,
300.0 - 0.5 * t + 5.0e-8 * t * t * t,
]
}
fn cubic_velocity(t: f64) -> [f64; 3] {
[
2.0 - 0.002 * t + 3.0e-7 * t * t,
1.5 + 0.004 * t,
-0.5 + 1.5e-7 * t * t,
]
}
fn clock_us(t: f64) -> f64 {
1.0 + 2.0e-3 * t
}
#[test]
fn the_header_names_the_frame_and_each_real_product_names_a_different_one() {
for (line, want) in [
(
"#cP2025 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS",
ReferenceFrame::Itrs(Itrs::Igs20),
),
(
"#cP2026 8 12 0 0 0.00000000 96 ORBIT IGc20 HLM IGS",
ReferenceFrame::Itrs(Itrs::Igs20),
),
(
"#cV2025 1 12 0 0 0.00000000 5040 SLR SLR20 FIT COMB",
ReferenceFrame::Itrs(Itrs::Slrf2020),
),
(
"#cP2025 1 1 0 0 0.00000000 96 ORBIT PZ90 HLM IAC",
ReferenceFrame::Pz90,
),
] {
assert_eq!(Sp3::parse(line).reference_frame(), want, "{line}");
}
}
#[test]
fn every_recognised_label_reduces_to_its_own_solution() {
use Itrs::{Igs08, Igs14, Igs20, Itrf2008, Itrf2014, Itrf2020, Slrf2020};
for (labels, want, itrf) in [
(&["ITR08", "ITRF08", "ITRF2008"][..], Itrf2008, Itrf2008),
(&["ITR14", "ITRF14", "ITRF2014"][..], Itrf2014, Itrf2014),
(&["ITR20", "ITRF20", "ITRF2020"][..], Itrf2020, Itrf2020),
(&["IGS08", "IGb08", "IGB08"][..], Igs08, Itrf2008),
(&["IGS14", "IGb14", "IGB14"][..], Igs14, Itrf2014),
(
&["IGS20", "IGb20", "IGB20", "IGc20", "IGC20"][..],
Igs20,
Itrf2020,
),
(&["SLR20", "SLRF20", "SLRF2020"][..], Slrf2020, Itrf2020),
] {
for label in labels {
let got = parse_reference_frame(label);
assert_eq!(got, ReferenceFrame::Itrs(want), "{label}");
assert_eq!(got.itrs().map(Itrs::itrf), Some(itrf), "{label}");
assert_eq!(parse_reference_frame(&format!("{label} ")), got, "{label} ");
}
}
for label in ["PZ90", "PZ-90", "PZ9011"] {
assert_eq!(
parse_reference_frame(label),
ReferenceFrame::Pz90,
"{label}"
);
}
for label in ["WGS84", "WGS-84"] {
assert_eq!(
parse_reference_frame(label),
ReferenceFrame::Wgs84,
"{label}"
);
}
for label in ["", "ccccc", " "] {
assert_eq!(
parse_reference_frame(label),
ReferenceFrame::Unstated,
"{label:?}"
);
}
for label in ["ITRF", "IGS", "IGS21", "GTRF", "PZ", "cccc"] {
assert_eq!(
parse_reference_frame(label),
ReferenceFrame::Unrecognised,
"{label}"
);
}
}
#[test]
fn a_file_without_a_header_line_leaves_the_frame_unstated() {
let no_header = "* 2024 1 9 0 0 0.00000000\nPG01 1.0 2.0 3.0 4.0\n";
assert_eq!(
Sp3::parse(no_header).reference_frame(),
ReferenceFrame::Unstated
);
assert_eq!(
Sp3::parse("## 2431 259200.00000000 900.00000000 61264 0.0000000000000\n")
.reference_frame(),
ReferenceFrame::Unstated
);
assert_eq!(
Sp3::parse("#cP2025\n").reference_frame(),
ReferenceFrame::Unstated
);
}
fn synthetic_sp3() -> String {
let mut s = String::from("#dV2024 1 9 0 0 0.00000000 5 ORBIT IGS20 HLM IGS\n");
for k in 0..5 {
let sec = f64::from(k * 900);
let (mm, ss) = ((k * 15) % 60, 0);
let hh = (k * 15) / 60;
s.push_str(&format!("* 2024 1 9 {hh:2} {mm} {ss}.00000000\n"));
let p = cubic(sec);
s.push_str(&format!(
"PG01 {:13.6} {:13.6} {:13.6} {:13.6}\n",
p[0],
p[1],
p[2],
clock_us(sec)
));
let v = cubic_velocity(sec); s.push_str(&format!(
"VG01 {:13.6} {:13.6} {:13.6} {:13.6}\n",
v[0] * 1.0e4,
v[1] * 1.0e4,
v[2] * 1.0e4,
0.0
));
}
s.push_str("EOF\n");
s
}
#[test]
fn parses_epochs_and_satellites() {
let sp3 = Sp3::parse(&synthetic_sp3());
assert_eq!(sp3.epoch_count(), 5);
let sats: Vec<SatId> = sp3.satellites().collect();
assert_eq!(
sats,
vec![SatId {
system: 'G',
prn: 1
}]
);
}
#[test]
fn the_accessors_hand_back_the_grid_and_the_samples_the_file_carries() {
let sp3 = Sp3::parse(&synthetic_sp3());
assert_eq!(
sp3.epochs(),
[0.0, 900.0, 1800.0, 2700.0, 3600.0].as_slice()
);
let g01 = SatId {
system: 'G',
prn: 1,
};
let pos = sp3.positions(g01).unwrap();
assert_eq!(pos.len(), sp3.epochs().len());
for (k, sample) in pos.iter().enumerate() {
let want = cubic(sp3.epochs()[k]);
let got = sample.unwrap();
for axis in 0..3 {
assert!(
(got[axis] - want[axis] * 1000.0).abs() < 1e-3,
"epoch {k} axis {axis}: {}",
got[axis]
);
}
}
assert!(
sp3.positions(SatId {
system: 'R',
prn: 7
})
.is_none()
);
}
#[test]
fn interpolation_reproduces_a_cubic_exactly() {
let sp3 = Sp3::parse(&synthetic_sp3());
let g01 = SatId {
system: 'G',
prn: 1,
};
let got = sp3.interpolate(g01, 1350.0, 3).unwrap();
let want = cubic(1350.0);
for axis in 0..3 {
let want_m = want[axis] * 1000.0;
assert!(
(got[axis] - want_m).abs() < 1e-3,
"axis {axis}: {} vs {want_m}",
got[axis]
);
}
}
#[test]
fn the_start_epoch_is_seconds_since_j2000_in_the_files_own_scale() {
let sp3 = Sp3::parse(&synthetic_sp3());
assert_eq!(sp3.start_epoch(), Some(758_030_400.0));
assert_eq!(sp3.time_system(), TimeSystem::Gps);
let igs = "#cP2025 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS\n\
## 2347 259200.00000000 900.00000000 60676 0.0000000000000\n\
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
* 2025 1 1 0 0 0.00000000\n\
PG01 15931.689402 2160.462672 21149.136216 999999.999999\n";
let sp3 = Sp3::parse(igs);
assert_eq!(sp3.start_epoch(), Some(788_961_600.0));
assert_eq!(sp3.time_system(), TimeSystem::Gps);
assert_eq!(sp3.time_system().tai_offset_seconds(), Some(19.0));
}
#[test]
fn the_time_system_is_taken_from_the_first_c_record_and_the_filler_cannot_undo_it() {
let glo = "#cP2025 1 1 0 0 0.00000000 96 ORBIT PZ90 HLM IAC\n\
%c R cc GLO ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
* 2025 1 1 0 0 0.00000000\n\
PR01 15931.689402 2160.462672 21149.136216 999999.999999\n";
let sp3 = Sp3::parse(glo);
assert_eq!(sp3.time_system(), TimeSystem::Glo);
assert_eq!(sp3.time_system().tai_offset_seconds(), None);
assert_eq!(Sp3::parse("#cP2025 1 1 0 0 0.0\n").start_epoch(), None);
}
#[test]
fn every_time_system_token_maps_to_its_own_scale() {
for (tok, want, tai) in [
("GPS", TimeSystem::Gps, Some(19.0)),
("ccc", TimeSystem::Gps, Some(19.0)),
("GLO", TimeSystem::Glo, None),
("GAL", TimeSystem::Gal, Some(19.0)),
("TAI", TimeSystem::Tai, Some(0.0)),
("UTC", TimeSystem::Utc, None),
("QZS", TimeSystem::Qzs, Some(19.0)),
("BDT", TimeSystem::Bdt, Some(33.0)),
("IRN", TimeSystem::Irn, Some(19.0)),
] {
assert_eq!(parse_time_system(tok), Some(want), "token {tok}");
assert_eq!(want.tai_offset_seconds(), tai, "offset for {tok}");
}
assert_eq!(parse_time_system("GST"), None);
assert_eq!(parse_time_system("UT1"), None);
assert_eq!(parse_time_system(""), None);
}
#[test]
fn interpolation_passes_through_the_sample_points() {
let sp3 = Sp3::parse(&synthetic_sp3());
let g01 = SatId {
system: 'G',
prn: 1,
};
let got = sp3.interpolate(g01, 900.0, 3).unwrap(); let want = cubic(900.0);
for axis in 0..3 {
assert!((got[axis] - want[axis] * 1000.0).abs() < 1e-3);
}
}
#[test]
fn interpolation_recovers_the_clock_and_velocity() {
let sp3 = Sp3::parse(&synthetic_sp3());
let g01 = SatId {
system: 'G',
prn: 1,
};
let clk = sp3.interpolate_clock(g01, 1350.0, 3).unwrap();
assert!(
(clk - clock_us(1350.0) * 1.0e-6).abs() < 1e-15,
"clock {clk}"
);
let vel = sp3.interpolate_velocity(g01, 1350.0, 3).unwrap();
let want = cubic_velocity(1350.0);
for axis in 0..3 {
let want_ms = want[axis] * 1000.0;
assert!(
(vel[axis] - want_ms).abs() < 1e-6,
"axis {axis}: {} vs {want_ms}",
vel[axis]
);
}
}
#[test]
fn a_position_only_file_has_no_velocity_and_the_clock_sentinel_is_skipped() {
let text = "\
#dP2024 1 9 0 0 0.00000000 2 ORBIT IGS20 HLM IGS\n\
* 2024 1 9 0 0 0.00000000\n\
PG01 16000.000000 -5000.000000 20000.000000 999999.999999\n\
* 2024 1 9 0 15 0.00000000\n\
PG01 16100.000000 -5100.000000 20050.000000 999999.999999\n\
EOF\n";
let sp3 = Sp3::parse(text);
let g01 = SatId {
system: 'G',
prn: 1,
};
assert!(sp3.interpolate(g01, 100.0, 1).is_some()); assert!(sp3.interpolate_velocity(g01, 100.0, 1).is_none()); assert!(sp3.interpolate_clock(g01, 100.0, 1).is_none()); }
const JDN_CASES: [(i64, i64, i64, i64); 18] = [
(1, 1, 1, 1_721_426),
(1582, 10, 15, 2_299_161),
(1858, 11, 17, 2_400_001),
(1900, 2, 28, 2_415_079),
(1900, 3, 1, 2_415_080), (1999, 12, 31, 2_451_544),
(2000, 1, 1, 2_451_545),
(2000, 2, 29, 2_451_604),
(2000, 3, 1, 2_451_605), (2024, 1, 9, 2_460_319),
(2024, 1, 10, 2_460_320),
(2024, 2, 28, 2_460_369),
(2024, 2, 29, 2_460_370),
(2024, 3, 1, 2_460_371), (2024, 12, 31, 2_460_676),
(2025, 1, 1, 2_460_677),
(2100, 2, 28, 2_488_128),
(2100, 3, 1, 2_488_129),
];
#[test]
fn jdn_matches_the_gregorian_calendar_at_every_boundary_that_matters() {
for (y, m, d, want) in JDN_CASES {
assert_eq!(jdn(y, m, d), want, "jdn({y}, {m}, {d})");
}
for w in JDN_CASES.windows(2) {
let (a, b) = (w[0], w[1]);
if b.3 == a.3 + 1 {
assert_eq!(jdn(b.0, b.1, b.2) - jdn(a.0, a.1, a.2), 1);
}
}
}
#[test]
fn the_epoch_grid_spans_a_day_boundary_and_carries_hours_minutes_and_seconds() {
let text = "\
#dP2024 12 31 23 45 30.00000000 3 ORBIT IGS20 HLM IGS\n\
* 2024 12 31 23 45 30.00000000\n\
PG01 16000.000000 -5000.000000 20000.000000\n\
* 2025 1 1 0 0 0.00000000\n\
PG01 16100.000000 -5100.000000 20050.000000\n\
* 2025 1 1 1 2 3.50000000\n\
PG01 16200.000000 -5200.000000 20100.000000\n\
EOF\n";
let sp3 = Sp3::parse(text);
assert_eq!(sp3.epochs, vec![0.0, 870.0, 4593.5], "epoch grid");
}
#[test]
fn the_interpolation_window_slides_with_the_query_time() {
let epochs: Vec<f64> = (0..12).map(|i| f64::from(i) * 100.0).collect();
for (t, want_start) in [
(0.0, 0), (250.0, 0), (450.0, 2), (650.0, 4), (1050.0, 8), (1100.0, 8), ] {
assert_eq!(window(&epochs, t, 3), Some((want_start, 4)), "t = {t}");
}
assert_eq!(window(&epochs, 650.0, 50), Some((0, 12)));
}
#[test]
fn short_records_are_skipped_and_a_clock_at_the_sentinel_is_rejected() {
let text = "\
#dP2025 1 1 0 0 0.00000000 2 ORBIT IGS20 HLM IGS\n\
* 2025 1 1 0 0 0.00000000\n\
PG01 16000.000000 -5000.000000 20000.000000\n\
PG02 16000.000000 -5000.000000 20000.000000 999999.000000\n\
PG03 16000.000000 -5000.000000 20000.000000 999998.000000\n\
* 2025 1 1 0 15\n\
* 2025 1 1 0 15 0.00000000\n\
PG01 16100.000000 -5100.000000 20050.000000\n\
PG02 16100.000000 -5100.000000 20050.000000 999999.000000\n\
PG03 16100.000000 -5100.000000 20050.000000 999998.000000\n\
EOF\n";
let sp3 = Sp3::parse(text);
let sat = |prn| SatId { system: 'G', prn };
assert_eq!(
sp3.epochs,
vec![0.0, 900.0],
"the five-field epoch line is not an epoch"
);
assert!(sp3.interpolate(sat(1), 450.0, 1).is_some());
assert!(sp3.interpolate_clock(sat(1), 450.0, 1).is_none());
assert!(
sp3.interpolate_clock(sat(2), 450.0, 1).is_none(),
"999999 µs is the sentinel"
);
let clk = sp3.interpolate_clock(sat(3), 450.0, 1).unwrap();
assert!(
(clk - 999_998.0e-6).abs() < 1e-15,
"999998 µs is a bias, not a sentinel: {clk}"
);
}
#[test]
fn unknown_satellite_has_no_interpolant() {
let sp3 = Sp3::parse(&synthetic_sp3());
assert!(
sp3.interpolate(
SatId {
system: 'R',
prn: 7
},
500.0,
3
)
.is_none()
);
}
}