#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::doc_markdown,
clippy::module_name_repetitions
)]
use crate::glonass::GlonassEphemeris;
use crate::gps::{BroadcastEphemeris, GnssSystem};
use crate::rinex::{orbit_fields, parse_field, parse_glonass_record, parse_record};
const KM_TO_M: f64 = 1000.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RecordKind {
Eph,
Sto,
Eop,
Ion,
}
impl RecordKind {
fn from_code(code: &str) -> Option<Self> {
Some(match code {
"EPH" => Self::Eph,
"STO" => Self::Sto,
"EOP" => Self::Eop,
"ION" => Self::Ion,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NavMessage {
Lnav,
Fdma,
Fnav,
Inav,
D1,
D2,
Sbas,
Cnav,
Cnv1,
Cnv2,
Cnv3,
L1nv,
L1oc,
L3oc,
Ifnv,
D1d2,
Cnvx,
Lxoc,
}
impl NavMessage {
fn from_code(code: &str) -> Option<Self> {
Some(match code {
"LNAV" => Self::Lnav,
"FDMA" => Self::Fdma,
"FNAV" => Self::Fnav,
"INAV" => Self::Inav,
"D1" => Self::D1,
"D2" => Self::D2,
"SBAS" => Self::Sbas,
"CNAV" => Self::Cnav,
"CNV1" => Self::Cnv1,
"CNV2" => Self::Cnv2,
"CNV3" => Self::Cnv3,
"L1NV" => Self::L1nv,
"L1OC" => Self::L1oc,
"L3OC" => Self::L3oc,
"IFNV" => Self::Ifnv,
"D1D2" => Self::D1d2,
"CNVX" => Self::Cnvx,
"LXOC" => Self::Lxoc,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Subtype {
Wide,
Japn,
Klob,
Neqn,
}
impl Subtype {
fn from_code(code: &str) -> Option<Self> {
Some(match code {
"WIDE" => Self::Wide,
"JAPN" => Self::Japn,
"KLOB" => Self::Klob,
"NEQN" => Self::Neqn,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Header {
kind: RecordKind,
constellation: char,
prn: Option<u8>,
message: NavMessage,
subtype: Option<Subtype>,
}
fn parse_header(line: &str) -> Option<Header> {
if !line.starts_with('>') {
return None;
}
let kind = RecordKind::from_code(line.get(2..5)?.trim())?;
let sat = line.get(6..9)?;
let constellation = sat.chars().next()?;
if !constellation.is_ascii_uppercase() {
return None;
}
let prn = match sat.get(1..3)?.trim() {
"" => None,
digits => Some(digits.parse().ok()?),
};
let message = NavMessage::from_code(line.get(10..14).unwrap_or("").trim())?;
let subtype = line
.get(15..19)
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(Subtype::from_code);
Some(Header {
kind,
constellation,
prn,
message,
subtype,
})
}
#[must_use]
pub fn block_len(kind: RecordKind, message: NavMessage) -> Option<usize> {
use NavMessage as M;
Some(match kind {
RecordKind::Eph => match message {
M::Lnav | M::Fnav | M::Inav | M::D1 | M::D2 => 8,
M::Cnav | M::Cnv3 | M::L1nv | M::L1oc | M::L3oc => 9,
M::Cnv1 | M::Cnv2 => 10,
M::Fdma => 5,
M::Sbas => 4,
M::Ifnv | M::D1d2 | M::Cnvx | M::Lxoc => return None,
},
RecordKind::Sto => 2,
RecordKind::Eop => 3,
RecordKind::Ion => match message {
M::Lnav | M::D1d2 | M::Cnvx => 3,
M::Ifnv => 2,
M::Lxoc => 1,
_ => return None,
},
})
}
fn ion_block_len(message: NavMessage, subtype: Option<Subtype>) -> Option<usize> {
match (message, subtype) {
(NavMessage::L1nv, Some(Subtype::Klob)) => Some(4),
(NavMessage::L1nv, Some(Subtype::Neqn)) => Some(7),
(m, _) => block_len(RecordKind::Ion, m),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Epoch {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: u32,
}
fn parse_epoch(line: &str) -> Option<Epoch> {
Some(Epoch {
year: line.get(4..8)?.trim().parse().ok()?,
month: line.get(9..11)?.trim().parse().ok()?,
day: line.get(12..14)?.trim().parse().ok()?,
hour: line.get(15..17)?.trim().parse().unwrap_or(0),
minute: line.get(18..20)?.trim().parse().unwrap_or(0),
second: line.get(21..23)?.trim().parse().unwrap_or(0),
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CnavEphemeris {
pub system: GnssSystem,
pub prn: u8,
pub message: NavMessage,
pub toc: f64,
pub sqrt_a0: f64,
pub a_dot: f64,
pub delta_n0: f64,
pub delta_n0_dot: f64,
pub m0: f64,
pub e: f64,
pub omega: f64,
pub omega0: f64,
pub omega_dot: f64,
pub i0: f64,
pub idot: f64,
pub cuc: f64,
pub cus: f64,
pub crc: f64,
pub crs: f64,
pub cic: f64,
pub cis: f64,
pub t_op: Option<f64>,
pub af0: f64,
pub af1: f64,
pub af2: f64,
pub health: u8,
pub tgd: f64,
pub week: Option<u16>,
}
impl CnavEphemeris {
#[must_use]
pub fn semi_major_axis(&self, tk: f64) -> f64 {
self.sqrt_a0 * self.sqrt_a0 + self.a_dot * tk
}
#[must_use]
pub fn mean_motion(&self, tk: f64) -> f64 {
let a0 = self.sqrt_a0 * self.sqrt_a0;
let n0 = libm::sqrt(self.system.gravitational_parameter() / (a0 * a0 * a0));
n0 + self.delta_n0 + 0.5 * self.delta_n0_dot * tk
}
#[allow(clippy::many_single_char_names)]
#[must_use]
pub fn position_velocity_ecef(&self, tk: f64) -> ([f64; 3], [f64; 3]) {
let a = self.semi_major_axis(tk);
let e = self.e;
let n = self.mean_motion(tk);
let ek = crate::gps::kepler_solve(self.m0 + n * tk, e);
let (sin_ek, cos_ek) = libm::sincos(ek);
let one_minus_ecos = 1.0 - e * cos_ek;
let sqrt_1me2 = libm::sqrt(1.0 - e * e);
let nu = libm::atan2(sqrt_1me2 * sin_ek, cos_ek - e);
let phi = nu + self.omega;
let (sin2phi, cos2phi) = libm::sincos(2.0 * phi);
let du = self.cus * sin2phi + self.cuc * cos2phi;
let dr = self.crs * sin2phi + self.crc * cos2phi;
let di = self.cis * sin2phi + self.cic * cos2phi;
let u = phi + du;
let r = a * one_minus_ecos + dr;
let i = self.i0 + self.idot * tk + di;
let (sin_u, cos_u) = libm::sincos(u);
let xp = r * cos_u;
let yp = r * sin_u;
let omega_e = self.system.rotation_rate();
let omega_k = self.omega0 + (self.omega_dot - omega_e) * tk - omega_e * self.toc;
let (sin_o, cos_o) = libm::sincos(omega_k);
let (sin_i, cos_i) = libm::sincos(i);
let position = [
xp * cos_o - yp * cos_i * sin_o,
xp * sin_o + yp * cos_i * cos_o,
yp * sin_i,
];
let ek_dot = n / one_minus_ecos;
let nu_dot = ek_dot * sqrt_1me2 / one_minus_ecos;
let u_dot = nu_dot * (1.0 + 2.0 * (self.cus * cos2phi - self.cuc * sin2phi));
let r_dot = self.a_dot * one_minus_ecos
+ a * e * sin_ek * ek_dot
+ 2.0 * nu_dot * (self.crs * cos2phi - self.crc * sin2phi);
let i_dot = self.idot + 2.0 * nu_dot * (self.cis * cos2phi - self.cic * sin2phi);
let xp_dot = r_dot * cos_u - yp * u_dot;
let yp_dot = r_dot * sin_u + xp * u_dot;
let omega_k_dot = self.omega_dot - omega_e;
let velocity = [
xp_dot * cos_o - yp_dot * cos_i * sin_o + yp * sin_i * sin_o * i_dot
- position[1] * omega_k_dot,
xp_dot * sin_o + yp_dot * cos_i * cos_o - yp * sin_i * cos_o * i_dot
+ position[0] * omega_k_dot,
yp_dot * sin_i + yp * cos_i * i_dot,
];
(position, velocity)
}
#[must_use]
pub fn position_ecef(&self, tk: f64) -> [f64; 3] {
self.position_velocity_ecef(tk).0
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeplerianRecord {
pub message: NavMessage,
pub ephemeris: BroadcastEphemeris,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SbasEphemeris {
pub prn: u8,
pub toc: f64,
pub af0: f64,
pub af1: f64,
pub transmission_time: f64,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub acceleration: [f64; 3],
pub health: u8,
pub accuracy_code: u16,
pub iodn: u16,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlonassCdmaEphemeris {
pub slot: u8,
pub message: NavMessage,
pub toe: f64,
pub position: [f64; 3],
pub velocity: [f64; 3],
pub luni_solar: [f64; 3],
pub tau: f64,
pub gamma: f64,
pub beta: f64,
pub health: u8,
pub data_invalid: u8,
pub tgd_l2ocp: f64,
pub age_of_ephemeris: f64,
pub age_of_clock: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SystemTimeOffset {
pub constellation: char,
pub prn: Option<u8>,
pub message: NavMessage,
pub epoch: Epoch,
pub correction: String,
pub sbas_id: String,
pub utc_id: String,
pub transmission_time: f64,
pub a0: f64,
pub a1: f64,
pub a2: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EarthOrientation {
pub constellation: char,
pub prn: Option<u8>,
pub message: NavMessage,
pub epoch: Epoch,
pub x_pole: [f64; 3],
pub y_pole: [f64; 3],
pub transmission_time: f64,
pub delta_ut1: [f64; 3],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum IonosphereModel {
Klobuchar {
alpha: [f64; 4],
beta: [f64; 4],
region: Option<Region>,
},
NequickG {
ai: [f64; 3],
disturbance_flags: u8,
},
NequickN {
regions: [NequickRegion; 3],
},
Bdgim {
alpha: [f64; 9],
},
GlonassCdma {
c: [f64; 3],
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Region {
pub longitude: [f64; 2],
pub latitude: [f64; 2],
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NequickRegion {
pub ai: [f64; 3],
pub disturbance_flag: u8,
pub bounds: Region,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ionosphere {
pub constellation: char,
pub prn: Option<u8>,
pub message: NavMessage,
pub subtype: Option<Subtype>,
pub epoch: Epoch,
pub issue_of_data: Option<f64>,
pub model: IonosphereModel,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Rinex4Nav {
pub version: f64,
pub leap_seconds: Option<i32>,
pub keplerian: Vec<KeplerianRecord>,
pub cnav: Vec<CnavEphemeris>,
pub glonass: Vec<GlonassEphemeris>,
pub glonass_cdma: Vec<GlonassCdmaEphemeris>,
pub sbas: Vec<SbasEphemeris>,
pub time_offsets: Vec<SystemTimeOffset>,
pub earth_orientation: Vec<EarthOrientation>,
pub ionosphere: Vec<Ionosphere>,
pub rejected: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rinex4Error {
NotNavigationData,
UnsupportedVersion {
major: u8,
},
NoEndOfHeader,
}
impl core::fmt::Display for Rinex4Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NotNavigationData => f.write_str(
"not a RINEX navigation file: no `RINEX VERSION / TYPE` record of type N",
),
Self::UnsupportedVersion { major } => {
write!(f, "RINEX major version {major} is not 4")
}
Self::NoEndOfHeader => f.write_str("the header has no `END OF HEADER` record"),
}
}
}
impl core::error::Error for Rinex4Error {}
const MAX_BLOCK_LEN: usize = 10;
fn label(line: &str) -> &str {
line.get(60..).unwrap_or("").trim_end()
}
fn keplerian_system(letter: char) -> Option<GnssSystem> {
Some(match letter {
'G' => GnssSystem::Gps,
'E' => GnssSystem::Galileo,
'C' => GnssSystem::Beidou,
'J' => GnssSystem::Qzss,
'I' => GnssSystem::Navic,
_ => return None,
})
}
pub fn parse(text: &str) -> Result<Rinex4Nav, Rinex4Error> {
let lines: Vec<&str> = text.lines().collect();
let version_line = lines
.iter()
.find(|l| label(l) == "RINEX VERSION / TYPE")
.ok_or(Rinex4Error::NotNavigationData)?;
let version: f64 = version_line
.get(0..9)
.and_then(|s| s.trim().parse().ok())
.ok_or(Rinex4Error::NotNavigationData)?;
let major = version as u8;
if major != 4 {
return Err(Rinex4Error::UnsupportedVersion { major });
}
if version_line.get(20..21).map(str::trim) != Some("N") {
return Err(Rinex4Error::NotNavigationData);
}
let end = lines
.iter()
.position(|l| label(l) == "END OF HEADER")
.ok_or(Rinex4Error::NoEndOfHeader)?;
let mut out = Rinex4Nav {
version,
leap_seconds: lines[..end]
.iter()
.find(|l| label(l) == "LEAP SECONDS")
.and_then(|l| l.get(0..6)?.trim().parse().ok()),
..Rinex4Nav::default()
};
let mut records = lines.iter().enumerate().skip(end + 1);
let mut body: Vec<&str> = Vec::with_capacity(MAX_BLOCK_LEN);
while let Some((idx, line)) = records.next() {
let Some(header) = parse_header(line) else {
continue;
};
let len = match header.kind {
RecordKind::Ion => ion_block_len(header.message, header.subtype),
kind => block_len(kind, header.message),
};
let Some(len) = len else {
continue;
};
body.clear();
body.extend(records.by_ref().take(len).map(|(_, l)| *l));
if body.len() < len || !decode(&header, &body, &mut out) {
out.rejected.push(idx + 1);
}
}
Ok(out)
}
fn decode(header: &Header, body: &[&str], out: &mut Rinex4Nav) -> bool {
match header.kind {
RecordKind::Eph => decode_eph(header, body, out),
RecordKind::Sto => decode_sto(header, body, out),
RecordKind::Eop => decode_eop(header, body, out),
RecordKind::Ion => decode_ion(header, body, out),
}
}
fn decode_eph(header: &Header, body: &[&str], out: &mut Rinex4Nav) -> bool {
use NavMessage as M;
match header.message {
M::Lnav | M::Fnav | M::Inav | M::D1 | M::D2 => {
let Some(system) = keplerian_system(header.constellation) else {
return false;
};
match parse_record(body, system) {
Some(ephemeris) => {
out.keplerian.push(KeplerianRecord {
message: header.message,
ephemeris,
});
true
}
None => false,
}
}
M::Cnav | M::Cnv1 | M::Cnv2 | M::Cnv3 | M::L1nv => match decode_cnav(header, body) {
Some(eph) => {
out.cnav.push(eph);
true
}
None => false,
},
M::Fdma => match parse_glonass_record(&body[..4]) {
Some(eph) => {
out.glonass.push(eph);
true
}
None => false,
},
M::L1oc | M::L3oc => match decode_glonass_cdma(header, body) {
Some(eph) => {
out.glonass_cdma.push(eph);
true
}
None => false,
},
M::Sbas => match decode_sbas(body) {
Some(eph) => {
out.sbas.push(eph);
true
}
None => false,
},
M::Ifnv | M::D1d2 | M::Cnvx | M::Lxoc => false,
}
}
fn decode_cnav(header: &Header, body: &[&str]) -> Option<CnavEphemeris> {
let system = keplerian_system(header.constellation)?;
let epoch = body[0];
let prn: u8 = epoch.get(1..3)?.trim().parse().ok()?;
let cal = parse_epoch(epoch)?;
let toc = f64::from(
crate::rinex::day_of_week(cal.year, cal.month, cal.day) * 86_400
+ cal.hour * 3600
+ cal.minute * 60
+ cal.second,
);
let af0 = parse_field(epoch, 23, 19)?;
let af1 = parse_field(epoch, 42, 19)?;
let af2 = parse_field(epoch, 61, 19)?;
let o1 = orbit_fields(body[1])?; let o2 = orbit_fields(body[2])?; let o3 = orbit_fields(body[3])?; let o4 = orbit_fields(body[4])?; let o5 = orbit_fields(body[5])?; let o6 = orbit_fields(body[6])?;
let t_op = (header.message != NavMessage::L1nv).then_some(o3[0]);
let week = match header.message {
NavMessage::Cnav | NavMessage::Cnv2 => orbit_fields(body[body.len() - 1])
.map(|last| last[1])
.filter(|w| *w > 0.0)
.map(|w| w as u16),
_ => None,
};
Some(CnavEphemeris {
system,
prn,
message: header.message,
toc,
sqrt_a0: o2[3],
a_dot: o1[0],
delta_n0: o1[2],
delta_n0_dot: o5[1],
m0: o1[3],
e: o2[1],
omega: o4[2],
omega0: o3[2],
omega_dot: o4[3],
i0: o4[0],
idot: o5[0],
cuc: o2[0],
cus: o2[2],
crc: o4[1],
crs: o1[1],
cic: o3[1],
cis: o3[3],
t_op,
af0,
af1,
af2,
health: o6[1] as u8,
tgd: o6[2],
week,
})
}
fn decode_glonass_cdma(header: &Header, body: &[&str]) -> Option<GlonassCdmaEphemeris> {
let epoch = body[0];
let slot: u8 = epoch.get(1..3)?.trim().parse().ok()?;
let cal = parse_epoch(epoch)?;
let toe = f64::from(cal.hour * 3600 + cal.minute * 60 + cal.second);
let tau = parse_field(epoch, 23, 19)?;
let gamma = parse_field(epoch, 42, 19)?;
let beta = parse_field(epoch, 61, 19)?;
let l1 = orbit_fields(body[1])?; let l2 = orbit_fields(body[2])?; let l3 = orbit_fields(body[3])?; let l4 = orbit_fields(body[4])?;
Some(GlonassCdmaEphemeris {
slot,
message: header.message,
toe,
position: [l1[0] * KM_TO_M, l2[0] * KM_TO_M, l3[0] * KM_TO_M],
velocity: [l1[1] * KM_TO_M, l2[1] * KM_TO_M, l3[1] * KM_TO_M],
luni_solar: [l1[2] * KM_TO_M, l2[2] * KM_TO_M, l3[2] * KM_TO_M],
tau,
gamma,
beta,
health: l1[3] as u8,
data_invalid: l2[3] as u8,
tgd_l2ocp: l3[3],
age_of_ephemeris: l4[2],
age_of_clock: l4[3],
})
}
fn decode_sbas(body: &[&str]) -> Option<SbasEphemeris> {
let epoch = body[0];
let prn: u8 = epoch.get(1..3)?.trim().parse().ok()?;
let cal = parse_epoch(epoch)?;
let toc = f64::from(cal.hour * 3600 + cal.minute * 60 + cal.second);
let af0 = parse_field(epoch, 23, 19)?;
let af1 = parse_field(epoch, 42, 19)?;
let transmission_time = parse_field(epoch, 61, 19)?;
let l1 = orbit_fields(body[1])?; let l2 = orbit_fields(body[2])?; let l3 = orbit_fields(body[3])?;
Some(SbasEphemeris {
prn,
toc,
af0,
af1,
transmission_time,
position: [l1[0] * KM_TO_M, l2[0] * KM_TO_M, l3[0] * KM_TO_M],
velocity: [l1[1] * KM_TO_M, l2[1] * KM_TO_M, l3[1] * KM_TO_M],
acceleration: [l1[2] * KM_TO_M, l2[2] * KM_TO_M, l3[2] * KM_TO_M],
health: l1[3] as u8,
accuracy_code: l2[3] as u16,
iodn: l3[3] as u16,
})
}
fn text_field(line: &str, start: usize) -> String {
line.get(start..start + 18).unwrap_or("").trim().to_string()
}
fn decode_sto(header: &Header, body: &[&str], out: &mut Rinex4Nav) -> bool {
let Some(epoch) = parse_epoch(body[0]) else {
return false;
};
let Some(values) = orbit_fields(body[1]) else {
return false;
};
out.time_offsets.push(SystemTimeOffset {
constellation: header.constellation,
prn: header.prn,
message: header.message,
epoch,
correction: text_field(body[0], 24),
sbas_id: text_field(body[0], 43),
utc_id: text_field(body[0], 62),
transmission_time: values[0],
a0: values[1],
a1: values[2],
a2: values[3],
});
true
}
fn decode_eop(header: &Header, body: &[&str], out: &mut Rinex4Nav) -> bool {
let Some(epoch) = parse_epoch(body[0]) else {
return false;
};
let Some(x) = triple(body[0]) else {
return false;
};
let Some(y) = triple(body[1]) else {
return false;
};
let Some(t) = orbit_fields(body[2]) else {
return false;
};
out.earth_orientation.push(EarthOrientation {
constellation: header.constellation,
prn: header.prn,
message: header.message,
epoch,
x_pole: x,
y_pole: y,
transmission_time: t[0],
delta_ut1: [t[1], t[2], t[3]],
});
true
}
fn triple(line: &str) -> Option<[f64; 3]> {
Some([
parse_field(line, 23, 19)?,
parse_field(line, 42, 19)?,
parse_field(line, 61, 19)?,
])
}
fn decode_ion(header: &Header, body: &[&str], out: &mut Rinex4Nav) -> bool {
let Some(epoch) = parse_epoch(body[0]) else {
return false;
};
let Some(first) = triple(body[0]) else {
return false;
};
let mut issue_of_data = None;
let model = match (header.message, header.subtype) {
(NavMessage::Ifnv, _) => {
let Some(flags) = parse_field(body[1], 4, 19) else {
return false;
};
IonosphereModel::NequickG {
ai: first,
disturbance_flags: flags as u8,
}
}
(NavMessage::Cnvx, _) if header.constellation == 'C' => {
let (Some(second), Some(third)) = (orbit_fields(body[1]), orbit_fields(body[2])) else {
return false;
};
IonosphereModel::Bdgim {
alpha: [
first[0], first[1], first[2], second[0], second[1], second[2], second[3],
third[0], third[1],
],
}
}
(NavMessage::Lxoc, _) => IonosphereModel::GlonassCdma { c: first },
(NavMessage::L1nv, Some(Subtype::Klob)) => {
let (Some(a), Some(b), Some(box_)) = (
orbit_fields(body[1]),
orbit_fields(body[2]),
orbit_fields(body[3]),
) else {
return false;
};
issue_of_data = Some(first[0]);
IonosphereModel::Klobuchar {
alpha: a,
beta: b,
region: Some(Region {
longitude: [box_[0], box_[1]],
latitude: [box_[2], box_[3]],
}),
}
}
(NavMessage::L1nv, Some(Subtype::Neqn)) => {
let mut regions = [NequickRegion {
ai: [0.0; 3],
disturbance_flag: 0,
bounds: Region {
longitude: [0.0; 2],
latitude: [0.0; 2],
},
}; 3];
let (pairs, _) = body[1..].as_chunks::<2>();
for (r, lines) in regions.iter_mut().zip(pairs) {
let (Some(a), Some(box_)) = (orbit_fields(lines[0]), orbit_fields(lines[1])) else {
return false;
};
r.ai = [a[0], a[1], a[2]];
r.disturbance_flag = a[3] as u8;
r.bounds = Region {
longitude: [box_[0], box_[1]],
latitude: [box_[2], box_[3]],
};
}
issue_of_data = Some(first[0]);
IonosphereModel::NequickN { regions }
}
_ => {
let (Some(second), Some(third)) = (orbit_fields(body[1]), orbit_fields(body[2])) else {
return false;
};
IonosphereModel::Klobuchar {
alpha: [first[0], first[1], first[2], second[0]],
beta: [second[1], second[2], second[3], third[0]],
region: None,
}
}
};
out.ionosphere.push(Ionosphere {
constellation: header.constellation,
prn: header.prn,
message: header.message,
subtype: header.subtype,
epoch,
issue_of_data,
model,
});
true
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::float_cmp)]
use super::*;
const SPEC_EXAMPLES: &str = concat!(
" 4.02 NAVIGATION DATA M RINEX VERSION / TYPE\n",
" 18 18 1929 7 LEAP SECONDS\n",
" END OF HEADER\n",
"> EPH G04 LNAV\n",
"G04 2019 03 14 04 00 00 1.330170780420e-04 7.275957614183e-12 0.000000000000e+00\n",
" 9.800000000000e+01-1.718750000000e+00 4.639836124941e-09 2.148941747752e+00\n",
" -1.881271600723e-07 3.355251392350e-04 8.245930075645e-06 5.153800453186e+03\n",
" 3.600000000000e+05-1.676380634308e-08 5.171400020311e-01 1.490116119385e-08\n",
" 9.601921900531e-01 2.187187500000e+02-1.736906885738e+00-8.044977962767e-09\n",
" -2.932264997750e-10 1.000000000000e+00 2.044000000000e+03 0.000000000000e+00\n",
" 4.000000000000e+00 6.300000000000e+01-8.847564458847e-09 8.660000000000e+02\n",
" 3.553500000000e+05 4.000000000000e+00\n",
"> EPH G04 CNAV\n",
"G04 2019 03 14 03 30 00 1.330042141490e-04 7.226219622680e-12 0.000000000000e+00\n",
" 2.001762390137e-03 6.914062500000e-01 4.625906973308e-09 1.887277537485e+00\n",
" 1.024454832077e-08 3.348654136062e-04 8.376315236092e-06 5.153800325291e+03\n",
" 2.412000000000e+05-4.656612873077e-09 5.171544951605e-01 2.328306436539e-08\n",
" 9.601927657114e-01 2.174140625000e+02-1.737767543851e+00-8.034028170143e-09\n",
" -2.950122884460e-10-1.312310522376e-14-2.000000000000e+00 2.000000000000e+00\n",
" 0.000000000000e+00 7.000000000000e+00-8.789356797934e-09 5.000000000000e+00\n",
" -5.820766091347e-10-6.606569513679e-09-1.178705133498e-08-1.178705133498e-08\n",
" 3.558540000000e+05 2.044000000000e+03\n",
"> EPH G04 CNV2\n",
"G04 2019 03 14 03 30 00 1.330042141490e-04 7.226219622680e-12 0.000000000000e+00\n",
" 2.001762390137e-03 6.914062500000e-01 4.625906973308e-09 1.887277537485e+00\n",
" 1.024454832077e-08 3.348654136062e-04 8.376315236092e-06 5.153800325291e+03\n",
" 2.412000000000e+05-4.656612873077e-09 5.171544951605e-01 2.328306436539e-08\n",
" 9.601927657114e-01 2.174140625000e+02-1.737767543851e+00-8.034028170143e-09\n",
" -2.950122884460e-10-1.312310522376e-14-2.000000000000e+00 2.000000000000e+00\n",
" 0.000000000000e+00 1.000000000000e+00-8.789356797934e-09 5.000000000000e+00\n",
" -5.820766091347e-10-6.606569513679e-09-1.178705133498e-08-1.178705133498e-08\n",
" -3.492459654808e-10-7.858034223318e-10\n",
" 3.550860000000e+05 2.044000000000e+03\n",
"> EPH I02 LNAV\n",
"I02 2020 09 15 02 05 36 6.225099787116e-04 1.773514668457e-11 0.000000000000e+00\n",
" 1.690000000000e+02-5.793750000000e+02 4.834487090078e-09-4.281979621524e-01\n",
" -1.904368400574e-05 2.015684265643e-03-3.430992364883e-06 6.493289550781e+03\n",
" 1.803360000000e+05 2.495944499969e-07-1.337499015334e+00 7.450580596924e-08\n",
" 5.022043764738e-01 1.946250000000e+02-2.970970345572e+00-4.461614415577e-09\n",
" -9.578970431139e-10 2.123000000000e+03\n",
" 2.000000000000e+00 0.000000000000e+00-1.862645149231e-09\n",
" 1.804920000000e+05\n",
"> EPH I10 L1NV\n",
"I10 2023 06 24 00 05 00 1.527369022369e-07 1.364242052659e-12 0.000000000000e+00\n",
" 0.000000000000e+00-2.593125000000e+02 7.028864208979e-09 2.300305834983e+00\n",
" -8.691102266312e-06 4.531537415460e-04-3.855675458908e-06 6.493495117188e+03\n",
" 7.000000000000e+00 1.341104507446e-07 1.359342162629e-01-7.078051567078e-08\n",
" 8.594830530333e-02 1.214375000000e+02-5.136403830694e-02-5.858815471753e-09\n",
" -4.846630453041e-10 0.000000000000e+00 1.000000000000e+00\n",
" 1.500000000000e+01 0.000000000000e+00 -3.608874976635e-09\n",
" 6.984919309616e-09 5.995389074087e-09\n",
" 5.191380000000e+05\n",
"> EPH R26 L1OC\n",
"R26 2024 02 03 00 15 00-1.605716170161e-05 1.652011860642e-12-2.081668171172e-17\n",
" 1.812154053020e+04-2.071979139000e+00 5.729816621169e-10 1.000000000000e+00\n",
" -2.325615360260e+03 1.285475494340e+00-1.047737896442e-09 1.000000000000e+00\n",
" 1.781341854668e+04 2.280306640081e+00-9.458744898438e-10 0.000000000000e+00\n",
" 2.000000000000e+00 1.000000000000e+01 7.500000000000e-01 7.500000000000e-01\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 1.500000000000e+01 5.000000000000e+00 5.184000000000e+05\n",
"> EPH R26 L3OC\n",
"R26 2024 02 03 00 15 00-1.605716170161e-05 1.652011860642e-12-2.081668171172e-17\n",
" 1.812154053020e+04-2.071979139000e+00 5.729816621169e-10 1.000000000000e+00\n",
" -2.325615360260e+03 1.285475494340e+00-1.047737896442e-09 1.000000000000e+00\n",
" 1.781341854668e+04 2.280306640081e+00-9.458744898438e-10 0.000000000000e+00\n",
" 2.000000000000e+00 1.000000000000e+01 7.500000000000e-01 7.500000000000e-01\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00 0.000000000000e+00\n",
" 1.500000000000e+01 5.000000000000e+00 5.184000000000e+05\n",
"> ION C03 D1D2\n",
" 2021 07 05 00 09 00 7.450580596924e-09 4.470348358154e-08-4.172325134277e-07\n",
" 5.960464477539e-07 1.187840000000e+05 1.802240000000e+05-6.553600000000e+05\n",
" 5.242880000000e+05\n",
"> ION I10 L1NV KLOB\n",
" 2023 06 24 00 07 30 1.000000000000e+00\n",
" 5.867332220078e-08 2.533197402954e-07-1.430511474609e-06-7.510185241699e-06\n",
" 1.495040000000e+05-5.406720000000e+05 2.883584000000e+06 8.323072000000e+06\n",
" 5.000000000000e+01 1.100000000000e+02 0.000000000000e+00 5.000000000000e+01\n",
"> ION I10 L1NV NEQN\n",
" 2023 06 24 00 17 24 0.000000000000e+00\n",
" 1.982500000000e+02 0.000000000000e+00 0.000000000000e+00 1.000000000000e+00\n",
" 3.000000000000e+01 1.300000000000e+02-3.000000000000e+01-1.000000000000e+01\n",
" 2.085000000000e+02-3.085937500000e-01-3.417968750000e-03 1.000000000000e+00\n",
" 3.000000000000e+01 1.300000000000e+02-5.000000000000e+00 3.000000000000e+01\n",
" 1.982500000000e+02 0.000000000000e+00 0.000000000000e+00 1.000000000000e+00\n",
" 3.000000000000e+01 1.300000000000e+02 3.500000000000e+01 5.000000000000e+01\n",
"> ION R22 LXOC\n",
" 2024 02 03 00 01 09 1.000000000000e+00 1.410000000000e+02 5.000000000000e+00\n",
);
fn parsed() -> Rinex4Nav {
parse(SPEC_EXAMPLES).expect("the specification's own examples parse")
}
#[test]
fn a_record_header_line_is_read_from_its_columns() {
let eph = parse_header("> EPH G01 LNAV").expect("a well-formed EPH header");
assert_eq!(eph.kind, RecordKind::Eph);
assert_eq!(eph.constellation, 'G');
assert_eq!(eph.prn, Some(1));
assert_eq!(eph.message, NavMessage::Lnav);
assert_eq!(eph.subtype, None);
let sto = parse_header("> STO R FDMA").expect("a constellation-wide STO header");
assert_eq!(sto.kind, RecordKind::Sto);
assert_eq!(sto.constellation, 'R');
assert_eq!(sto.prn, None);
assert_eq!(sto.message, NavMessage::Fdma);
let ion = parse_header("> ION I L1NV KLOB").expect("a subtyped ION header");
assert_eq!(ion.subtype, Some(Subtype::Klob));
assert_eq!(parse_header("G01 2024 01 01 00 00 00"), None);
assert_eq!(parse_header("> XXX G01 LNAV"), None);
assert_eq!(parse_header("> EPH G01 XXXX"), None);
}
#[test]
fn the_block_length_follows_the_message_and_not_the_constellation() {
use NavMessage as M;
for (message, expected) in [
(M::Lnav, 8),
(M::Cnav, 9),
(M::Cnv2, 10),
(M::Fnav, 8),
(M::Inav, 8),
(M::D1, 8),
(M::D2, 8),
(M::Cnv1, 10),
(M::Cnv3, 9),
(M::L1nv, 9),
(M::L1oc, 9),
(M::L3oc, 9),
(M::Fdma, 5),
(M::Sbas, 4),
] {
assert_eq!(
block_len(RecordKind::Eph, message),
Some(expected),
"EPH {message:?}"
);
}
assert_eq!(block_len(RecordKind::Eph, M::Cnvx), None);
assert_eq!(block_len(RecordKind::Sto, M::Cnvx), Some(2));
assert_eq!(block_len(RecordKind::Eop, M::Cnvx), Some(3));
assert_eq!(ion_block_len(M::Cnvx, None), Some(3));
assert_eq!(ion_block_len(M::Ifnv, None), Some(2));
assert_eq!(ion_block_len(M::Lxoc, None), Some(1));
assert_eq!(ion_block_len(M::L1nv, Some(Subtype::Klob)), Some(4));
assert_eq!(ion_block_len(M::L1nv, Some(Subtype::Neqn)), Some(7));
assert_eq!(ion_block_len(M::L1nv, None), None);
}
#[test]
fn the_specifications_own_examples_decode_to_the_values_printed_in_them() {
let nav = parsed();
assert_eq!(nav.version, 4.02);
assert_eq!(nav.leap_seconds, Some(18));
assert!(nav.rejected.is_empty(), "{:?}", nav.rejected);
assert_eq!(nav.keplerian.len(), 2);
let g04 = &nav.keplerian[0].ephemeris;
assert_eq!(nav.keplerian[0].message, NavMessage::Lnav);
assert_eq!(g04.system, GnssSystem::Gps);
assert_eq!(g04.prn, 4);
assert_eq!(g04.sqrt_a, 5.153_800_453_186e+03);
assert_eq!(g04.e, 3.355_251_392_350e-04);
assert_eq!(g04.toe, 3.600_000_000_000e+05);
assert_eq!(g04.week, 2044);
assert_eq!(g04.tgd, -8.847_564_458_847e-09);
let i02 = &nav.keplerian[1].ephemeris;
assert_eq!(i02.system, GnssSystem::Navic);
assert_eq!(i02.prn, 2);
assert_eq!(i02.week, 2123);
assert_eq!(i02.sqrt_a, 6.493_289_550_781e+03);
assert_eq!(nav.cnav.len(), 3);
let cnav = &nav.cnav[0];
assert_eq!(cnav.message, NavMessage::Cnav);
assert_eq!(cnav.prn, 4);
assert_eq!(cnav.sqrt_a0, 5.153_800_325_291e+03);
assert_eq!(cnav.a_dot, 2.001_762_390_137e-03);
assert_eq!(cnav.delta_n0, 4.625_906_973_308e-09);
assert_eq!(cnav.delta_n0_dot, -1.312_310_522_376e-14);
assert_eq!(cnav.t_op, Some(2.412_000_000_000e+05));
assert_eq!(cnav.week, Some(2044));
assert_eq!(cnav.health, 7);
let cnv2 = &nav.cnav[1];
assert_eq!(cnv2.message, NavMessage::Cnv2);
assert_eq!(cnv2.sqrt_a0, cnav.sqrt_a0);
assert_eq!(cnv2.delta_n0_dot, cnav.delta_n0_dot);
assert_eq!(cnv2.health, 1);
assert_eq!(cnv2.week, Some(2044));
let l1nv = &nav.cnav[2];
assert_eq!(l1nv.message, NavMessage::L1nv);
assert_eq!(l1nv.system, GnssSystem::Navic);
assert_eq!(l1nv.t_op, None);
assert_eq!(l1nv.week, None);
assert_eq!(l1nv.sqrt_a0, 6.493_495_117_188e+03);
assert_eq!(l1nv.a_dot, 0.0);
assert_eq!(l1nv.delta_n0_dot, 0.0);
}
#[test]
fn a_file_that_is_not_a_version_four_navigation_file_is_refused() {
let v3 = SPEC_EXAMPLES.replace(" 4.02 ", " 3.05 ");
assert_eq!(
parse(&v3),
Err(Rinex4Error::UnsupportedVersion { major: 3 })
);
let obs = SPEC_EXAMPLES.replace("NAVIGATION DATA M", "OBSERVATION DATA M");
assert_eq!(parse(&obs), Err(Rinex4Error::NotNavigationData));
let headless = SPEC_EXAMPLES.replace("END OF HEADER", "COMMENT ");
assert_eq!(parse(&headless), Err(Rinex4Error::NoEndOfHeader));
assert_eq!(
parse("not a RINEX file"),
Err(Rinex4Error::NotNavigationData)
);
}
#[test]
fn an_unreadable_record_is_counted_and_the_walk_stays_in_step() {
let broken = SPEC_EXAMPLES.replace("5.153800453186e+03", "5.15380045X186e+03");
assert_ne!(broken, SPEC_EXAMPLES);
let nav = parse(&broken).expect("the file still parses as a file");
let header = SPEC_EXAMPLES
.lines()
.position(|l| l == "> EPH G04 LNAV")
.expect("the record the replacement broke");
assert_eq!(nav.rejected, vec![header + 1], "{:?}", nav.rejected);
assert_eq!(nav.keplerian.len(), 1);
assert_eq!(nav.keplerian[0].ephemeris.system, GnssSystem::Navic);
assert_eq!(nav.cnav.len(), 3);
}
#[test]
fn the_glonass_cdma_messages_decode_to_a_pz90_state_in_metres() {
let nav = parsed();
assert_eq!(nav.glonass_cdma.len(), 2);
let [l1oc, l3oc] = [&nav.glonass_cdma[0], &nav.glonass_cdma[1]];
assert_eq!(l1oc.message, NavMessage::L1oc);
assert_eq!(l3oc.message, NavMessage::L3oc);
assert_eq!(l1oc.slot, 26);
assert_eq!(l1oc.toe, 900.0);
assert_eq!(l1oc.tau, -1.605_716_170_161e-05);
assert_eq!(l1oc.gamma, 1.652_011_860_642e-12);
assert_eq!(l1oc.beta, -2.081_668_171_172e-17);
assert_eq!(
l1oc.position,
[
1.812_154_053_020e+04 * 1000.0,
-2.325_615_360_260e+03 * 1000.0,
1.781_341_854_668e+04 * 1000.0
]
);
assert_eq!(
l1oc.velocity,
[
-2.071_979_139_000e+00 * 1000.0,
1.285_475_494_340e+00 * 1000.0,
2.280_306_640_081e+00 * 1000.0
]
);
assert_eq!(
l1oc.luni_solar,
[
5.729_816_621_169e-10 * 1000.0,
-1.047_737_896_442e-09 * 1000.0,
-9.458_744_898_438e-10 * 1000.0
]
);
let norm = |v: [f64; 3]| v.iter().map(|x| x * x).sum::<f64>().sqrt();
let radius = norm(l1oc.position);
assert!(
(25_400e3..25_600e3).contains(&radius),
"GLONASS radius {radius} m"
);
let speed = norm(l1oc.velocity);
assert!((3_300.0..3_500.0).contains(&speed), "speed {speed} m/s");
let accel = norm(l1oc.luni_solar);
assert!((1e-7..1e-5).contains(&accel), "acceleration {accel} m/s^2");
assert_eq!(l1oc.health, 1);
assert_eq!(l1oc.data_invalid, 1);
assert_eq!(l1oc.tgd_l2ocp, 0.0);
assert_eq!(l1oc.age_of_ephemeris, 0.75);
assert_eq!(l1oc.age_of_clock, 0.75);
assert_eq!(l3oc.position, l1oc.position);
assert_eq!(l3oc.slot, l1oc.slot);
}
#[test]
fn the_navic_l1_ionosphere_messages_use_their_own_line_layout() {
let nav = parsed();
let by_message = |m: NavMessage, sub: Option<Subtype>| {
nav.ionosphere
.iter()
.find(|i| i.message == m && i.subtype == sub)
.expect("the record is in the specification's examples")
};
let klob = by_message(NavMessage::L1nv, Some(Subtype::Klob));
assert_eq!(klob.constellation, 'I');
assert_eq!(klob.prn, Some(10));
assert_eq!(klob.issue_of_data, Some(1.0));
let IonosphereModel::Klobuchar {
alpha,
beta,
region,
} = klob.model
else {
panic!("NavIC L1 KLOB is a Klobuchar record: {:?}", klob.model);
};
assert_eq!(alpha[0], 5.867_332_220_078e-08);
assert_eq!(alpha[3], -7.510_185_241_699e-06);
assert_eq!(beta[0], 1.495_040_000_000e+05);
assert_eq!(beta[3], 8.323_072_000_000e+06);
assert_eq!(
region,
Some(Region {
longitude: [50.0, 110.0],
latitude: [0.0, 50.0],
})
);
let neqn = by_message(NavMessage::L1nv, Some(Subtype::Neqn));
assert_eq!(neqn.issue_of_data, Some(0.0));
let IonosphereModel::NequickN { regions } = neqn.model else {
panic!("NavIC L1 NEQN is a NeQuick-N record: {:?}", neqn.model);
};
assert_eq!(regions[0].ai, [198.25, 0.0, 0.0]);
assert_eq!(regions[0].disturbance_flag, 1);
assert_eq!(regions[0].bounds.longitude, [30.0, 130.0]);
assert_eq!(regions[0].bounds.latitude, [-30.0, -10.0]);
assert_eq!(regions[1].ai[1], -3.085_937_500_000e-01);
assert_eq!(regions[1].bounds.latitude, [-5.0, 30.0]);
assert_eq!(regions[2].bounds.latitude, [35.0, 50.0]);
for r in regions.windows(2) {
assert!(r[0].bounds.latitude[1] <= r[1].bounds.latitude[0]);
}
let lxoc = by_message(NavMessage::Lxoc, None);
assert_eq!(lxoc.constellation, 'R');
assert_eq!(lxoc.issue_of_data, None);
assert_eq!(
lxoc.model,
IonosphereModel::GlonassCdma {
c: [1.0, 141.0, 5.0]
}
);
let legacy = by_message(NavMessage::D1d2, None);
assert!(matches!(
legacy.model,
IonosphereModel::Klobuchar { region: None, .. }
));
}
#[test]
fn the_seconds_of_week_a_record_is_stamped_with_is_computed_from_its_calendar_date() {
let nav = parsed();
let g04 = &nav.keplerian[0].ephemeris;
assert_eq!(g04.toc, 360_000.0);
assert_eq!(g04.toc, g04.toe);
assert_eq!(nav.cnav[0].toc, 358_200.0);
assert_eq!(nav.cnav[1].toc, 358_200.0);
let l1nv = nav
.cnav
.iter()
.find(|e| e.message == NavMessage::L1nv)
.expect("the NavIC L1NV example");
assert_eq!(l1nv.toc, 518_700.0);
assert_eq!(nav.glonass_cdma[0].toe, 900.0);
}
#[test]
fn a_cnav_record_whose_prediction_week_is_absent_reports_none() {
let present = parsed();
assert_eq!(present.cnav[0].week, Some(2044));
let zeroed = SPEC_EXAMPLES.replace(
" 3.558540000000e+05 2.044000000000e+03\n",
" 3.558540000000e+05 0.000000000000e+00\n",
);
assert_ne!(zeroed, SPEC_EXAMPLES);
let nav = parse(&zeroed).expect("the file still parses");
assert!(nav.rejected.is_empty(), "{:?}", nav.rejected);
assert_eq!(nav.cnav[0].message, NavMessage::Cnav);
assert_eq!(nav.cnav[0].week, None);
assert_eq!(nav.cnav[1].week, Some(2044));
}
#[test]
fn a_line_the_walk_cannot_frame_leaves_the_records_after_it_alone() {
let baseline = parsed();
let padded = SPEC_EXAMPLES.replace(
"> EPH G04 CNAV\n",
"a line that is not a record header\n> EPH G04 ZZZZ\n> EPH G04 CNAV\n",
);
assert_ne!(padded, SPEC_EXAMPLES);
let nav = parse(&padded).expect("the file still parses");
assert!(nav.rejected.is_empty(), "{:?}", nav.rejected);
assert_eq!(nav.keplerian.len(), baseline.keplerian.len());
assert_eq!(nav.cnav.len(), baseline.cnav.len());
assert_eq!(nav.ionosphere.len(), baseline.ionosphere.len());
assert_eq!(nav.glonass_cdma.len(), baseline.glonass_cdma.len());
assert_eq!(nav.cnav[0].sqrt_a0, baseline.cnav[0].sqrt_a0);
}
#[test]
fn the_analytic_velocity_is_the_derivative_of_the_analytic_position() {
let nav = parsed();
let mut eph = nav.cnav[0];
assert_ne!(eph.a_dot, 0.0);
eph.delta_n0_dot = 0.0;
let h = 5.0;
for tk in [-7200.0, -900.0, 0.0, 900.0, 7200.0] {
let (_, analytic) = eph.position_velocity_ecef(tk);
let p = |d: f64| eph.position_ecef(tk + d);
let (m2, m1, p1, p2) = (p(-2.0 * h), p(-h), p(h), p(2.0 * h));
for k in 0..3 {
let numeric = (m2[k] - 8.0 * m1[k] + 8.0 * p1[k] - p2[k]) / (12.0 * h);
assert!(
(analytic[k] - numeric).abs() < 1e-6,
"tk = {tk}, axis {k}: analytic {} against numeric {numeric}",
analytic[k]
);
}
}
}
#[test]
fn every_field_of_the_epoch_stamp_reaches_the_seconds_of_week() {
let moved = SPEC_EXAMPLES.replace(
"G04 2019 03 14 03 30 00 1.330042141490e-04",
"G04 2019 03 14 05 30 42 1.330042141490e-04",
);
assert_ne!(moved, SPEC_EXAMPLES);
let nav = parse(&moved).expect("the file still parses");
assert_eq!(nav.cnav[0].toc, 365_442.0);
let moved = SPEC_EXAMPLES.replace(
"R26 2024 02 03 00 15 00-1.605716170161e-05",
"R26 2024 02 03 07 15 23-1.605716170161e-05",
);
assert_ne!(moved, SPEC_EXAMPLES);
let nav = parse(&moved).expect("the file still parses");
assert_eq!(nav.glonass_cdma[0].toe, 26_123.0);
assert_eq!(nav.glonass_cdma[1].toe, 26_123.0);
}
#[test]
fn a_comment_that_looks_like_a_record_is_still_a_comment() {
let baseline = parsed();
let smuggled = SPEC_EXAMPLES.replace(
" END OF HEADER\n",
concat!(
"> EPH G04 LNAV COMMENT\n",
"> EPH G04 LNAV END OF HEADER\n",
),
);
assert_ne!(smuggled, SPEC_EXAMPLES);
let nav = parse(&smuggled).expect("the file still parses");
assert!(nav.rejected.is_empty(), "{:?}", nav.rejected);
assert_eq!(nav.keplerian.len(), baseline.keplerian.len());
assert_eq!(
nav.keplerian[0].ephemeris.af0,
baseline.keplerian[0].ephemeris.af0
);
}
#[test]
fn a_truncated_final_record_is_reported_by_the_line_it_starts_on() {
let full: Vec<&str> = SPEC_EXAMPLES.lines().collect();
let start = full
.iter()
.position(|l| *l == "> ION R22 LXOC")
.expect("the LXOC record is last");
let truncated = full[..=start].join("\n");
let nav = parse(&truncated).expect("the file still parses");
assert_eq!(nav.rejected, vec![start + 1]);
assert!(nav.ionosphere.iter().all(|i| i.message != NavMessage::Lxoc));
}
#[test]
fn with_the_rates_zeroed_the_cnav_algorithm_reproduces_the_legacy_one_exactly() {
let nav = parsed();
let cnav = &nav.cnav[0];
let mut flat = *cnav;
flat.a_dot = 0.0;
flat.delta_n0_dot = 0.0;
let legacy = crate::gps::BroadcastEphemeris {
system: cnav.system,
prn: cnav.prn,
toe: cnav.toc,
toc: cnav.toc,
sqrt_a: cnav.sqrt_a0,
e: cnav.e,
i0: cnav.i0,
omega0: cnav.omega0,
omega: cnav.omega,
m0: cnav.m0,
delta_n: cnav.delta_n0,
idot: cnav.idot,
omega_dot: cnav.omega_dot,
cuc: cnav.cuc,
cus: cnav.cus,
crc: cnav.crc,
crs: cnav.crs,
cic: cnav.cic,
cis: cnav.cis,
..crate::gps::BroadcastEphemeris::default()
};
for tk in [-7200.0, -1800.0, 0.0, 1800.0, 7200.0] {
assert_eq!(
flat.position_velocity_ecef(tk),
legacy.position_velocity_ecef(tk),
"tk = {tk}"
);
}
}
#[test]
fn each_rate_term_moves_the_orbit_by_the_amount_its_definition_says() {
let nav = parsed();
let cnav = &nav.cnav[0];
assert_ne!(cnav.a_dot, 0.0, "the specification's example carries A-dot");
for tk in [3600.0, -3600.0] {
let a0 = cnav.sqrt_a0 * cnav.sqrt_a0;
let drift = cnav.semi_major_axis(tk) - a0;
assert!(
(drift - cnav.a_dot * tk).abs() < 1e-12 * (cnav.a_dot * tk).abs(),
"{drift} m against A-dot*tk {} m",
cnav.a_dot * tk
);
}
assert_eq!(cnav.semi_major_axis(0.0), cnav.sqrt_a0 * cnav.sqrt_a0);
let n_at = |tk| cnav.mean_motion(tk);
let measured = n_at(7200.0) - n_at(0.0);
let expected = 0.5 * cnav.delta_n0_dot * 7200.0;
assert!(
(measured - expected).abs() < 1e-8 * expected.abs(),
"{measured} vs {expected}"
);
let mut no_a_dot = *cnav;
no_a_dot.a_dot = 0.0;
let tk = 7200.0;
let norm = |p: [f64; 3]| p.iter().map(|x| x * x).sum::<f64>().sqrt();
let drift = norm(cnav.position_ecef(tk)) - norm(no_a_dot.position_ecef(tk));
let expected = cnav.a_dot * tk;
assert!(
(drift - expected).abs() < 1e-3 * expected.abs(),
"radius drift {drift} m against A-dot*tk {expected} m"
);
}
#[test]
fn every_error_prints_a_distinct_message() {
let messages: Vec<String> = [
Rinex4Error::NotNavigationData,
Rinex4Error::UnsupportedVersion { major: 3 },
Rinex4Error::NoEndOfHeader,
]
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(messages.len(), 3);
for (i, a) in messages.iter().enumerate() {
assert_ne!(a.len(), 0);
for b in &messages[i + 1..] {
assert_ne!(a, b);
}
}
}
}