use super::{FileLocation, FormatError, ParseMode};
use chrono::{DateTime, Datelike, NaiveDate, Timelike, Utc as ChronoUtc};
use qtty::angular::Radians;
use qtty::angular_rate::AngularRate;
use qtty::length::Meters;
use qtty::time::Seconds;
use qtty::unit::{Radian, Second};
use std::fs;
use std::io::Write;
use std::path::Path;
use tempoch::{Time, UTC};
#[derive(Debug, Clone)]
pub struct GpsNavRecord {
pub prn: u8,
pub toc: Time<UTC>,
pub af0: Seconds,
pub af1: f64,
pub af2: f64,
pub iode: f64,
pub crs: Meters,
pub delta_n: AngularRate<Radian, Second>,
pub m0: Radians,
pub cuc: Radians,
pub e: f64,
pub cus: Radians,
pub sqrt_a: f64,
pub toe: Seconds,
pub cic: Radians,
pub omega0: Radians,
pub cis: Radians,
pub i0: Radians,
pub crc: Meters,
pub omega: Radians,
pub omega_dot: AngularRate<Radian, Second>,
pub idot: AngularRate<Radian, Second>,
}
impl Default for GpsNavRecord {
fn default() -> Self {
let naive = NaiveDate::from_ymd_opt(2000, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
Self {
prn: 0,
toc: Time::<UTC>::try_from_chrono(dt).unwrap(),
af0: Seconds::new(0.0),
af1: 0.0,
af2: 0.0,
iode: 0.0,
crs: Meters::new(0.0),
delta_n: AngularRate::new(0.0),
m0: Radians::new(0.0),
cuc: Radians::new(0.0),
e: 0.0,
cus: Radians::new(0.0),
sqrt_a: 0.0,
toe: Seconds::new(0.0),
cic: Radians::new(0.0),
omega0: Radians::new(0.0),
cis: Radians::new(0.0),
i0: Radians::new(0.0),
crc: Meters::new(0.0),
omega: Radians::new(0.0),
omega_dot: AngularRate::new(0.0),
idot: AngularRate::new(0.0),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RinexNavFile {
pub gps: Vec<GpsNavRecord>,
}
pub fn read_rinex_nav<P: AsRef<Path>>(path: P) -> Result<RinexNavFile, FormatError> {
let text = fs::read_to_string(path)?;
parse_rinex_nav(&text)
}
pub fn parse_rinex_nav(text: &str) -> Result<RinexNavFile, FormatError> {
parse_rinex_nav_with_mode(text, ParseMode::Strict)
}
pub fn parse_rinex_nav_with_mode(text: &str, mode: ParseMode) -> Result<RinexNavFile, FormatError> {
let mut out = RinexNavFile::default();
let mut lines = text.lines().enumerate();
let mut found_header = false;
for (_, l) in lines.by_ref() {
if l.contains("END OF HEADER") {
found_header = true;
break;
}
}
if !found_header && mode == ParseMode::Strict {
return Err(FormatError::Format(
"RINEX NAV: END OF HEADER marker not found".to_owned(),
));
}
let collected: Vec<(usize, &str)> = lines.collect();
let mut i = 0;
while i < collected.len() {
let (line_no, l0) = collected[i];
let line_no_1 = line_no + 1;
if l0.len() < 4 || !(l0.starts_with('G') || l0.as_bytes()[0].is_ascii_digit()) {
i += 1;
continue;
}
if i + 7 >= collected.len() {
if mode == ParseMode::Strict {
return Err(FormatError::located(
"RINEX 3.05 §6.5",
FileLocation::at_line(line_no_1),
"truncated GPS record: fewer than 8 lines",
));
}
break;
}
let prn_result = if l0.starts_with('G') {
l0[1..3].trim().parse::<u8>()
} else {
l0[..3].trim().parse::<u8>()
};
let prn = match prn_result {
Ok(p) if p > 0 => p,
Ok(_) | Err(_) => {
if mode == ParseMode::Strict {
return Err(FormatError::located(
"RINEX 3.05 §6.3",
FileLocation::at_line(line_no_1),
format!("invalid GPS PRN: {:?}", &l0[..l0.len().min(3)]),
));
}
i += 8;
continue;
}
};
let rest = &l0[3..];
let toc_tokens: Vec<&str> = rest.split_whitespace().collect();
let header_result = (|| -> Result<(Time<UTC>, f64, f64, f64), FormatError> {
if toc_tokens.len() < 9 {
return Err(FormatError::located(
"RINEX 3.05 §6.3",
FileLocation::at_line(line_no_1),
format!(
"GPS record line 0 needs ≥9 whitespace tokens, got {}",
toc_tokens.len()
),
));
}
let parse_int = |s: &str, field: &str| -> Result<i64, FormatError> {
s.parse::<i64>().map_err(|_| {
FormatError::located(
"RINEX 3.05 §6.3",
FileLocation::at_line(line_no_1),
format!("invalid {field} in TOC: {s:?}"),
)
})
};
let y = parse_int(toc_tokens[0], "year")?;
let mo = parse_int(toc_tokens[1], "month")?;
let d = parse_int(toc_tokens[2], "day")?;
let h = parse_int(toc_tokens[3], "hour")?;
let mi = parse_int(toc_tokens[4], "minute")?;
let sec_f = parse_d(toc_tokens[5], line_no_1, "second")?;
let sec_i = sec_f as u64;
let nanos = ((sec_f - sec_i as f64) * 1e9).round() as u32;
let toc = NaiveDate::from_ymd_opt(y as i32, mo as u32, d as u32)
.and_then(|nd| nd.and_hms_nano_opt(h as u32, mi as u32, sec_i as u32, nanos))
.and_then(|naive| {
let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
Time::<UTC>::try_from_chrono(dt).ok()
})
.ok_or_else(|| {
FormatError::located(
"RINEX 3.05 §6.3",
FileLocation::at_line(line_no_1),
format!(
"invalid TOC date/time: {}-{:02}-{:02} {:02}:{:02}:{:.3}",
toc_tokens[0], mo, d, h, mi, sec_f
),
)
})?;
let af0 = parse_d(toc_tokens[6], line_no_1, "af0")?;
let af1 = parse_d(toc_tokens[7], line_no_1, "af1")?;
let af2 = parse_d(toc_tokens[8], line_no_1, "af2")?;
Ok((toc, af0, af1, af2))
})();
let (toc, af0, af1, af2) = match header_result {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let parse_body = |idx: usize, expected: usize| -> Result<Vec<f64>, FormatError> {
let (ln, text) = collected[i + idx];
let vals = collect_floats(text, ln + 1)?;
if vals.len() < expected && mode == ParseMode::Strict {
return Err(FormatError::located(
"RINEX 3.05 §6.5",
FileLocation::at_line(ln + 1),
format!(
"GPS broadcast body line {idx}: expected ≥{expected} fields, got {}",
vals.len()
),
));
}
Ok(vals)
};
let v1 = match parse_body(1, 4) {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let v2 = match parse_body(2, 4) {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let v3 = match parse_body(3, 4) {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let v4 = match parse_body(4, 4) {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let v5 = match parse_body(5, 1) {
Ok(v) => v,
Err(e) => {
if mode == ParseMode::Strict {
return Err(e);
}
i += 8;
continue;
}
};
let rec = GpsNavRecord {
prn,
toc,
af0: Seconds::new(af0),
af1,
af2,
iode: v1[0],
crs: Meters::new(v1[1]),
delta_n: AngularRate::new(v1[2]),
m0: Radians::new(v1[3]),
cuc: Radians::new(v2[0]),
e: v2[1],
cus: Radians::new(v2[2]),
sqrt_a: v2[3],
toe: Seconds::new(v3[0]),
cic: Radians::new(v3[1]),
omega0: Radians::new(v3[2]),
cis: Radians::new(v3[3]),
i0: Radians::new(v4[0]),
crc: Meters::new(v4[1]),
omega: Radians::new(v4[2]),
omega_dot: AngularRate::new(v4[3]),
idot: AngularRate::new(v5[0]),
};
out.gps.push(rec);
i += 8;
}
Ok(out)
}
fn parse_d(s: &str, line: usize, field: &str) -> Result<f64, FormatError> {
s.replace('D', "E")
.replace('d', "e")
.parse::<f64>()
.map_err(|_| {
FormatError::located(
"RINEX 3.05 §6.5",
FileLocation::at_line(line),
format!("invalid float for field {field}: {s:?}"),
)
})
}
fn collect_floats(line: &str, line_no: usize) -> Result<Vec<f64>, FormatError> {
line.split_whitespace()
.map(|s| parse_d(s, line_no, "body"))
.collect()
}
fn fmt_d(v: f64) -> String {
if !v.is_finite() {
return format!("{:>19}", "0.000000000000000D+00");
}
let s = format!("{:19.12E}", v);
s.replace('E', "D")
}
pub fn write_rinex_nav<W: Write>(w: &mut W, file: &RinexNavFile) -> Result<(), FormatError> {
fn header_line(w: &mut impl Write, body: &str, label: &str) -> std::io::Result<()> {
let body = if body.len() > 60 { &body[..60] } else { body };
writeln!(w, "{:<60}{}", body, label)
}
header_line(
w,
" 3.04 N: GNSS NAV DATA M (Mixed)",
"RINEX VERSION / TYPE",
)?;
header_line(w, "", "END OF HEADER")?;
for r in &file.gps {
let dt = r
.toc
.try_to_chrono()
.map_err(|e| FormatError::Format(format!("rinex_nav: TOC to chrono failed: {e}")))?;
let sec = dt.second() as f64 + dt.nanosecond() as f64 / 1e9;
writeln!(
w,
"G{:02} {:04} {:02} {:02} {:02} {:02} {:02}{}{}{}",
r.prn,
dt.year(),
dt.month(),
dt.day(),
dt.hour(),
dt.minute(),
sec as u32,
fmt_d(r.af0.value()),
fmt_d(r.af1),
fmt_d(r.af2),
)?;
let cont = |w: &mut W, vs: [f64; 4]| -> std::io::Result<()> {
writeln!(
w,
" {}{}{}{}",
fmt_d(vs[0]),
fmt_d(vs[1]),
fmt_d(vs[2]),
fmt_d(vs[3])
)
};
cont(w, [r.iode, r.crs.value(), r.delta_n.value(), r.m0.value()])?;
cont(w, [r.cuc.value(), r.e, r.cus.value(), r.sqrt_a])?;
cont(
w,
[
r.toe.value(),
r.cic.value(),
r.omega0.value(),
r.cis.value(),
],
)?;
cont(
w,
[
r.i0.value(),
r.crc.value(),
r.omega.value(),
r.omega_dot.value(),
],
)?;
cont(w, [r.idot.value(), 0.0, 0.0, 0.0])?;
cont(w, [0.0, 0.0, 0.0, 0.0])?;
cont(w, [0.0, 0.0, 0.0, 0.0])?;
}
Ok(())
}
pub fn write_rinex_nav_to_path<P: AsRef<Path>>(
path: P,
file: &RinexNavFile,
) -> Result<(), FormatError> {
let mut f = fs::File::create(path)?;
write_rinex_nav(&mut f, file)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_one_gps_record() {
let txt = "\
3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\
END OF HEADER\n\
G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\
1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\
5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\
5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\
9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\
1.000E-10\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n";
let f = parse_rinex_nav(txt).expect("parse");
assert_eq!(f.gps.len(), 1);
let r = &f.gps[0];
assert_eq!(r.prn, 1);
assert!((r.sqrt_a - 5_153.651).abs() < 1e-3);
assert!((r.e - 1e-3).abs() < 1e-9);
assert!((r.toe.value() - 518_400.0).abs() < 1e-3);
}
#[test]
fn round_trips_through_writer() {
let txt = "\
3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\
END OF HEADER\n\
G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\
1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\
5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\
5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\
9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\
1.000E-10\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n";
let f = parse_rinex_nav(txt).unwrap();
let mut buf = Vec::new();
write_rinex_nav(&mut buf, &f).unwrap();
let f2 = parse_rinex_nav(std::str::from_utf8(&buf).unwrap()).unwrap();
assert_eq!(f.gps.len(), f2.gps.len());
let r = &f.gps[0];
let r2 = &f2.gps[0];
assert_eq!(r.prn, r2.prn);
assert!((r.sqrt_a - r2.sqrt_a).abs() < 1e-9);
assert!((r.e - r2.e).abs() < 1e-15);
assert!((r.toe.value() - r2.toe.value()).abs() < 1e-9);
assert!((r.m0.value() - r2.m0.value()).abs() < 1e-12);
}
const VALID_RECORD: &str = "\
3.04 N: GNSS NAV DATA M (Mixed) RINEX VERSION / TYPE\n\
END OF HEADER\n\
G01 2024 01 01 00 00 00 1.234567E-04 5.678E-12 0.000E+00\n\
1.000000E+00 2.000000E+01 3.456E-09 1.234567E+00\n\
5.000E-07 1.000E-03 9.000E-07 5.153651E+03\n\
5.184000E+05 1.000E-08 1.000E+00 2.000E-08\n\
9.760000E-01 2.500E+02 -1.500E+00 -8.000E-09\n\
1.000E-10\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n\
0.000E+00 0.000E+00 0.000E+00 0.000E+00\n";
#[test]
fn strict_rejects_bad_float_field() {
use crate::formats::ParseMode;
let bad = VALID_RECORD.replace("1.234567E-04", "NOTAFLOAT");
assert!(
parse_rinex_nav(&bad).is_err(),
"strict mode must reject malformed float"
);
let f = parse_rinex_nav_with_mode(&bad, ParseMode::Permissive).unwrap();
assert_eq!(
f.gps.len(),
0,
"permissive mode must skip the malformed record"
);
}
#[test]
fn strict_rejects_invalid_date() {
use crate::formats::ParseMode;
let bad = VALID_RECORD.replace("2024 01 01", "2024 99 01");
assert!(
parse_rinex_nav(&bad).is_err(),
"strict mode must reject out-of-range month"
);
let f = parse_rinex_nav_with_mode(&bad, ParseMode::Permissive).unwrap();
assert_eq!(
f.gps.len(),
0,
"permissive mode must skip the record with invalid date"
);
}
#[test]
fn strict_rejects_truncated_record() {
use crate::formats::ParseMode;
let lines: Vec<&str> = VALID_RECORD.lines().collect();
let truncated = lines[..lines.len() - 2].join("\n") + "\n";
assert!(
parse_rinex_nav(&truncated).is_err(),
"strict mode must reject a truncated record"
);
assert!(parse_rinex_nav_with_mode(&truncated, ParseMode::Permissive).is_ok());
}
}