use crate::broadcast::{
satellite_state, satellite_state_cnav, satellite_state_cnav_unchecked,
satellite_state_unchecked, CnavRates, SatelliteState,
};
use crate::constants::{
BDS_EPOCH_MINUS_GPS_EPOCH_S, GPST_MINUS_BDT_S, GPS_EPOCH_TO_J2000_S, SECONDS_PER_WEEK,
};
use crate::error::{Error, Result as CoreResult};
use crate::glonass;
use crate::id::{GnssSatelliteId, GnssSystem};
use crate::spp::EphemerisSource;
use super::{
cnav_ura_nominal_m, is_beidou_geo, parse_glonass, parse_iono_corrections_checked,
parse_leap_seconds_checked, parse_nav, BroadcastGroupDelays, BroadcastIssue, BroadcastRecord,
CnavParameters, GlonassRecord, IonoCorrections, NavMessage, NavParseError, GLONASS_MAX_AGE_S,
MAX_EPHEMERIS_AGE_S,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavMessagePreference {
#[default]
PreferLegacy,
PreferModern,
}
pub struct BroadcastStore {
records: Vec<BroadcastRecord>,
glonass: Vec<GlonassRecord>,
leap_seconds: Option<f64>,
iono: IonoCorrections,
message_preference: NavMessagePreference,
}
impl BroadcastStore {
pub fn new(records: Vec<BroadcastRecord>) -> CoreResult<Self> {
for record in &records {
validate_manual_record(record)?;
}
Ok(Self {
records,
glonass: Vec::new(),
leap_seconds: None,
iono: IonoCorrections::default(),
message_preference: NavMessagePreference::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_checked(text)?,
iono: parse_iono_corrections_checked(text)?,
message_preference: NavMessagePreference::default(),
})
}
pub fn set_message_preference(&mut self, preference: NavMessagePreference) {
self.message_preference = preference;
}
pub const fn message_preference(&self) -> NavMessagePreference {
self.message_preference
}
pub fn iono_corrections(&self) -> IonoCorrections {
self.iono
}
pub fn glonass_records(&self) -> &[GlonassRecord] {
&self.glonass
}
pub fn glonass_frequency_channels(&self) -> std::collections::BTreeMap<u8, i8> {
self.glonass
.iter()
.map(|r| (r.satellite_id.prn, r.freq_channel as i8))
.collect()
}
fn is_default_usable(r: &BroadcastRecord) -> bool {
r.sv_health == 0.0
&& matches!(
r.message,
NavMessage::GpsLnav
| NavMessage::GpsCnav
| NavMessage::GpsCnav2
| NavMessage::QzssCnav
| NavMessage::QzssCnav2
| NavMessage::GalileoInav
| NavMessage::BeidouD1
| NavMessage::BeidouD2
)
&& (!r.message.is_cnav_family()
|| r.cnav
.map(|cnav| cnav_ura_nominal_m(cnav.ura_ed_index).is_some())
.unwrap_or(false))
}
pub fn records(&self) -> &[BroadcastRecord] {
&self.records
}
pub fn select_by_iode_at(
&self,
sat: GnssSatelliteId,
iode: u8,
t_j2000_s: f64,
) -> Option<&BroadcastRecord> {
let (t_continuous, _) = query_continuous_time(sat, t_j2000_s)?;
self.records
.iter()
.filter(|r| r.satellite_id == sat)
.filter(|r| r.issue_of_data.message == NavMessage::GpsLnav)
.filter(|r| r.issue_of_data.issue == u32::from(iode))
.filter(|r| (t_continuous - Self::toe_continuous_s(r)).abs() <= Self::half_window_s(r))
.min_by(|a, b| {
let da = (t_continuous - Self::toe_continuous_s(a)).abs();
let db = (t_continuous - Self::toe_continuous_s(b)).abs();
da.partial_cmp(&db).unwrap_or(core::cmp::Ordering::Equal)
})
}
pub fn state_by_iode_at(
&self,
sat: GnssSatelliteId,
iode: u8,
t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
let (t_continuous, is_geo) = query_continuous_time(sat, t_j2000_s)?;
let rec = self.select_by_iode_at(sat, iode, t_j2000_s)?;
let sow = t_continuous.rem_euclid(SECONDS_PER_WEEK);
let state = evaluate_record_unchecked(rec, sow, is_geo);
let position = state.orbit.position().ok()?;
Some((position.as_array(), state.clock.dt_clock_total_s))
}
pub fn retain(&mut self, keep: impl FnMut(&BroadcastRecord) -> bool) {
self.records.retain(keep);
}
fn toe_continuous_s(rec: &BroadcastRecord) -> f64 {
f64::from(rec.toe.week) * SECONDS_PER_WEEK + rec.toe.tow_s
}
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> {
let mut preferred = None;
let mut fallback = None;
for record in self.records.iter().filter(|r| r.satellite_id == sat) {
if (t_continuous_s - Self::toe_continuous_s(record)).abs() > Self::half_window_s(record)
{
continue;
}
if self.is_preferred_family(record) {
select_better_candidate(&mut preferred, record, t_continuous_s);
} else {
select_better_candidate(&mut fallback, record, t_continuous_s);
}
}
preferred.or(fallback)
}
fn is_preferred_family(&self, record: &BroadcastRecord) -> bool {
if !matches!(
record.satellite_id.system,
GnssSystem::Gps | GnssSystem::Qzss
) {
return true;
}
match self.message_preference {
NavMessagePreference::PreferLegacy => !record.message.is_cnav_family(),
NavMessagePreference::PreferModern => record.message.is_cnav_family(),
}
}
pub fn select_by_issue_at(
&self,
sat: GnssSatelliteId,
issue: BroadcastIssue,
nav_message: NavMessage,
t_j2000_s: f64,
) -> Option<&BroadcastRecord> {
if issue.message != nav_message {
return None;
}
let (t_continuous_s, _) = query_continuous_time(sat, t_j2000_s)?;
self.records
.iter()
.filter(|r| {
r.satellite_id == sat
&& r.message == nav_message
&& r.issue_of_data == issue
&& (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
}
}
}
fn validate_manual_record(record: &BroadcastRecord) -> CoreResult<()> {
validate_finite(record.toe.tow_s, "record.toe.tow_s")?;
validate_finite(record.toc.tow_s, "record.toc.tow_s")?;
validate_finite(record.sv_health, "record.sv_health")?;
validate_finite(record.sv_accuracy_m, "record.sv_accuracy_m")?;
if let Some(fit) = record.fit_interval_s {
validate_finite(fit, "record.fit_interval_s")?;
if fit <= 0.0 {
return Err(invalid_input("record.fit_interval_s", "not positive"));
}
}
validate_group_delays(record.group_delays)?;
validate_cnav_presence(record)?;
if let Some(cnav) = record.cnav {
validate_cnav_parameters(cnav)?;
}
if let Some(cnav) = record.cnav {
satellite_state_cnav(
&record.elements,
&cnav_rates(cnav),
&record.clock,
&record.constants(),
record.elements.toe_sow,
record.broadcast_clock_group_delay_s(),
)
.map(|_| ())
} else {
satellite_state(
&record.elements,
&record.clock,
&record.constants(),
record.elements.toe_sow,
record.broadcast_clock_group_delay_s(),
is_beidou_geo(record.satellite_id),
)
.map(|_| ())
}
}
fn validate_group_delays(delays: BroadcastGroupDelays) -> CoreResult<()> {
for (field, value) in [
("group_delays.gps_tgd_s", delays.gps_tgd_s),
(
"group_delays.galileo_bgd_e5a_e1_s",
delays.galileo_bgd_e5a_e1_s,
),
(
"group_delays.galileo_bgd_e5b_e1_s",
delays.galileo_bgd_e5b_e1_s,
),
("group_delays.beidou_tgd1_s", delays.beidou_tgd1_s),
("group_delays.beidou_tgd2_s", delays.beidou_tgd2_s),
("group_delays.cnav_isc_l1ca_s", delays.cnav_isc_l1ca_s),
("group_delays.cnav_isc_l2c_s", delays.cnav_isc_l2c_s),
("group_delays.cnav_isc_l5i5_s", delays.cnav_isc_l5i5_s),
("group_delays.cnav_isc_l5q5_s", delays.cnav_isc_l5q5_s),
("group_delays.cnav_isc_l1cd_s", delays.cnav_isc_l1cd_s),
("group_delays.cnav_isc_l1cp_s", delays.cnav_isc_l1cp_s),
] {
if let Some(value) = value {
validate_finite(value, field)?;
}
}
Ok(())
}
fn validate_cnav_presence(record: &BroadcastRecord) -> CoreResult<()> {
if record.message.is_cnav_family() != record.cnav.is_some() {
return Err(invalid_input(
"record.cnav",
"must be present only for CNAV-family messages",
));
}
Ok(())
}
fn validate_cnav_parameters(params: CnavParameters) -> CoreResult<()> {
validate_finite(params.adot_m_s, "record.cnav.adot_m_s")?;
validate_finite(
params.delta_n0_dot_rad_s2,
"record.cnav.delta_n0_dot_rad_s2",
)?;
validate_finite(params.top.tow_s, "record.cnav.top.tow_s")?;
validate_finite(
params.transmission_time_sow,
"record.cnav.transmission_time_sow",
)?;
if !(-16..=15).contains(¶ms.ura_ed_index) {
return Err(invalid_input("record.cnav.ura_ed_index", "out of range"));
}
if !(-16..=15).contains(¶ms.ura_ned0_index) {
return Err(invalid_input("record.cnav.ura_ned0_index", "out of range"));
}
if params.ura_ned1_index > 7 {
return Err(invalid_input("record.cnav.ura_ned1_index", "out of range"));
}
if params.ura_ned2_index > 7 {
return Err(invalid_input("record.cnav.ura_ned2_index", "out of range"));
}
Ok(())
}
fn cnav_rates(params: CnavParameters) -> CnavRates {
CnavRates {
adot_m_s: params.adot_m_s,
delta_n0_dot_rad_s2: params.delta_n0_dot_rad_s2,
}
}
fn evaluate_record_unchecked(rec: &BroadcastRecord, sow: f64, is_geo: bool) -> SatelliteState {
if let Some(cnav) = rec.cnav {
satellite_state_cnav_unchecked(
&rec.elements,
&cnav_rates(cnav),
&rec.clock,
&rec.constants(),
sow,
rec.broadcast_clock_group_delay_s(),
)
} else {
satellite_state_unchecked(
&rec.elements,
&rec.clock,
&rec.constants(),
sow,
rec.broadcast_clock_group_delay_s(),
is_geo,
)
}
}
fn select_better_candidate<'a>(
best: &mut Option<&'a BroadcastRecord>,
candidate: &'a BroadcastRecord,
t_continuous_s: f64,
) {
let Some(current) = *best else {
*best = Some(candidate);
return;
};
if candidate_is_better(candidate, current, t_continuous_s) {
*best = Some(candidate);
}
}
fn candidate_is_better(
candidate: &BroadcastRecord,
current: &BroadcastRecord,
t_continuous_s: f64,
) -> bool {
let da = (t_continuous_s - BroadcastStore::toe_continuous_s(candidate)).abs();
let db = (t_continuous_s - BroadcastStore::toe_continuous_s(current)).abs();
match da.partial_cmp(&db).unwrap_or(core::cmp::Ordering::Equal) {
core::cmp::Ordering::Less => true,
core::cmp::Ordering::Greater => false,
core::cmp::Ordering::Equal => {
cnav_tie_rank(candidate.message) < cnav_tie_rank(current.message)
}
}
}
const fn cnav_tie_rank(message: NavMessage) -> u8 {
match message {
NavMessage::GpsCnav | NavMessage::QzssCnav => 0,
NavMessage::GpsCnav2 | NavMessage::QzssCnav2 => 1,
_ => 0,
}
}
fn validate_finite(value: f64, field: &'static str) -> CoreResult<()> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_input(field, "not finite"))
}
}
fn invalid_input(field: &'static str, reason: &'static str) -> Error {
Error::InvalidInput(format!("{field} {reason}"))
}
impl core::str::FromStr for BroadcastStore {
type Err = NavParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_nav(s)
}
}
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).ok()?;
let clock = glonass::clock_offset_s(rec.clk_bias, rec.gamma_n, tk);
return Some(([state[0], state[1], state[2]], clock));
}
let (t_continuous, is_geo) = query_continuous_time(sat, t_j2000_s)?;
let rec = self.select(sat, t_continuous)?;
let sow = t_continuous.rem_euclid(SECONDS_PER_WEEK);
let state = evaluate_record_unchecked(rec, sow, is_geo);
let position = state.orbit.position().ok()?;
Some((position.as_array(), state.clock.dt_clock_total_s))
}
}
fn query_continuous_time(sat: GnssSatelliteId, t_j2000_s: f64) -> Option<(f64, bool)> {
if !matches!(
sat.system,
GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou | GnssSystem::Qzss
) {
return None;
}
let gpst_continuous = t_j2000_s + GPS_EPOCH_TO_J2000_S;
if sat.system == GnssSystem::BeiDou {
Some((
gpst_continuous - GPST_MINUS_BDT_S - BDS_EPOCH_MINUS_GPS_EPOCH_S,
is_beidou_geo(sat),
))
} else {
Some((gpst_continuous, false))
}
}