#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EpochUtc {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct RinexEphemeris {
pub system: char,
pub prn: u8,
pub toc: EpochUtc,
pub af0: f64,
pub af1: f64,
pub af2: f64,
pub iode: f64,
pub crs: f64,
pub delta_n: f64,
pub m0: f64,
pub cuc: f64,
pub e: f64,
pub cus: f64,
pub sqrt_a: f64,
pub toe: f64,
pub cic: f64,
pub omega0: f64,
pub cis: f64,
pub i0: f64,
pub crc: f64,
pub omega: f64,
pub omega_dot: f64,
pub idot: f64,
pub gps_week: f64,
pub sv_accuracy: f64,
pub sv_health: f64,
pub tgd: f64,
pub iodc: f64,
pub trans_time: f64,
}
pub fn parse_d(s: &str) -> Result<f64, String> {
let t = s.trim();
if t.is_empty() {
return Ok(0.0);
}
let normalized = t.replace(['D', 'd'], "E");
normalized
.parse::<f64>()
.map_err(|_| format!("not a number: {s:?}"))
}
fn col(line: &str, lo: usize, hi: usize) -> &str {
let n = line.len();
if lo >= n {
return "";
}
&line[lo..hi.min(n)]
}
fn orbit_fields(line: &str) -> Result<[f64; 4], String> {
Ok([
parse_d(col(line, 4, 23))?,
parse_d(col(line, 23, 42))?,
parse_d(col(line, 42, 61))?,
parse_d(col(line, 61, 80))?,
])
}
pub fn parse_nav(text: &str) -> Result<Vec<RinexEphemeris>, String> {
let lines: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < lines.len() {
let done = lines[i].contains("END OF HEADER");
i += 1;
if done {
break;
}
}
let mut out = Vec::new();
while i < lines.len() {
let head = lines[i];
if head.trim().is_empty() {
i += 1;
continue;
}
let system = head.chars().next().unwrap_or(' ');
if i + 7 >= lines.len() {
break;
}
if system != 'G' {
i += 8;
continue;
}
let prn: u8 = col(head, 1, 3)
.trim()
.parse()
.map_err(|_| format!("bad PRN in {head:?}"))?;
let toc = EpochUtc {
year: col(head, 4, 8).trim().parse().map_err(|_| "bad year")?,
month: col(head, 9, 11).trim().parse().map_err(|_| "bad month")?,
day: col(head, 12, 14).trim().parse().map_err(|_| "bad day")?,
hour: col(head, 15, 17).trim().parse().map_err(|_| "bad hour")?,
minute: col(head, 18, 20).trim().parse().map_err(|_| "bad minute")?,
second: parse_d(col(head, 21, 23))?,
};
let af0 = parse_d(col(head, 23, 42))?;
let af1 = parse_d(col(head, 42, 61))?;
let af2 = parse_d(col(head, 61, 80))?;
let l1 = orbit_fields(lines[i + 1])?;
let l2 = orbit_fields(lines[i + 2])?;
let l3 = orbit_fields(lines[i + 3])?;
let l4 = orbit_fields(lines[i + 4])?;
let l5 = orbit_fields(lines[i + 5])?;
let l6 = orbit_fields(lines[i + 6])?;
let l7 = orbit_fields(lines[i + 7])?;
out.push(RinexEphemeris {
system,
prn,
toc,
af0,
af1,
af2,
iode: l1[0],
crs: l1[1],
delta_n: l1[2],
m0: l1[3],
cuc: l2[0],
e: l2[1],
cus: l2[2],
sqrt_a: l2[3],
toe: l3[0],
cic: l3[1],
omega0: l3[2],
cis: l3[3],
i0: l4[0],
crc: l4[1],
omega: l4[2],
omega_dot: l4[3],
idot: l5[0],
gps_week: l5[2],
sv_accuracy: l6[0],
sv_health: l6[1],
tgd: l6[2],
iodc: l6[3],
trans_time: l7[0],
});
i += 8;
}
Ok(out)
}
const MU_GPS: f64 = 3.986_005e14;
const OMEGA_E_DOT: f64 = 7.292_115_146_7e-5;
const F_REL: f64 = -4.442_807_633e-10;
fn julian_day_number(year: i64, month: i64, day: i64) -> i64 {
let a = (14 - month) / 12;
let y = year + 4800 - a;
let m = month + 12 * a - 3;
day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}
impl EpochUtc {
pub fn gps_time_of_week(&self) -> f64 {
let days = julian_day_number(self.year as i64, self.month as i64, self.day as i64)
- julian_day_number(1980, 1, 6);
let day_of_week = days.rem_euclid(7) as f64;
day_of_week * 86_400.0 + self.hour as f64 * 3600.0 + self.minute as f64 * 60.0 + self.second
}
}
impl RinexEphemeris {
pub fn semi_major_axis(&self) -> f64 {
self.sqrt_a * self.sqrt_a
}
fn tk_and_eccentric_anomaly(&self, t_tow_s: f64) -> (f64, f64) {
let a = self.semi_major_axis();
let n0 = (MU_GPS / (a * a * a)).sqrt();
let mut tk = t_tow_s - self.toe;
if tk > 302_400.0 {
tk -= 604_800.0;
} else if tk < -302_400.0 {
tk += 604_800.0;
}
let mk = self.m0 + (n0 + self.delta_n) * tk;
let mut ek = mk;
for _ in 0..30 {
let d = (ek - self.e * ek.sin() - mk) / (1.0 - self.e * ek.cos());
ek -= d;
if d.abs() < 1e-13 {
break;
}
}
(tk, ek)
}
pub fn sv_clock_bias_s(&self, t_tow_s: f64) -> f64 {
let toc_tow = self.toc.gps_time_of_week();
let mut dt = t_tow_s - toc_tow;
if dt > 302_400.0 {
dt -= 604_800.0;
} else if dt < -302_400.0 {
dt += 604_800.0;
}
let (_, ek) = self.tk_and_eccentric_anomaly(t_tow_s);
let dtr = F_REL * self.e * self.sqrt_a * ek.sin();
self.af0 + self.af1 * dt + self.af2 * dt * dt + dtr
}
pub fn sv_position_ecef(&self, t_tow_s: f64) -> [f64; 3] {
let a = self.semi_major_axis();
let (tk, ek) = self.tk_and_eccentric_anomaly(t_tow_s);
let nu = ((1.0 - self.e * self.e).sqrt() * ek.sin()).atan2(ek.cos() - self.e);
let phi = nu + self.omega;
let (s2, c2) = ((2.0 * phi).sin(), (2.0 * phi).cos());
let du = self.cus * s2 + self.cuc * c2;
let dr = self.crs * s2 + self.crc * c2;
let di = self.cis * s2 + self.cic * c2;
let u = phi + du;
let r = a * (1.0 - self.e * ek.cos()) + dr;
let i = self.i0 + di + self.idot * tk;
let (xp, yp) = (r * u.cos(), r * u.sin());
let omega_k = self.omega0 + (self.omega_dot - OMEGA_E_DOT) * tk - OMEGA_E_DOT * self.toe;
let (so, co) = (omega_k.sin(), omega_k.cos());
let ci = i.cos();
[xp * co - yp * ci * so, xp * so + yp * ci * co, yp * i.sin()]
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
3.04 N: GNSS NAV DATA G: GPS RINEX VERSION / TYPE
END OF HEADER
G01 2023 01 01 00 00 00 4.567890123456D-04 1.136868377216D-12 0.000000000000D+00
6.500000000000D+01-1.234375000000D+01 4.567890123456D-09-1.234567890123D+00
-6.146728992462D-07 1.234567890123D-02 7.430091500282D-06 5.153679868698D+03
1.728000000000D+05 1.117587089539D-08-1.234567890123D+00 7.450580596924D-09
9.876543210987D-01 2.612500000000D+02 5.678901234567D-01-8.123456789012D-09
-2.345678901234D-10 1.000000000000D+00 2.244000000000D+03 0.000000000000D+00
2.000000000000D+00 0.000000000000D+00-1.117587089539D-08 6.500000000000D+01
1.674000000000D+05 4.000000000000D+00 0.000000000000D+00 0.000000000000D+00";
#[test]
fn parse_d_handles_fortran_exponent() {
assert!((parse_d("-1.234567890123D-09").unwrap() - -1.234_567_890_123e-9).abs() < 1e-24);
assert!((parse_d(" 5.153679868698D+03").unwrap() - 5153.679868698).abs() < 1e-9);
assert!((parse_d("4.5678E+04").unwrap() - 45678.0).abs() < 1e-9);
assert_eq!(parse_d(" ").unwrap(), 0.0);
assert!(parse_d("not-a-number").is_err());
}
#[test]
fn parses_a_gps_ephemeris_record() {
let ephs = parse_nav(SAMPLE).expect("parses");
assert_eq!(ephs.len(), 1);
let e = &ephs[0];
assert_eq!(e.system, 'G');
assert_eq!(e.prn, 1);
assert_eq!(e.toc.year, 2023);
assert_eq!(e.toc.month, 1);
assert_eq!(e.toc.day, 1);
assert!((e.af0 - 4.567_890_123_456e-4).abs() < 1e-16);
assert!((e.af1 - 1.136_868_377_216e-12).abs() < 1e-24);
assert_eq!(e.af2, 0.0);
assert!((e.sqrt_a - 5153.679868698).abs() < 1e-9);
assert!((e.e - 1.234_567_890_123e-2).abs() < 1e-14);
assert!((e.m0 - -1.234_567_890_123).abs() < 1e-12);
assert!((e.toe - 172_800.0).abs() < 1e-6);
assert!((e.delta_n - 4.567_890_123_456e-9).abs() < 1e-21);
assert!((e.omega_dot - -8.123_456_789_012e-9).abs() < 1e-21);
assert_eq!(e.gps_week, 2244.0);
let a = e.sqrt_a * e.sqrt_a;
assert!((a - 26_560_000.0).abs() < 50_000.0, "a = {a} m");
}
#[test]
fn skips_non_gps_systems() {
let mixed = SAMPLE.replace(
"G01 2023 01 01 00 00 00",
"E11 2023 01 01 00 00 00 0.0D+00 0.0D+00 0.0D+00\n \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n 0.0D+00 0.0D+00 0.0D+00 0.0D+00\n \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n 0.0D+00 0.0D+00 0.0D+00 0.0D+00\n \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\n 0.0D+00 0.0D+00 0.0D+00 0.0D+00\n \
0.0D+00 0.0D+00 0.0D+00 0.0D+00\nG01 2023 01 01 00 00 00",
);
let ephs = parse_nav(&mixed).expect("parses");
assert_eq!(ephs.len(), 1);
assert_eq!(ephs[0].system, 'G');
}
#[test]
fn sv_position_is_a_gps_orbit() {
let eph = &parse_nav(SAMPLE).unwrap()[0];
let p = eph.sv_position_ecef(eph.toe);
let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
let a = eph.semi_major_axis();
let band = eph.e * a + 400.0;
assert!(
(r - a).abs() < band,
"radius {r:.0} m outside a±band ({a:.0} ± {band:.0})"
);
assert!((r - 26_560_000.0).abs() < 600_000.0, "r = {r:.0} m");
}
#[test]
fn sv_speed_matches_a_gps_orbit() {
let eph = &parse_nav(SAMPLE).unwrap()[0];
let dt = 1.0;
let a = eph.sv_position_ecef(eph.toe);
let b = eph.sv_position_ecef(eph.toe + dt);
let v =
(((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt()) / dt;
assert!((3.0e3..4.5e3).contains(&v), "ECEF speed {v:.1} m/s");
}
#[test]
fn gps_time_of_week_for_known_dates() {
let sunday = EpochUtc {
year: 2023,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
};
assert_eq!(sunday.gps_time_of_week(), 0.0);
let tue = EpochUtc {
day: 3,
hour: 12,
..sunday
};
assert_eq!(tue.gps_time_of_week(), 2.0 * 86_400.0 + 43_200.0);
let sat = EpochUtc {
day: 7,
hour: 23,
minute: 59,
second: 59.0,
..sunday
};
assert_eq!(sat.gps_time_of_week(), 6.0 * 86_400.0 + 86_399.0);
}
#[test]
fn sv_clock_bias_is_af0_dominated_with_a_relativistic_term() {
let eph = &parse_nav(SAMPLE).unwrap()[0];
let toc_tow = eph.toc.gps_time_of_week();
let bias = eph.sv_clock_bias_s(toc_tow);
assert!(bias.is_finite());
assert!(
(bias - eph.af0).abs() < 1e-7,
"bias {bias} vs af0 {}",
eph.af0
);
let dtr = bias - eph.af0;
assert!(dtr != 0.0 && dtr.abs() < 3e-8, "relativistic term {dtr}");
}
#[test]
fn week_rollover_correction_is_symmetric() {
let eph = &parse_nav(SAMPLE).unwrap()[0];
let p0 = eph.sv_position_ecef(eph.toe);
let pw = eph.sv_position_ecef(eph.toe + 604_800.0);
for k in 0..3 {
assert!(
(p0[k] - pw[k]).abs() < 1e-3,
"axis {k}: {} vs {}",
p0[k],
pw[k]
);
}
}
#[test]
fn empty_after_header_is_empty() {
let only_header = "\
3.04 N: GNSS NAV DATA G: GPS RINEX VERSION / TYPE
END OF HEADER";
assert!(parse_nav(only_header).unwrap().is_empty());
}
}