use crate::broadcast::{
satellite_state, ClockPolynomial, ConstellationConstants, KeplerianElements, SECONDS_PER_WEEK,
};
use crate::glonass;
use crate::id::{GnssSatelliteId, GnssSystem};
use crate::spp::EphemerisSource;
const GPS_EPOCH_TO_J2000_S: f64 = 630_763_200.0;
const MAX_EPHEMERIS_AGE_S: f64 = 4.0 * 3600.0;
const GLONASS_MAX_AGE_S: f64 = 15.0 * 60.0;
const GPST_MINUS_BDT_S: f64 = 14.0;
const BDS_EPOCH_MINUS_GPS_EPOCH_S: f64 = 1356.0 * SECONDS_PER_WEEK;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavMessage {
GpsLnav,
GalileoInav,
GalileoFnav,
BeidouD1,
BeidouD2,
}
pub fn is_beidou_geo(sat: GnssSatelliteId) -> bool {
sat.system == GnssSystem::BeiDou && (sat.prn <= 5 || (59..=61).contains(&sat.prn))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KlobucharAlphaBeta {
pub alpha: [f64; 4],
pub beta: [f64; 4],
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct IonoCorrections {
pub gps: Option<KlobucharAlphaBeta>,
pub beidou: Option<KlobucharAlphaBeta>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlonassRecord {
pub satellite_id: GnssSatelliteId,
pub toe_utc_j2000_s: f64,
pub pos_m: [f64; 3],
pub vel_m_s: [f64; 3],
pub acc_m_s2: [f64; 3],
pub clk_bias: f64,
pub gamma_n: f64,
pub sv_health: f64,
pub freq_channel: i32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BroadcastRecord {
pub satellite_id: GnssSatelliteId,
pub message: NavMessage,
pub week: u32,
pub elements: KeplerianElements,
pub clock: ClockPolynomial,
pub group_delay_s: f64,
pub sv_health: f64,
pub sv_accuracy_m: f64,
pub fit_interval_s: Option<f64>,
}
impl BroadcastRecord {
pub const fn constants(&self) -> ConstellationConstants {
match self.satellite_id.system {
GnssSystem::Galileo => ConstellationConstants::GALILEO,
GnssSystem::BeiDou => ConstellationConstants::BEIDOU,
_ => ConstellationConstants::GPS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NavParseError {
UnsupportedHeader(String),
MissingHeaderEnd,
TruncatedRecord(String),
BadField {
satellite: String,
field: &'static str,
},
}
impl core::fmt::Display for NavParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
NavParseError::UnsupportedHeader(s) => write!(f, "unsupported RINEX NAV header: {s}"),
NavParseError::MissingHeaderEnd => write!(f, "no END OF HEADER line"),
NavParseError::TruncatedRecord(s) => write!(f, "truncated navigation record for {s}"),
NavParseError::BadField { satellite, field } => {
write!(f, "bad/missing {field} field in record for {satellite}")
}
}
}
}
impl std::error::Error for NavParseError {}
pub fn parse_nav(text: &str) -> Result<Vec<BroadcastRecord>, NavParseError> {
let mut lines = text.lines();
let major = verify_and_skip_header(&mut lines)?;
if major >= 4 {
parse_nav_v4(lines)
} else {
parse_nav_v3(lines)
}
}
fn parse_nav_v3<'a, I>(lines: I) -> Result<Vec<BroadcastRecord>, NavParseError>
where
I: Iterator<Item = &'a str>,
{
let mut blocks: Vec<Vec<&str>> = Vec::new();
for line in lines {
if is_record_start(line) {
blocks.push(vec![line]);
} else if let Some(last) = blocks.last_mut() {
last.push(line);
}
}
let mut records = Vec::new();
for block in &blocks {
let letter = block[0].as_bytes()[0] as char;
match GnssSystem::from_letter(letter) {
Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou) => {
records.push(parse_keplerian_block(block)?);
}
_ => {}
}
}
Ok(records)
}
fn parse_nav_v4<'a, I>(lines: I) -> Result<Vec<BroadcastRecord>, NavParseError>
where
I: Iterator<Item = &'a str>,
{
let mut frames: Vec<(&str, Vec<&str>)> = Vec::new();
for line in lines {
if is_v4_frame_marker(line) {
frames.push((line, Vec::new()));
} else if let Some((_, body)) = frames.last_mut() {
body.push(line);
}
}
let mut records = Vec::new();
for (marker, body) in &frames {
let Some((frame_type, sv, msg_token)) = parse_v4_marker(marker) else {
continue;
};
if frame_type != "EPH" {
continue; }
let letter = sv.as_bytes().first().map(|b| *b as char).unwrap_or(' ');
let supported = matches!(
GnssSystem::from_letter(letter),
Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou)
);
if !supported {
continue; }
if let Some(message) = nav_message_from_v4_token(msg_token) {
let mut record = parse_keplerian_block(body)?;
record.message = message;
records.push(record);
}
}
Ok(records)
}
fn is_v4_frame_marker(line: &str) -> bool {
line.starts_with("> ")
}
fn parse_v4_marker(line: &str) -> Option<(&str, &str, &str)> {
let rest = line.strip_prefix('>')?;
let mut fields = rest.split_whitespace();
let frame_type = fields.next()?;
let sv = fields.next()?;
let msg_token = fields.next()?;
Some((frame_type, sv, msg_token))
}
fn nav_message_from_v4_token(token: &str) -> Option<NavMessage> {
match token {
"LNAV" => Some(NavMessage::GpsLnav),
"INAV" => Some(NavMessage::GalileoInav),
"FNAV" => Some(NavMessage::GalileoFnav),
"D1" => Some(NavMessage::BeidouD1),
"D2" => Some(NavMessage::BeidouD2),
_ => None,
}
}
pub fn parse_iono_corrections(text: &str) -> IonoCorrections {
let row = |line: &str| -> Option<[f64; 4]> {
Some([
parse_f64(line, 5, 17)?,
parse_f64(line, 17, 29)?,
parse_f64(line, 29, 41)?,
parse_f64(line, 41, 53)?,
])
};
let (mut gpsa, mut gpsb, mut bdsa, mut bdsb) = (None, None, None, None);
for line in text.lines() {
if line.contains("END OF HEADER") {
break;
}
if !line.contains("IONOSPHERIC CORR") {
continue;
}
match line.get(0..4).map(str::trim) {
Some("GPSA") => gpsa = row(line),
Some("GPSB") => gpsb = row(line),
Some("BDSA") => bdsa = row(line),
Some("BDSB") => bdsb = row(line),
_ => {}
}
}
let pair = |a: Option<[f64; 4]>, b: Option<[f64; 4]>| match (a, b) {
(Some(alpha), Some(beta)) => Some(KlobucharAlphaBeta { alpha, beta }),
_ => None,
};
IonoCorrections {
gps: pair(gpsa, gpsb),
beidou: pair(bdsa, bdsb),
}
}
pub fn parse_leap_seconds(text: &str) -> Option<f64> {
for line in text.lines() {
if line.contains("END OF HEADER") {
break;
}
if line.contains("LEAP SECONDS") {
return line.get(0..6).and_then(|s| s.trim().parse::<f64>().ok());
}
}
None
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn j2000_seconds_utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> f64 {
let day = days_from_civil(y, mo, d) - 10957;
(day * 86_400 + h * 3600 + mi * 60 + s - 43_200) as f64
}
fn parse_glonass_epoch(l0: &str) -> Option<f64> {
let y: i64 = field(l0, 4, 8)?.parse().ok()?;
let mo: i64 = field(l0, 9, 11)?.parse().ok()?;
let d: i64 = field(l0, 12, 14)?.parse().ok()?;
let h: i64 = field(l0, 15, 17)?.parse().ok()?;
let mi: i64 = field(l0, 18, 20)?.parse().ok()?;
let s: i64 = field(l0, 21, 23)?.trim().parse().ok()?;
Some(j2000_seconds_utc(y, mo, d, h, mi, s))
}
fn parse_glonass_block(block: &[&str]) -> Result<GlonassRecord, NavParseError> {
let l0 = block[0];
let sat = l0.get(0..3).unwrap_or("").trim().to_string();
if block.len() < 4 {
return Err(NavParseError::TruncatedRecord(sat));
}
let bad = |what: &'static str| NavParseError::BadField {
satellite: sat.clone(),
field: what,
};
let prn: u8 = l0
.get(1..3)
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| bad("prn"))?;
let satellite_id = GnssSatelliteId::new(GnssSystem::Glonass, prn);
let toe_utc_j2000_s = parse_glonass_epoch(l0).ok_or_else(|| bad("epoch"))?;
let clk_bias = parse_f64(l0, 23, 42).ok_or_else(|| bad("clock bias"))?;
let gamma_n = parse_f64(l0, 42, 61).ok_or_else(|| bad("gamma_n"))?;
let o1 = orbit_row(block[1]);
let o2 = orbit_row(block[2]);
let o3 = orbit_row(block[3]);
let km = |v: Option<f64>, what: &'static str| v.map(|x| x * 1000.0).ok_or_else(|| bad(what));
Ok(GlonassRecord {
satellite_id,
toe_utc_j2000_s,
pos_m: [km(o1[0], "x")?, km(o2[0], "y")?, km(o3[0], "z")?],
vel_m_s: [km(o1[1], "vx")?, km(o2[1], "vy")?, km(o3[1], "vz")?],
acc_m_s2: [km(o1[2], "ax")?, km(o2[2], "ay")?, km(o3[2], "az")?],
clk_bias,
gamma_n,
sv_health: o1[3].unwrap_or(0.0),
freq_channel: o2[3].unwrap_or(0.0) as i32,
})
}
pub fn parse_glonass(text: &str) -> Result<Vec<GlonassRecord>, NavParseError> {
let mut lines = text.lines();
if verify_and_skip_header(&mut lines).is_err() {
return Ok(Vec::new());
}
let mut blocks: Vec<Vec<&str>> = Vec::new();
for line in lines {
if is_record_start(line) {
blocks.push(vec![line]);
} else if let Some(last) = blocks.last_mut() {
last.push(line);
}
}
blocks
.iter()
.filter(|b| b[0].starts_with('R'))
.map(|b| parse_glonass_block(b))
.collect()
}
fn verify_and_skip_header<'a, I>(lines: &mut I) -> Result<u8, NavParseError>
where
I: Iterator<Item = &'a str>,
{
let mut major: Option<u8> = None;
for line in lines.by_ref() {
if line.contains("RINEX VERSION / TYPE") {
let version = line.get(0..9).unwrap_or("").trim();
let detected = match version.chars().next() {
Some('3') => Some(3u8),
Some('4') => Some(4u8),
_ => None,
};
let is_nav = line.get(20..21) == Some("N");
match (detected, is_nav) {
(Some(v), true) => major = Some(v),
_ => {
return Err(NavParseError::UnsupportedHeader(
line.trim_end().to_string(),
))
}
}
}
if line.contains("END OF HEADER") {
return major.ok_or_else(|| {
NavParseError::UnsupportedHeader("no RINEX VERSION / TYPE".to_string())
});
}
}
Err(NavParseError::MissingHeaderEnd)
}
fn is_record_start(line: &str) -> bool {
let b = line.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1].is_ascii_digit() && b[2].is_ascii_digit()
}
fn field(line: &str, start: usize, end: usize) -> Option<&str> {
let s = line.get(start..end.min(line.len()))?.trim();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn parse_f64(line: &str, start: usize, end: usize) -> Option<f64> {
let s = field(line, start, end)?;
s.replace(['D', 'd'], "e").parse::<f64>().ok()
}
fn orbit_row(line: &str) -> [Option<f64>; 4] {
[
parse_f64(line, 4, 23),
parse_f64(line, 23, 42),
parse_f64(line, 42, 61),
parse_f64(line, 61, 80),
]
}
fn epoch_seconds_of_week(
year: i64,
month: i64,
day: i64,
hour: i64,
minute: i64,
second: i64,
) -> f64 {
const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = if month < 3 { year - 1 } else { year };
let dow = (y + y / 4 - y / 100 + y / 400 + T[(month - 1) as usize] + day).rem_euclid(7);
(dow * 86_400 + hour * 3600 + minute * 60 + second) as f64
}
fn parse_keplerian_block(block: &[&str]) -> Result<BroadcastRecord, NavParseError> {
let l0 = block[0];
let sat = l0.get(0..3).unwrap_or("").trim().to_string();
if block.len() < 8 {
return Err(NavParseError::TruncatedRecord(sat));
}
let bad = |what: &'static str| NavParseError::BadField {
satellite: sat.clone(),
field: what,
};
let letter = l0.as_bytes()[0] as char;
let system = GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
let prn: u8 = l0
.get(1..3)
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| bad("prn"))?;
let satellite_id = GnssSatelliteId::new(system, prn);
let toc_sow = parse_toc(l0).ok_or_else(|| bad("toc epoch"))?;
let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
let o1 = orbit_row(block[1]);
let o2 = orbit_row(block[2]);
let o3 = orbit_row(block[3]);
let o4 = orbit_row(block[4]);
let o5 = orbit_row(block[5]);
let o6 = orbit_row(block[6]);
let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
let elements = KeplerianElements {
crs: g(o1[1], "crs")?,
delta_n: g(o1[2], "deltaN")?,
m0: g(o1[3], "m0")?,
cuc: g(o2[0], "cuc")?,
e: g(o2[1], "e")?,
cus: g(o2[2], "cus")?,
sqrt_a: g(o2[3], "sqrtA")?,
toe_sow: g(o3[0], "toe")?,
cic: g(o3[1], "cic")?,
omega0: g(o3[2], "omega0")?,
cis: g(o3[3], "cis")?,
i0: g(o4[0], "i0")?,
crc: g(o4[1], "crc")?,
omega: g(o4[2], "omega")?,
omega_dot: g(o4[3], "omegaDot")?,
idot: g(o5[0], "idot")?,
};
let clock = ClockPolynomial {
af0,
af1,
af2,
toc_sow,
};
let week = g(o5[2], "week")? as u32;
let sv_accuracy_m = g(o6[0], "accuracy")?;
let sv_health = g(o6[1], "health")?;
let group_delay_s = g(o6[2], "group delay")?;
let message = match system {
GnssSystem::Galileo => galileo_message(o5[1]),
GnssSystem::BeiDou => {
if is_beidou_geo(satellite_id) {
NavMessage::BeidouD2
} else {
NavMessage::BeidouD1
}
}
_ => NavMessage::GpsLnav,
};
let fit_interval_s = match system {
GnssSystem::Gps => Some(gps_fit_interval_s(block[7]).map_err(|()| bad("fit interval"))?),
_ => None,
};
Ok(BroadcastRecord {
satellite_id,
message,
week,
elements,
clock,
group_delay_s,
sv_health,
sv_accuracy_m,
fit_interval_s,
})
}
fn gps_fit_interval_s(orbit7: &str) -> Result<f64, ()> {
let hours = match field(orbit7, 23, 42) {
None => 0.0,
Some(_) => parse_f64(orbit7, 23, 42).ok_or(())?,
};
let hours = if hours == 0.0 { 4.0 } else { hours };
Ok(hours * 3600.0)
}
fn galileo_message(data_sources: Option<f64>) -> NavMessage {
let word = data_sources.map(|v| v as u32).unwrap_or(0);
if word & 0x200 != 0 {
NavMessage::GalileoInav
} else if word & 0x100 != 0 {
NavMessage::GalileoFnav
} else {
NavMessage::GalileoInav
}
}
fn parse_toc(l0: &str) -> Option<f64> {
let year: i64 = field(l0, 4, 8)?.parse().ok()?;
let month: i64 = field(l0, 9, 11)?.parse().ok()?;
let day: i64 = field(l0, 12, 14)?.parse().ok()?;
let hour: i64 = field(l0, 15, 17)?.parse().ok()?;
let minute: i64 = field(l0, 18, 20)?.parse().ok()?;
let second: i64 = field(l0, 21, 23)?.trim().parse().ok()?;
Some(epoch_seconds_of_week(
year, month, day, hour, minute, second,
))
}
pub struct BroadcastStore {
records: Vec<BroadcastRecord>,
glonass: Vec<GlonassRecord>,
leap_seconds: Option<f64>,
iono: IonoCorrections,
}
impl BroadcastStore {
pub fn new(records: Vec<BroadcastRecord>) -> Self {
Self {
records,
glonass: Vec::new(),
leap_seconds: None,
iono: IonoCorrections::default(),
}
}
pub fn from_nav(text: &str) -> Result<Self, NavParseError> {
let records = parse_nav(text)?
.into_iter()
.filter(Self::is_default_usable)
.collect();
let glonass = parse_glonass(text)?
.into_iter()
.filter(|r| r.sv_health == 0.0)
.collect();
Ok(Self {
records,
glonass,
leap_seconds: parse_leap_seconds(text),
iono: parse_iono_corrections(text),
})
}
pub fn iono_corrections(&self) -> IonoCorrections {
self.iono
}
pub fn glonass_records(&self) -> &[GlonassRecord] {
&self.glonass
}
fn is_default_usable(r: &BroadcastRecord) -> bool {
r.sv_health == 0.0
&& matches!(
r.message,
NavMessage::GpsLnav
| NavMessage::GalileoInav
| NavMessage::BeidouD1
| NavMessage::BeidouD2
)
}
pub fn records(&self) -> &[BroadcastRecord] {
&self.records
}
pub fn retain(&mut self, keep: impl FnMut(&BroadcastRecord) -> bool) {
self.records.retain(keep);
}
fn toe_continuous_s(rec: &BroadcastRecord) -> f64 {
f64::from(rec.week) * SECONDS_PER_WEEK + rec.elements.toe_sow
}
fn half_window_s(rec: &BroadcastRecord) -> f64 {
match rec.fit_interval_s {
Some(fit) => fit / 2.0,
None => MAX_EPHEMERIS_AGE_S,
}
}
fn select(&self, sat: GnssSatelliteId, t_continuous_s: f64) -> Option<&BroadcastRecord> {
self.records
.iter()
.filter(|r| r.satellite_id == sat)
.filter(|r| {
(t_continuous_s - Self::toe_continuous_s(r)).abs() <= Self::half_window_s(r)
})
.min_by(|a, b| {
let da = (t_continuous_s - Self::toe_continuous_s(a)).abs();
let db = (t_continuous_s - Self::toe_continuous_s(b)).abs();
da.partial_cmp(&db).unwrap_or(core::cmp::Ordering::Equal)
})
}
fn select_glonass(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<(&GlonassRecord, f64)> {
let leap = self.leap_seconds?;
let toe_gpst = |r: &GlonassRecord| r.toe_utc_j2000_s + leap;
let rec = self
.glonass
.iter()
.filter(|r| r.satellite_id == sat)
.min_by(|a, b| {
let da = (t_j2000_s - toe_gpst(a)).abs();
let db = (t_j2000_s - toe_gpst(b)).abs();
da.partial_cmp(&db).unwrap_or(core::cmp::Ordering::Equal)
})?;
let tk = t_j2000_s - toe_gpst(rec);
if tk.abs() <= GLONASS_MAX_AGE_S {
Some((rec, tk))
} else {
None
}
}
}
impl EphemerisSource for BroadcastStore {
fn position_clock_at_j2000_s(
&self,
sat: GnssSatelliteId,
t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
if sat.system == GnssSystem::Glonass {
let (rec, tk) = self.select_glonass(sat, t_j2000_s)?;
let state0 = [
rec.pos_m[0],
rec.pos_m[1],
rec.pos_m[2],
rec.vel_m_s[0],
rec.vel_m_s[1],
rec.vel_m_s[2],
];
let state = glonass::propagate(state0, rec.acc_m_s2, tk);
let clock = glonass::clock_offset_s(rec.clk_bias, rec.gamma_n, tk);
return Some(([state[0], state[1], state[2]], clock));
}
if !matches!(
sat.system,
GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou
) {
return None;
}
let gpst_continuous = t_j2000_s + GPS_EPOCH_TO_J2000_S;
let (t_continuous, is_geo) = if sat.system == GnssSystem::BeiDou {
(
gpst_continuous - GPST_MINUS_BDT_S - BDS_EPOCH_MINUS_GPS_EPOCH_S,
is_beidou_geo(sat),
)
} else {
(gpst_continuous, false)
};
let rec = self.select(sat, t_continuous)?;
let sow = t_continuous.rem_euclid(SECONDS_PER_WEEK);
let state = satellite_state(
&rec.elements,
&rec.clock,
&rec.constants(),
sow,
rec.group_delay_s,
is_geo,
);
Some((
state.orbit.position().as_array(),
state.clock.dt_clock_total_s,
))
}
}
#[cfg(test)]
mod tests;