#![allow(clippy::doc_markdown)]
use std::collections::BTreeMap;
use std::fmt;
use crate::sp3::{JDN_SECONDS_AT_J2000, SatId, TimeSystem, jdn, parse_sat_id, parse_time_system};
const OBSERVATION_WIDTH: usize = 16;
const OBSERVATION_START: usize = 3;
const MAX_OBS_TYPES: usize = 100;
const OBS_TYPES_LABEL: &str = "SYS / # / OBS TYPES";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ObsKind {
Pseudorange,
CarrierPhase,
Doppler,
SignalStrength,
IonospherePhaseDelay,
ReceiverChannel,
}
impl ObsKind {
#[must_use]
pub const fn letter(self) -> char {
match self {
Self::Pseudorange => 'C',
Self::CarrierPhase => 'L',
Self::Doppler => 'D',
Self::SignalStrength => 'S',
Self::IonospherePhaseDelay => 'I',
Self::ReceiverChannel => 'X',
}
}
fn from_letter(c: char) -> Option<Self> {
Some(match c {
'C' => Self::Pseudorange,
'L' => Self::CarrierPhase,
'D' => Self::Doppler,
'S' => Self::SignalStrength,
'I' => Self::IonospherePhaseDelay,
'X' => Self::ReceiverChannel,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ObsCode {
pub kind: ObsKind,
pub band: char,
pub attribute: Option<char>,
}
impl fmt::Display for ObsCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.kind.letter(), self.band)?;
match self.attribute {
Some(a) => write!(f, "{a}"),
None => write!(f, " "),
}
}
}
impl ObsCode {
fn parse(code: &str) -> Option<Self> {
let mut chars = code.chars();
let kind = ObsKind::from_letter(chars.next()?)?;
let band = chars.next()?;
if !band.is_ascii_digit() {
return None;
}
let attribute = match chars.next() {
None | Some(' ') => None,
Some(a) if a.is_ascii_alphanumeric() => Some(a),
Some(_) => return None,
};
Some(Self {
kind,
band,
attribute,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Observation {
pub value: f64,
pub lli: Option<u8>,
pub signal_strength: Option<u8>,
}
impl Observation {
#[must_use]
pub fn lost_lock(&self) -> bool {
self.lli.is_some_and(|f| f & 0x01 != 0)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SatObservation {
pub sat: SatId,
pub values: Vec<Option<Observation>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObsEpoch {
pub seconds_j2000: f64,
pub power_failure: bool,
pub receiver_clock_offset: Option<f64>,
pub satellites: Vec<SatObservation>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObsHeader {
pub version: f64,
pub file_system: char,
pub marker_name: String,
pub approx_position: Option<[f64; 3]>,
pub antenna_delta_hen: Option<[f64; 3]>,
pub interval: Option<f64>,
pub time_system: TimeSystem,
pub obs_types: BTreeMap<char, Vec<ObsCode>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RinexObs {
pub header: ObsHeader,
pub epochs: Vec<ObsEpoch>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RinexObsError {
NotObservationData,
UnsupportedVersion {
major: u8,
},
NoEndOfHeader,
MissingObsTypes,
MalformedHeaderRecord {
line: usize,
label: &'static str,
},
ObsTypeCountMismatch {
line: usize,
expected: usize,
found: usize,
},
MalformedEpochRecord {
line: usize,
},
TruncatedEpoch {
line: usize,
expected: usize,
found: usize,
},
UndeclaredSystem {
line: usize,
},
MalformedObservationRecord {
line: usize,
},
}
impl fmt::Display for RinexObsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::NotObservationData => write!(f, "not a RINEX observation file"),
Self::UnsupportedVersion { major } => {
write!(f, "RINEX version {major} is not the version 3 read here")
}
Self::NoEndOfHeader => write!(f, "the header has no END OF HEADER"),
Self::MissingObsTypes => write!(f, "the header declares no SYS / # / OBS TYPES list"),
Self::MalformedHeaderRecord { line, label } => {
write!(f, "line {line} does not hold a readable {label}")
}
Self::ObsTypeCountMismatch {
line,
expected,
found,
} => write!(
f,
"the observation-type list at line {line} declares {expected} descriptors and carries {found}"
),
Self::MalformedEpochRecord { line } => {
write!(f, "line {line} does not hold a readable epoch record")
}
Self::TruncatedEpoch {
line,
expected,
found,
} => write!(
f,
"the epoch at line {line} declares {expected} satellite records and carries {found}"
),
Self::UndeclaredSystem { line } => write!(
f,
"line {line} observes a constellation the header declares no descriptors for"
),
Self::MalformedObservationRecord { line } => {
write!(f, "line {line} does not hold a readable observation record")
}
}
}
}
impl std::error::Error for RinexObsError {}
fn column(line: &str, start: usize, end: usize) -> Option<&str> {
let end = end.min(line.len());
if start >= end {
return Some("");
}
line.get(start..end).map(str::trim)
}
fn label_of(line: &str) -> &str {
column(line, 60, 80).unwrap_or("")
}
fn integer_field(line: &str, start: usize, end: usize) -> Result<Option<u64>, ()> {
let raw = column(line, start, end).ok_or(())?;
if raw.is_empty() {
return Ok(None);
}
raw.parse().map(Some).map_err(|_| ())
}
fn float_field(line: &str, start: usize, end: usize) -> Result<Option<f64>, ()> {
let raw = column(line, start, end).ok_or(())?;
if raw.is_empty() {
return Ok(None);
}
let value: f64 = raw.parse().map_err(|_| ())?;
if value.is_finite() {
Ok(Some(value))
} else {
Err(())
}
}
fn triple_field(line: &str) -> Result<[f64; 3], ()> {
let mut out = [0.0; 3];
for (k, slot) in out.iter_mut().enumerate() {
*slot = float_field(line, k * 14, (k + 1) * 14)?.ok_or(())?;
}
Ok(out)
}
fn obs_types_on_line(line: &str) -> Result<Vec<ObsCode>, ()> {
let mut out = Vec::new();
for slot in 0..13 {
let start = 7 + 4 * slot;
let raw = column(line, start, start + 3).ok_or(())?;
if raw.is_empty() {
continue;
}
out.push(ObsCode::parse(raw).ok_or(())?);
}
Ok(out)
}
struct ObsTypeList {
line: usize,
declared: usize,
codes: Vec<ObsCode>,
}
fn absorb_obs_types(
line: &str,
lineno: usize,
lists: &mut BTreeMap<char, ObsTypeList>,
open: &mut Option<char>,
) -> Result<(), RinexObsError> {
let malformed = RinexObsError::MalformedHeaderRecord {
line: lineno,
label: OBS_TYPES_LABEL,
};
let system = column(line, 0, 1).ok_or(malformed)?.chars().next();
let codes = obs_types_on_line(line).map_err(|()| malformed)?;
let Some(sys) = system else {
let list = open.and_then(|s| lists.get_mut(&s)).ok_or(malformed)?;
list.codes.extend(codes);
return Ok(());
};
let declared = integer_field(line, 3, 6)
.map_err(|()| malformed)?
.ok_or(malformed)?;
let declared = usize::try_from(declared).map_err(|_| malformed)?;
if declared > MAX_OBS_TYPES {
return Err(malformed);
}
lists.insert(
sys,
ObsTypeList {
line: lineno,
declared,
codes,
},
);
*open = Some(sys);
Ok(())
}
fn seal_obs_types(
lists: BTreeMap<char, ObsTypeList>,
) -> Result<BTreeMap<char, Vec<ObsCode>>, RinexObsError> {
let mut out = BTreeMap::new();
for (system, list) in lists {
if list.codes.len() != list.declared {
return Err(RinexObsError::ObsTypeCountMismatch {
line: list.line,
expected: list.declared,
found: list.codes.len(),
});
}
out.insert(system, list.codes);
}
if out.is_empty() {
return Err(RinexObsError::MissingObsTypes);
}
Ok(out)
}
fn parse_version_record(line: &str) -> Result<(f64, char), RinexObsError> {
if label_of(line) != "RINEX VERSION / TYPE" {
return Err(RinexObsError::NotObservationData);
}
let version = float_field(line, 0, 9)
.ok()
.flatten()
.ok_or(RinexObsError::NotObservationData)?;
if column(line, 20, 21) != Some("O") {
return Err(RinexObsError::NotObservationData);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let major = version.trunc().clamp(0.0, 255.0) as u8;
if major != 3 {
return Err(RinexObsError::UnsupportedVersion { major });
}
let file_system = column(line, 40, 41)
.and_then(|s| s.chars().next())
.ok_or(RinexObsError::NotObservationData)?;
Ok((version, file_system))
}
fn parse_header(lines: &[&str]) -> Result<(ObsHeader, usize), RinexObsError> {
let first = lines.first().ok_or(RinexObsError::NotObservationData)?;
let (version, file_system) = parse_version_record(first)?;
let mut header = ObsHeader {
version,
file_system,
marker_name: String::new(),
approx_position: None,
antenna_delta_hen: None,
interval: None,
time_system: TimeSystem::Gps,
obs_types: BTreeMap::new(),
};
let mut lists: BTreeMap<char, ObsTypeList> = BTreeMap::new();
let mut open: Option<char> = None;
for (index, line) in lines.iter().enumerate().skip(1) {
let lineno = index + 1;
let label = label_of(line);
let malformed = |label: &'static str| RinexObsError::MalformedHeaderRecord {
line: lineno,
label,
};
match label {
"END OF HEADER" => {
header.obs_types = seal_obs_types(lists)?;
return Ok((header, index + 1));
}
"MARKER NAME" => {
header.marker_name = column(line, 0, 60)
.ok_or_else(|| malformed("MARKER NAME"))?
.into();
}
"APPROX POSITION XYZ" => {
header.approx_position =
Some(triple_field(line).map_err(|()| malformed("APPROX POSITION XYZ"))?);
}
"ANTENNA: DELTA H/E/N" => {
header.antenna_delta_hen =
Some(triple_field(line).map_err(|()| malformed("ANTENNA: DELTA H/E/N"))?);
}
"INTERVAL" => {
header.interval = float_field(line, 0, 10).map_err(|()| malformed("INTERVAL"))?;
}
"TIME OF FIRST OBS" => {
if let Some(system) = column(line, 48, 51).filter(|s| !s.is_empty()) {
header.time_system =
parse_time_system(system).ok_or_else(|| malformed("TIME OF FIRST OBS"))?;
}
}
OBS_TYPES_LABEL => absorb_obs_types(line, lineno, &mut lists, &mut open)?,
_ => {}
}
}
Err(RinexObsError::NoEndOfHeader)
}
fn epoch_seconds(line: &str) -> Result<f64, ()> {
let year = integer_field(line, 2, 6)?.ok_or(())?;
let month = integer_field(line, 7, 9)?.ok_or(())?;
let day = integer_field(line, 10, 12)?.ok_or(())?;
let hour = integer_field(line, 13, 15)?.ok_or(())?;
let minute = integer_field(line, 16, 18)?.ok_or(())?;
let second = float_field(line, 18, 29)?.ok_or(())?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err(());
}
if hour > 23 || minute > 59 || !(0.0..61.0).contains(&second) {
return Err(());
}
let (y, m, d) = (
i64::try_from(year).map_err(|_| ())?,
i64::try_from(month).map_err(|_| ())?,
i64::try_from(day).map_err(|_| ())?,
);
#[allow(clippy::cast_precision_loss)]
let days = jdn(y, m, d) as f64;
let hms = u32::try_from(hour * 3600 + minute * 60).map_err(|_| ())?;
Ok(days.mul_add(86_400.0, second) + f64::from(hms) - JDN_SECONDS_AT_J2000)
}
fn parse_observation_record(
line: &str,
lineno: usize,
obs_types: &BTreeMap<char, Vec<ObsCode>>,
) -> Result<SatObservation, RinexObsError> {
let malformed = RinexObsError::MalformedObservationRecord { line: lineno };
let id = column(line, 0, OBSERVATION_START).ok_or(malformed)?;
let sat = parse_sat_id(id).ok_or(malformed)?;
let codes = obs_types
.get(&sat.system)
.ok_or(RinexObsError::UndeclaredSystem { line: lineno })?;
let mut values = Vec::with_capacity(codes.len());
for slot in 0..codes.len() {
let start = OBSERVATION_START + OBSERVATION_WIDTH * slot;
let Some(value) = float_field(line, start, start + 14).map_err(|()| malformed)? else {
values.push(None);
continue;
};
let digit = |from: usize| -> Result<Option<u8>, RinexObsError> {
match integer_field(line, from, from + 1).map_err(|()| malformed)? {
Some(d) => u8::try_from(d).map(Some).map_err(|_| malformed),
None => Ok(None),
}
};
values.push(Some(Observation {
value,
lli: digit(start + 14)?,
signal_strength: digit(start + 15)?,
}));
}
Ok(SatObservation { sat, values })
}
impl RinexObs {
pub fn parse(text: &str) -> Result<Self, RinexObsError> {
let lines: Vec<&str> = text.lines().collect();
let (header, start) = parse_header(&lines)?;
let mut records = lines.iter().enumerate().skip(start);
let mut epochs = Vec::new();
while let Some((position, line)) = records.next() {
if !line.starts_with('>') {
continue;
}
let lineno = position + 1;
let malformed = RinexObsError::MalformedEpochRecord { line: lineno };
let flag = integer_field(line, 31, 32)
.map_err(|()| malformed)?
.ok_or(malformed)?;
let count = integer_field(line, 32, 35)
.map_err(|()| malformed)?
.ok_or(malformed)?;
let count = usize::try_from(count).map_err(|_| malformed)?;
if flag >= 2 {
records.by_ref().take(count).for_each(drop);
continue;
}
let seconds_j2000 = epoch_seconds(line).map_err(|()| malformed)?;
let receiver_clock_offset = float_field(line, 41, 56).map_err(|()| malformed)?;
let mut satellites = Vec::new();
while satellites.len() < count {
let Some((at, record)) = records.next().filter(|(_, l)| !l.starts_with('>')) else {
return Err(RinexObsError::TruncatedEpoch {
line: lineno,
expected: count,
found: satellites.len(),
});
};
satellites.push(parse_observation_record(record, at + 1, &header.obs_types)?);
}
epochs.push(ObsEpoch {
seconds_j2000,
power_failure: flag == 1,
receiver_clock_offset,
satellites,
});
}
Ok(Self { header, epochs })
}
#[must_use]
pub fn slot_of(&self, system: char, code: ObsCode) -> Option<usize> {
self.header
.obs_types
.get(&system)?
.iter()
.position(|c| *c == code)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn pad(body: &str, label: &str) -> String {
format!("{body:<60}{label}")
}
fn fixture() -> String {
[
pad(
" 3.05 OBSERVATION DATA M: MIXED",
"RINEX VERSION / TYPE",
),
pad("G 2 C1C L1C", "SYS / # / OBS TYPES"),
pad("R 1 C1C", "SYS / # / OBS TYPES"),
pad("END", "END OF HEADER"),
"> 2024 01 09 12 00 0.0000000 0 2".into(),
"G07 23629347.915 6 124144623.325 7".into(),
"R05 21000000.000".into(),
]
.join("\n")
}
#[test]
fn a_minimal_file_parses() {
let obs = RinexObs::parse(&fixture()).unwrap();
assert_eq!(obs.epochs.len(), 1);
assert_eq!(obs.epochs[0].satellites.len(), 2);
assert_eq!(obs.header.obs_types[&'G'].len(), 2);
assert_eq!(obs.header.obs_types[&'R'].len(), 1);
}
#[test]
fn a_miscounted_observation_type_list_is_refused() {
let text = fixture().replace("G 2 C1C L1C", "G 3 C1C L1C");
assert_eq!(
RinexObs::parse(&text),
Err(RinexObsError::ObsTypeCountMismatch {
line: 2,
expected: 3,
found: 2,
})
);
}
#[test]
fn the_header_gates_are_each_reachable() {
let text = fixture();
assert_eq!(
RinexObs::parse(&text.replace("OBSERVATION DATA", "NAVIGATION DATA ")),
Err(RinexObsError::NotObservationData)
);
assert_eq!(
RinexObs::parse(&text.replace(" 3.05", " 2.11")),
Err(RinexObsError::UnsupportedVersion { major: 2 })
);
assert_eq!(
RinexObs::parse(&text.replace("END OF HEADER", "COMMENT ")),
Err(RinexObsError::NoEndOfHeader)
);
assert_eq!(RinexObs::parse(""), Err(RinexObsError::NotObservationData));
}
#[test]
fn the_header_ends_at_the_line_after_its_terminator() {
let text = fixture();
let lines: Vec<&str> = text.lines().collect();
let (_, index) = parse_header(&lines).unwrap();
assert_eq!(index, 4);
assert_eq!(label_of(lines[index - 1]), "END OF HEADER");
assert!(
lines[index].starts_with('>'),
"the data section starts at the first epoch record"
);
}
#[test]
fn a_header_without_observation_types_is_refused() {
let text: String = fixture()
.lines()
.filter(|l| !l.contains("SYS / # / OBS TYPES"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(RinexObs::parse(&text), Err(RinexObsError::MissingObsTypes));
}
#[test]
fn a_descriptor_list_continues_onto_a_second_line() {
let first: Vec<String> = (0..13).map(|k| format!("C{}C", k % 10)).collect();
let text = [
pad(
" 3.05 OBSERVATION DATA G: GPS",
"RINEX VERSION / TYPE",
),
pad(&format!("G 14 {}", first.join(" ")), OBS_TYPES_LABEL),
pad(" L1C", OBS_TYPES_LABEL),
pad("END", "END OF HEADER"),
]
.join("\n");
let obs = RinexObs::parse(&text).unwrap();
let codes = &obs.header.obs_types[&'G'];
assert_eq!(codes.len(), 14);
assert_eq!(
codes[13],
ObsCode {
kind: ObsKind::CarrierPhase,
band: '1',
attribute: Some('C'),
}
);
assert_eq!(obs.slot_of('G', codes[13]), Some(13));
}
#[test]
fn the_descriptor_count_cap_binds_above_itself() {
let codes: Vec<String> = (0..MAX_OBS_TYPES)
.map(|k| {
let attribute = char::from(b'A' + u8::try_from(k / 10).unwrap());
format!("C{}{attribute}", k % 10)
})
.collect();
let header = |declared: usize| {
let mut records = vec![pad(
" 3.05 OBSERVATION DATA G: GPS",
"RINEX VERSION / TYPE",
)];
for (record, chunk) in codes.chunks(13).enumerate() {
let body = if record == 0 {
format!("G {declared:>3} {}", chunk.join(" "))
} else {
format!(" {}", chunk.join(" "))
};
records.push(pad(&body, OBS_TYPES_LABEL));
}
records.push(pad("END", "END OF HEADER"));
records.join("\n")
};
let obs = RinexObs::parse(&header(MAX_OBS_TYPES)).unwrap();
assert_eq!(obs.header.obs_types[&'G'].len(), MAX_OBS_TYPES);
assert_eq!(
RinexObs::parse(&header(MAX_OBS_TYPES + 1)),
Err(RinexObsError::MalformedHeaderRecord {
line: 2,
label: OBS_TYPES_LABEL,
})
);
}
#[test]
fn the_time_of_first_obs_record_names_the_time_scale() {
let file = |body: &str| {
[
pad(
" 3.05 OBSERVATION DATA R: GLONASS",
"RINEX VERSION / TYPE",
),
pad("R 1 C1C", OBS_TYPES_LABEL),
pad(body, "TIME OF FIRST OBS"),
pad("END", "END OF HEADER"),
]
.join("\n")
};
let calendar = " 2024 1 9 12 0 0.0000000";
let named = RinexObs::parse(&file(&format!("{calendar} GLO"))).unwrap();
assert_eq!(named.header.time_system, TimeSystem::Glo);
let blank = RinexObs::parse(&file(calendar)).unwrap();
assert_eq!(blank.header.time_system, TimeSystem::Gps);
assert_eq!(
RinexObs::parse(&file(&format!("{calendar} XXX"))),
Err(RinexObsError::MalformedHeaderRecord {
line: 3,
label: "TIME OF FIRST OBS",
})
);
}
#[test]
fn an_orphaned_continuation_line_is_refused() {
let text = fixture().replace("G 2 C1C L1C", " C1C L1C");
assert_eq!(
RinexObs::parse(&text),
Err(RinexObsError::MalformedHeaderRecord {
line: 2,
label: "SYS / # / OBS TYPES",
})
);
}
#[test]
fn a_truncated_epoch_is_refused() {
let text = fixture().replace(" 0 2", " 0 3");
assert_eq!(
RinexObs::parse(&text),
Err(RinexObsError::TruncatedEpoch {
line: 5,
expected: 3,
found: 2,
})
);
}
#[test]
fn an_out_of_range_epoch_calendar_is_refused() {
let epoch = |month: u32, day: u32, hour: u32, minute: u32, second: f64| {
format!("> 2024 {month:02} {day:02} {hour:02} {minute:02}{second:11.7} 0 0")
};
for (month, day, hour, minute, second) in [
(1, 1, 0, 0, 0.0),
(12, 31, 23, 59, 59.999_999_9),
(12, 31, 23, 59, 60.0),
] {
assert!(
epoch_seconds(&epoch(month, day, hour, minute, second)).is_ok(),
"{month:02}-{day:02} {hour:02}:{minute:02}:{second} is a legal epoch"
);
}
for (month, day, hour, minute, second, why) in [
(0, 1, 0, 0, 0.0, "month 0"),
(13, 1, 0, 0, 0.0, "month 13"),
(1, 0, 0, 0, 0.0, "day 0"),
(1, 32, 0, 0, 0.0, "day 32"),
(1, 1, 24, 0, 0.0, "hour 24"),
(1, 1, 0, 60, 0.0, "minute 60"),
(1, 1, 0, 0, 61.0, "second 61"),
(1, 1, 0, 0, -0.001, "a negative second"),
] {
assert!(
epoch_seconds(&epoch(month, day, hour, minute, second)).is_err(),
"{why} is not a legal epoch"
);
}
let last = epoch_seconds(&epoch(12, 31, 23, 59, 59.0)).unwrap();
let leap = epoch_seconds(&epoch(12, 31, 23, 59, 60.0)).unwrap();
assert!((leap - last - 1.0).abs() < 1e-9, "{leap} vs {last}");
assert_eq!(
RinexObs::parse(&fixture().replace("> 2024 01 09", "> 2024 01 32")),
Err(RinexObsError::MalformedEpochRecord { line: 5 })
);
}
#[test]
fn an_undeclared_constellation_is_refused() {
let text = fixture().replace("R05 21000000.000", "E05 21000000.000");
assert_eq!(
RinexObs::parse(&text),
Err(RinexObsError::UndeclaredSystem { line: 7 })
);
}
#[test]
fn every_error_prints_a_distinct_message() {
let errors = [
RinexObsError::NotObservationData,
RinexObsError::UnsupportedVersion { major: 2 },
RinexObsError::NoEndOfHeader,
RinexObsError::MissingObsTypes,
RinexObsError::MalformedHeaderRecord {
line: 3,
label: "INTERVAL",
},
RinexObsError::ObsTypeCountMismatch {
line: 4,
expected: 3,
found: 2,
},
RinexObsError::MalformedEpochRecord { line: 5 },
RinexObsError::TruncatedEpoch {
line: 6,
expected: 2,
found: 1,
},
RinexObsError::UndeclaredSystem { line: 7 },
RinexObsError::MalformedObservationRecord { line: 8 },
];
let messages: Vec<String> = errors.iter().map(ToString::to_string).collect();
assert!(messages.iter().all(|m| !m.is_empty()));
let distinct: std::collections::HashSet<&String> = messages.iter().collect();
assert_eq!(distinct.len(), errors.len(), "duplicate: {messages:?}");
}
#[test]
fn event_records_are_stepped_over_by_their_own_count() {
let mut text = fixture();
text.push_str("\n> 2024 01 09 12 00 30.0000000 4 9");
text.push_str(&"\nAN EVENT LINE, CARRYING NO OBSERVATION".repeat(9));
text.push_str(
"\n> 2024 01 09 12 01 0.0000000 0 2\n\
G07 23629350.000 6 124144630.000 7\n\
R05 21000010.000",
);
let obs = RinexObs::parse(&text).unwrap();
assert_eq!(obs.epochs.len(), 2, "the event was read as an epoch");
assert_eq!(obs.epochs[1].satellites.len(), 2);
assert!((obs.epochs[1].seconds_j2000 - obs.epochs[0].seconds_j2000 - 60.0).abs() < 1e-9);
}
#[test]
fn a_line_that_opens_no_epoch_is_stepped_over() {
let text = fixture().replace("> 2024 01 09", "THIS LINE OPENS NO EPOCH\n> 2024 01 09");
let obs = RinexObs::parse(&text).unwrap();
assert_eq!(obs.epochs.len(), 1);
assert_eq!(obs.epochs[0].satellites.len(), 2);
}
#[test]
fn observation_codes_parse_and_print() {
for code in ["C1C", "L2W", "D5Q", "S1X", "I1 ", "X1 "] {
let parsed = ObsCode::parse(code).unwrap();
assert_eq!(parsed.to_string(), code, "{code}");
}
assert!(ObsCode::parse("Z1C").is_none(), "unknown kind letter");
assert!(ObsCode::parse("CXC").is_none(), "non-digit band");
assert!(
ObsCode::parse("C1-").is_none(),
"non-alphanumeric attribute"
);
}
#[test]
fn loss_of_lock_reads_bit_zero_only() {
let with = |lli: Option<u8>| Observation {
value: 1.0,
lli,
signal_strength: None,
};
assert!(!with(None).lost_lock());
assert!(!with(Some(0)).lost_lock());
assert!(with(Some(1)).lost_lock());
assert!(
!with(Some(2)).lost_lock(),
"half-cycle ambiguity is not a slip"
);
assert!(with(Some(3)).lost_lock());
}
}