use affn::cartesian;
use affn::centers::{AffineCenter, ReferenceCenter};
use affn::frames::GCRS;
use chrono::{DateTime, NaiveDate, Utc as ChronoUtc};
use qtty::time::Microseconds;
use qtty::unit::Kilometer;
#[derive(Debug, Copy, Clone)]
pub struct EarthCenter;
impl ReferenceCenter for EarthCenter {
type Params = ();
fn center_name() -> &'static str {
"Geocentric"
}
}
impl AffineCenter for EarthCenter {}
type Position<F = GCRS, U = Kilometer> = cartesian::Position<EarthCenter, F, U>;
use std::io::{BufRead, BufReader, Read, Write};
use tempoch::{Time, UTC};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Sp3Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("malformed SP3 header at line {line}: {message}")]
Header {
line: usize,
message: String,
},
#[error("malformed SP3 record at line {line}: {message}")]
Record {
line: usize,
message: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sp3Position {
pub sat_id: String,
pub position: Position<GCRS, Kilometer>,
pub clock: Microseconds,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sp3Epoch {
pub time: Time<UTC>,
pub positions: Vec<Sp3Position>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Sp3Record {
pub header: Vec<String>,
pub epochs: Vec<Sp3Epoch>,
}
fn civil_to_time_utc(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: f64,
line: usize,
) -> Result<Time<UTC>, Sp3Error> {
let second_int = second as u32;
let nanos = ((second - second_int as f64) * 1e9).round() as u32;
let naive = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|d| d.and_hms_nano_opt(hour, minute, second_int, nanos))
.ok_or_else(|| Sp3Error::Record {
line,
message: "invalid epoch date/time".into(),
})?;
let dt: DateTime<ChronoUtc> = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
Time::<UTC>::try_from_chrono(dt).map_err(|e| Sp3Error::Record {
line,
message: format!("epoch UTC conversion: {e}"),
})
}
pub fn read_sp3<R: Read>(r: R) -> Result<Sp3Record, Sp3Error> {
let mut buf = BufReader::new(r);
let mut header = Vec::new();
let mut epochs = Vec::new();
let mut line_no = 0usize;
let mut in_header = true;
let mut current: Option<Sp3Epoch> = None;
let mut s = String::new();
loop {
s.clear();
let n = buf.read_line(&mut s)?;
if n == 0 {
break;
}
line_no += 1;
let trimmed_eol = s.trim_end_matches(['\r', '\n']).to_string();
if trimmed_eol == "EOF" {
break;
}
if in_header {
if trimmed_eol.starts_with('*') {
in_header = false;
} else {
header.push(trimmed_eol);
continue;
}
}
if trimmed_eol.starts_with('*') {
if let Some(e) = current.take() {
epochs.push(e);
}
let fields: Vec<&str> = trimmed_eol
.strip_prefix('*')
.unwrap_or(trimmed_eol.as_str())
.split_whitespace()
.collect();
if fields.len() < 6 {
return Err(Sp3Error::Record {
line: line_no,
message: format!("epoch needs 6 fields, got {}", fields.len()),
});
}
current = Some(Sp3Epoch {
time: civil_to_time_utc(
parse_field(fields[0], line_no, "year")?,
parse_field(fields[1], line_no, "month")?,
parse_field(fields[2], line_no, "day")?,
parse_field(fields[3], line_no, "hour")?,
parse_field(fields[4], line_no, "minute")?,
parse_field(fields[5], line_no, "second")?,
line_no,
)?,
positions: Vec::new(),
});
} else if trimmed_eol.starts_with('P') {
let epoch = current.as_mut().ok_or_else(|| Sp3Error::Record {
line: line_no,
message: "P record before any epoch".into(),
})?;
let id = trimmed_eol
.get(1..4)
.ok_or_else(|| Sp3Error::Record {
line: line_no,
message: "P record too short for sat id".into(),
})?
.trim()
.to_string();
let rest: Vec<&str> = trimmed_eol[4..].split_whitespace().collect();
if rest.len() < 4 {
return Err(Sp3Error::Record {
line: line_no,
message: format!("P record needs 4 numeric fields, got {}", rest.len()),
});
}
epoch.positions.push(Sp3Position {
sat_id: id,
position: Position::<GCRS, Kilometer>::new(
parse_field::<f64>(rest[0], line_no, "x")?,
parse_field::<f64>(rest[1], line_no, "y")?,
parse_field::<f64>(rest[2], line_no, "z")?,
),
clock: Microseconds::new(parse_field(rest[3], line_no, "clock")?),
});
} else if trimmed_eol.starts_with('V')
|| trimmed_eol.starts_with("EP")
|| trimmed_eol.starts_with("EV")
{
continue;
}
}
if let Some(e) = current {
epochs.push(e);
}
Ok(Sp3Record { header, epochs })
}
fn parse_field<T>(raw: &str, line: usize, what: &str) -> Result<T, Sp3Error>
where
T: std::str::FromStr,
<T as std::str::FromStr>::Err: std::fmt::Display,
{
raw.parse::<T>().map_err(|e| Sp3Error::Record {
line,
message: format!("bad {what}: {e}"),
})
}
pub fn write_sp3<W: Write>(w: &mut W, rec: &Sp3Record) -> Result<(), Sp3Error> {
use chrono::{Datelike, Timelike};
for h in &rec.header {
writeln!(w, "{h}")?;
}
for e in &rec.epochs {
let dt = e.time.try_to_chrono().map_err(|err| Sp3Error::Record {
line: 0,
message: format!("epoch to chrono: {err}"),
})?;
let second = dt.second() as f64 + dt.nanosecond() as f64 / 1e9;
writeln!(
w,
"* {:4} {:>2} {:>2} {:>2} {:>2} {:11.8}",
dt.year(),
dt.month(),
dt.day(),
dt.hour(),
dt.minute(),
second
)?;
for p in &e.positions {
writeln!(
w,
"P{:<3} {:14.6} {:14.6} {:14.6} {:14.6}",
p.sat_id,
p.position.x().value(),
p.position.y().value(),
p.position.z().value(),
p.clock.value()
)?;
}
}
writeln!(w, "EOF")?;
Ok(())
}
pub struct Sp3Stream<R: Read> {
inner: BufReader<R>,
header: Vec<String>,
pending_epoch_line: Option<(usize, String)>,
line_no: usize,
finished: bool,
}
impl<R: Read> Sp3Stream<R> {
pub fn new(r: R) -> Result<Self, Sp3Error> {
let mut inner = BufReader::new(r);
let mut header = Vec::new();
let mut line_no = 0usize;
let mut s = String::new();
let pending = loop {
s.clear();
let n = inner.read_line(&mut s)?;
if n == 0 {
break None;
}
line_no += 1;
let trimmed = s.trim_end_matches(['\r', '\n']).to_string();
if trimmed == "EOF" {
break None;
}
if trimmed.starts_with('*') {
break Some((line_no, trimmed));
}
header.push(trimmed);
};
Ok(Self {
inner,
header,
pending_epoch_line: pending,
line_no,
finished: false,
})
}
pub fn header(&self) -> &[String] {
&self.header
}
pub fn next_epoch(&mut self) -> Result<Option<Sp3Epoch>, Sp3Error> {
if self.finished {
return Ok(None);
}
let (epoch_line_no, epoch_line) = match self.pending_epoch_line.take() {
Some(v) => v,
None => {
self.finished = true;
return Ok(None);
}
};
let mut epoch = parse_epoch_line(&epoch_line, epoch_line_no)?;
let mut s = String::new();
loop {
s.clear();
let n = self.inner.read_line(&mut s)?;
if n == 0 {
self.finished = true;
break;
}
self.line_no += 1;
let trimmed = s.trim_end_matches(['\r', '\n']).to_string();
if trimmed == "EOF" {
self.finished = true;
break;
}
if trimmed.starts_with('*') {
self.pending_epoch_line = Some((self.line_no, trimmed));
break;
}
if trimmed.starts_with('P') {
epoch.positions.push(parse_p_line(&trimmed, self.line_no)?);
}
}
Ok(Some(epoch))
}
}
fn parse_epoch_line(line: &str, line_no: usize) -> Result<Sp3Epoch, Sp3Error> {
let fields: Vec<&str> = line
.strip_prefix('*')
.unwrap_or(line)
.split_whitespace()
.collect();
if fields.len() < 6 {
return Err(Sp3Error::Record {
line: line_no,
message: format!("epoch needs 6 fields, got {}", fields.len()),
});
}
Ok(Sp3Epoch {
time: civil_to_time_utc(
parse_field(fields[0], line_no, "year")?,
parse_field(fields[1], line_no, "month")?,
parse_field(fields[2], line_no, "day")?,
parse_field(fields[3], line_no, "hour")?,
parse_field(fields[4], line_no, "minute")?,
parse_field(fields[5], line_no, "second")?,
line_no,
)?,
positions: Vec::new(),
})
}
fn parse_p_line(line: &str, line_no: usize) -> Result<Sp3Position, Sp3Error> {
let id = line
.get(1..4)
.ok_or_else(|| Sp3Error::Record {
line: line_no,
message: "P record too short for sat id".into(),
})?
.trim()
.to_string();
let rest: Vec<&str> = line[4..].split_whitespace().collect();
if rest.len() < 4 {
return Err(Sp3Error::Record {
line: line_no,
message: format!("P record needs 4 numeric fields, got {}", rest.len()),
});
}
Ok(Sp3Position {
sat_id: id,
position: Position::<GCRS, Kilometer>::new(
parse_field::<f64>(rest[0], line_no, "x")?,
parse_field::<f64>(rest[1], line_no, "y")?,
parse_field::<f64>(rest[2], line_no, "z")?,
),
clock: Microseconds::new(parse_field(rest[3], line_no, "clock")?),
})
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
#dP2024 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS\n\
## 2295 518400.00000000 900.00000000 60310 0.0000000000000\n\
+ 2 G01G02 \n\
++ 5 5 \n\
%c L cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
%i 0 0 0 0 0 0 0 0 0\n\
%i 0 0 0 0 0 0 0 0 0\n\
/* COMMENT LINE\n\
* 2024 1 1 0 0 0.00000000\n\
PG01 1000.000000 2000.000000 3000.000000 0.000123\n\
PG02 -1500.000000 1700.000000 -2500.000000 0.000456\n\
* 2024 1 1 0 15 0.00000000\n\
PG01 1100.000000 2100.000000 3100.000000 0.000124\n\
PG02 -1400.000000 1800.000000 -2400.000000 0.000457\n\
EOF\n";
#[test]
fn parse_sample() {
let rec = read_sp3(SAMPLE.as_bytes()).expect("parse sample");
assert_eq!(rec.epochs.len(), 2);
assert_eq!(rec.epochs[0].positions.len(), 2);
assert_eq!(rec.epochs[0].positions[0].sat_id, "G01");
assert!((rec.epochs[0].positions[0].position.x().value() - 1000.0).abs() < 1e-9);
}
#[test]
fn round_trip() {
let rec = read_sp3(SAMPLE.as_bytes()).expect("parse");
let mut buf = Vec::new();
write_sp3(&mut buf, &rec).expect("write");
let rec2 = read_sp3(buf.as_slice()).expect("re-parse");
assert_eq!(rec.epochs.len(), rec2.epochs.len());
assert_eq!(rec.header.len(), rec2.header.len());
for (e1, e2) in rec.epochs.iter().zip(rec2.epochs.iter()) {
let t1 = e1.time.raw_seconds_pair().0.value();
let t2 = e2.time.raw_seconds_pair().0.value();
assert!((t1 - t2).abs() < 1e-3, "epoch time mismatch: {t1} vs {t2}");
assert_eq!(e1.positions, e2.positions);
}
}
}