#![allow(clippy::doc_markdown, clippy::many_single_char_names)]
use crate::gps::{BroadcastEphemeris, GnssSystem};
const SEMICIRCLE: f64 = core::f64::consts::PI;
const REFERENCE_INCLINATION_SEMICIRCLES: f64 = 0.30;
const WEEK_MODULUS: i32 = 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct AlmanacEntry {
pub ephemeris: BroadcastEphemeris,
pub svn: Option<u16>,
pub config: Option<u8>,
}
#[must_use]
pub fn resolve_week(truncated: u16, hint: u16) -> u16 {
let offset = (i32::from(truncated) - i32::from(hint)).rem_euclid(WEEK_MODULUS);
let signed = if offset > WEEK_MODULUS / 2 {
offset - WEEK_MODULUS
} else {
offset
};
let mut week = i32::from(hint) + signed;
if week < 0 {
week += WEEK_MODULUS;
}
while week > i32::from(u16::MAX) {
week -= WEEK_MODULUS;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
week as u16
}
}
#[allow(clippy::too_many_arguments)]
fn reduced(
prn: u8,
week: u16,
toa: f64,
e: f64,
i0: f64,
omega0: f64,
omega: f64,
m0: f64,
omega_dot: f64,
sqrt_a: f64,
af0: f64,
af1: f64,
health: u8,
) -> BroadcastEphemeris {
BroadcastEphemeris {
system: GnssSystem::Gps,
prn,
week,
toe: toa,
toc: toa,
sqrt_a,
e,
i0,
omega0,
omega,
m0,
delta_n: 0.0,
idot: 0.0,
omega_dot,
cuc: 0.0,
cus: 0.0,
crc: 0.0,
crs: 0.0,
cic: 0.0,
cis: 0.0,
af0,
af1,
af2: 0.0,
tgd: 0.0,
health,
}
}
fn numbers<const N: usize>(line: &str) -> Option<[f64; N]> {
let mut out = [0.0; N];
let mut fields = line.split_whitespace();
for slot in &mut out {
*slot = fields.next()?.parse().ok()?;
}
Some(out)
}
#[must_use]
pub fn parse_sem(text: &str, hint: u16) -> Vec<AlmanacEntry> {
let lines: Vec<&str> = text.lines().map(str::trim_end).collect();
let Some(header) = lines.iter().find(|l| !l.trim().is_empty()) else {
return Vec::new();
};
let header_index = lines.iter().position(|l| l == header).unwrap_or(0);
let Some([week, toa]) = lines.get(header_index + 1).and_then(|l| numbers::<2>(l)) else {
return Vec::new();
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let week = resolve_week(
(week as i64).rem_euclid(i64::from(WEEK_MODULUS)) as u16,
hint,
);
let mut out = Vec::new();
let mut idx = header_index + 2;
while idx < lines.len() {
if lines[idx].trim().is_empty() {
idx += 1;
continue;
}
let end = lines[idx..]
.iter()
.position(|l| l.trim().is_empty())
.map_or(lines.len(), |n| idx + n);
if end - idx >= 8
&& let Some(entry) = sem_record(&lines[idx..end], week, toa)
{
out.push(entry);
}
idx = end;
}
out
}
fn sem_record(block: &[&str], week: u16, toa: f64) -> Option<AlmanacEntry> {
let prn: u8 = block[0].trim().parse().ok()?;
let svn: u16 = block[1].trim().parse().ok()?;
let [e, delta_i, omega_dot] = numbers::<3>(block[3])?;
let [sqrt_a, omega0, omega] = numbers::<3>(block[4])?;
let [m0, af0, af1] = numbers::<3>(block[5])?;
let health: u8 = block[6].trim().parse().ok()?;
let config: u8 = block[7].trim().parse().ok()?;
Some(AlmanacEntry {
ephemeris: reduced(
prn,
week,
toa,
e,
(REFERENCE_INCLINATION_SEMICIRCLES + delta_i) * SEMICIRCLE,
omega0 * SEMICIRCLE,
omega * SEMICIRCLE,
m0 * SEMICIRCLE,
omega_dot * SEMICIRCLE,
sqrt_a,
af0,
af1,
health,
),
svn: Some(svn),
config: Some(config),
})
}
fn after_colon(line: &str) -> Option<f64> {
line.split_once(':')?.1.trim().parse().ok()
}
#[must_use]
pub fn parse_yuma(text: &str, hint: u16) -> Vec<AlmanacEntry> {
let mut out = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.lines().map(str::trim_end) {
if line.trim_start().starts_with("****") {
if let Some(entry) = yuma_record(¤t, hint) {
out.push(entry);
}
current.clear();
} else if !line.trim().is_empty() {
current.push(line);
}
}
if let Some(entry) = yuma_record(¤t, hint) {
out.push(entry);
}
out
}
fn yuma_record(block: &[&str], hint: u16) -> Option<AlmanacEntry> {
let field = |key: &str| {
block
.iter()
.find(|l| l.trim_start().starts_with(key))
.and_then(|l| after_colon(l))
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let prn = field("ID")? as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let health = field("Health")? as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let week = resolve_week(field("week")? as u16, hint);
Some(AlmanacEntry {
ephemeris: reduced(
prn,
week,
field("Time of Applicability")?,
field("Eccentricity")?,
field("Orbital Inclination")?,
field("Right Ascen at Week")?,
field("Argument of Perigee")?,
field("Mean Anom")?,
field("Rate of Right Ascen")?,
field("SQRT(A)")?,
field("Af0")?,
field("Af1")?,
health,
),
svn: None,
config: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
const SEM: &str = include_str!("../tests/fixtures/almanac.sem.week0384.147456.txt");
const YUMA: &str = include_str!("../tests/fixtures/almanac.yuma.week0384.147456.txt");
const HINT: u16 = 2432;
#[test]
fn a_truncated_week_resolves_to_the_era_nearest_the_hint() {
assert_eq!(resolve_week(384, 2432), 2432);
assert_eq!(resolve_week(383, 2432), 2431);
assert_eq!(resolve_week(385, 2432), 2433);
assert_eq!(resolve_week(0, 1023), 1024);
assert_eq!(resolve_week(1023, 2), 1023);
for (truncated, hint) in [(1023_u16, 2_u16), (0, 1023), (384, 2432), (700, 100)] {
let week = resolve_week(truncated, hint);
assert_eq!(
i32::from(week).rem_euclid(WEEK_MODULUS),
i32::from(truncated),
"resolve_week({truncated}, {hint}) = {week} is not congruent"
);
}
assert_eq!(resolve_week(384 + 512, 2432), 2432 + 512);
}
#[test]
fn the_sem_header_gives_the_week_and_time_of_applicability_to_every_record() {
let sem = parse_sem(SEM, HINT);
assert_eq!(sem.len(), 32, "SEM record count");
for entry in &sem {
assert_eq!(entry.ephemeris.week, HINT);
assert!((entry.ephemeris.toe - 147_456.0).abs() < 1e-9);
assert_eq!(entry.ephemeris.system, GnssSystem::Gps);
}
assert_eq!(sem[0].ephemeris.prn, 1);
assert_eq!(sem[0].svn, Some(80));
assert_eq!(sem[0].config, Some(12));
}
#[test]
fn yuma_takes_its_week_from_each_record_and_carries_no_identifiers() {
let yuma = parse_yuma(YUMA, HINT);
assert_eq!(yuma.len(), 32, "Yuma record count");
assert_eq!(yuma[0].ephemeris.prn, 1);
assert_eq!(yuma[0].svn, None);
assert_eq!(yuma[0].config, None);
for entry in &yuma {
assert_eq!(entry.ephemeris.week, HINT);
}
}
#[test]
fn the_terms_the_almanac_does_not_carry_are_zero() {
for entry in parse_sem(SEM, HINT) {
let e = &entry.ephemeris;
for (name, term) in [
("Cuc", e.cuc),
("Cus", e.cus),
("Crc", e.crc),
("Crs", e.crs),
("Cic", e.cic),
("Cis", e.cis),
("delta_n", e.delta_n),
("idot", e.idot),
("af2", e.af2),
("tgd", e.tgd),
] {
assert!(term == 0.0, "PRN {}: {name} is {term:e}", e.prn);
}
}
}
#[test]
fn the_sem_inclination_offset_is_lifted_onto_the_reference() {
let sem = parse_sem(SEM, HINT);
let i0 = sem[0].ephemeris.i0;
assert!(
(i0 - 0.957_014_660_9).abs() < 5e-10,
"PRN 1 inclination {i0} rad"
);
for entry in &sem {
let degrees = entry.ephemeris.i0.to_degrees();
assert!(
(52.0..58.0).contains(°rees),
"PRN {}: inclination {degrees}°",
entry.ephemeris.prn
);
}
}
#[test]
fn a_malformed_file_yields_nothing_rather_than_a_wrong_orbit() {
assert_eq!(parse_sem("", HINT).len(), 0);
assert_eq!(parse_sem("32 CURRENT.ALM\n", HINT).len(), 0);
assert_eq!(
parse_sem("32 CURRENT.ALM\nnot a week\n\n1\n", HINT).len(),
0
);
let truncated = "32 X\n 384 147456\n\n1\n80\n0\n 1e-3 4e-3 -2e-9\n 5.1e3 4e-2 5e-2\n";
assert_eq!(parse_sem(truncated, HINT).len(), 0);
assert_eq!(parse_yuma("", HINT).len(), 0);
assert_eq!(
parse_yuma(
"******** Week 384 almanac for PRN-01 ********\nID: 01\n",
HINT
)
.len(),
0
);
}
}