use super::{FileLocation, FormatError, ParseMode};
use affn::cartesian;
use affn::centers::{AffineCenter, ReferenceCenter};
use affn::frames::ITRF;
use chrono::{DateTime, NaiveDate, Utc as ChronoUtc};
use qtty::unit::Kilometer;
use qtty::Day;
use std::fs;
use std::io::Write;
use std::path::Path;
use tempoch::{ModifiedJulianDate, Time, UTC};
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
pub struct GeocentricCenter;
impl ReferenceCenter for GeocentricCenter {
type Params = ();
fn center_name() -> &'static str {
"Geocentric"
}
}
impl AffineCenter for GeocentricCenter {}
type CpfCartesian = cartesian::Position<GeocentricCenter, ITRF, Kilometer>;
#[derive(Debug, Clone, PartialEq)]
pub struct CpfEphemerisEntry {
pub epoch: Time<UTC>,
pub position: CpfCartesian,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CpfEphemeris {
pub entries: Vec<CpfEphemerisEntry>,
}
impl CpfEphemeris {
pub fn iter(&self) -> impl Iterator<Item = &CpfEphemerisEntry> {
self.entries.iter()
}
}
impl std::ops::Index<usize> for CpfEphemeris {
type Output = CpfEphemerisEntry;
fn index(&self, i: usize) -> &CpfEphemerisEntry {
&self.entries[i]
}
}
#[derive(Debug, Clone, Default)]
pub struct CpfFile {
pub source: String,
pub version: String,
pub target_name: String,
pub cospar_id: String,
pub sic: i32,
pub norad: String,
pub reference_frame: String,
pub time_step_s: f64,
pub ephemeris: CpfEphemeris,
pub parse_mode: ParseMode,
}
pub fn read_cpf<P: AsRef<Path>>(path: P) -> Result<CpfFile, FormatError> {
let text = fs::read_to_string(path.as_ref())?;
parse_cpf_impl(&text, ParseMode::Strict, Some(path.as_ref().into()))
}
pub fn parse_cpf(text: &str) -> Result<CpfFile, FormatError> {
parse_cpf_impl(text, ParseMode::Strict, None)
}
pub fn parse_cpf_with_mode(text: &str, mode: ParseMode) -> Result<CpfFile, FormatError> {
parse_cpf_impl(text, mode, None)
}
pub fn write_cpf<W: Write>(f: &CpfFile, w: &mut W) -> Result<(), FormatError> {
writeln!(w, "H1 CPF {} {} 2024 01 01 00 1", f.version, f.source).map_err(FormatError::Io)?;
writeln!(
w,
"H2 {} {} {} 2024 01 01 00 00 00 2024 01 01 01 00 00 {} 0 {}",
if f.cospar_id.is_empty() {
&f.target_name
} else {
&f.cospar_id
},
f.sic,
if f.norad.is_empty() { "0" } else { &f.norad },
if f.time_step_s > 0.0 {
format!("{}", f.time_step_s as u64)
} else {
"60".to_string()
},
f.reference_frame,
)
.map_err(FormatError::Io)?;
for entry in &f.ephemeris.entries {
let (mjd_int, sod) = mjd_sod_from_epoch(entry.epoch);
let x_m = entry.position.x().value() * 1_000.0;
let y_m = entry.position.y().value() * 1_000.0;
let z_m = entry.position.z().value() * 1_000.0;
writeln!(
w,
"10 1 {} {:.3} 0 {:.3} {:.3} {:.3}",
mjd_int, sod, x_m, y_m, z_m,
)
.map_err(FormatError::Io)?;
}
writeln!(w, "99").map_err(FormatError::Io)?;
Ok(())
}
fn mjd_sod_from_epoch(epoch: Time<UTC>) -> (i64, f64) {
use chrono::Timelike;
let dt = epoch
.try_to_chrono()
.expect("CPF UTC epoch must be representable in chrono");
let mjd_epoch =
NaiveDate::from_ymd_opt(1858, 11, 17).expect("MJD epoch 1858-11-17 is a valid date");
let mjd_int = dt.date_naive().signed_duration_since(mjd_epoch).num_days();
let sod = (dt.time().num_seconds_from_midnight() as f64)
+ (dt.timestamp_subsec_nanos() as f64) * 1e-9;
(mjd_int, sod)
}
fn parse_cpf_impl(
text: &str,
mode: ParseMode,
path: Option<std::path::PathBuf>,
) -> Result<CpfFile, FormatError> {
let mut out = CpfFile {
parse_mode: mode,
..Default::default()
};
for (line_idx, raw) in text.lines().enumerate() {
let line_no = line_idx + 1;
let line = raw.trim_end();
if line.is_empty() {
continue;
}
let mut tokens = line.split_whitespace();
let tag = match tokens.next() {
Some(t) => t,
None => continue,
};
match tag {
"H1" => {
let _fmt = tokens.next(); if let Some(v) = tokens.next() {
out.version = v.to_string();
}
if let Some(s) = tokens.next() {
out.source = s.to_string();
}
}
"H2" => {
let toks: Vec<&str> = tokens.collect();
if let Some(cospar) = toks.first() {
out.cospar_id = cospar.to_string();
out.target_name = cospar.to_string();
}
if let Some(sic_str) = toks.get(1) {
out.sic = sic_str.parse().unwrap_or(0);
}
if let Some(norad_str) = toks.get(2) {
out.norad = norad_str.to_string();
}
if let Some(step_str) = toks.get(15) {
out.time_step_s = step_str.parse().unwrap_or(0.0);
}
if let Some(frame) = toks.last() {
out.reference_frame = frame.to_string();
}
}
"10" => {
let rest: Vec<&str> = tokens.collect();
let maybe_mjd = rest.get(1).and_then(|s| s.parse::<i64>().ok());
let maybe_sod = rest.get(2).and_then(|s| s.parse::<f64>().ok());
let maybe_x = rest.get(4).and_then(|s| s.parse::<f64>().ok());
let maybe_y = rest.get(5).and_then(|s| s.parse::<f64>().ok());
let maybe_z = rest.get(6).and_then(|s| s.parse::<f64>().ok());
let (mjd_i, sod, x_m, y_m, z_m) =
match (maybe_mjd, maybe_sod, maybe_x, maybe_y, maybe_z) {
(Some(m), Some(s), Some(x), Some(y), Some(z)) => (m, s, x, y, z),
(None, ..) => {
let loc = FileLocation::new(path.clone(), Some(line_no), None);
let err =
FormatError::located("CPF v2 §4.1", loc, "record 10: missing MJD");
if mode == ParseMode::Strict {
return Err(err);
}
continue;
}
(_, None, ..) => {
let loc = FileLocation::new(path.clone(), Some(line_no), None);
let err =
FormatError::located("CPF v2 §4.1", loc, "record 10: missing SOD");
if mode == ParseMode::Strict {
return Err(err);
}
continue;
}
_ => {
let loc = FileLocation::new(path.clone(), Some(line_no), None);
let err = FormatError::located(
"CPF v2 §4.1",
loc,
"record 10: missing X/Y/Z",
);
if mode == ParseMode::Strict {
return Err(err);
}
continue;
}
};
let mjd = ModifiedJulianDate::<UTC>::try_new(Day::new(mjd_i as f64))
.map_err(|_| FormatError::Format(format!("CPF 10: invalid MJD {mjd_i}")))?;
let _ = (mjd, sod);
let epoch = build_epoch_from_mjd_sod(mjd_i, sod)?;
out.ephemeris.entries.push(CpfEphemerisEntry {
epoch,
position: CpfCartesian::new(x_m / 1_000.0, y_m / 1_000.0, z_m / 1_000.0),
});
}
"20" | "30" | "40" | "50" | "60" => { }
"99" => break, _ => {} }
}
Ok(out)
}
fn build_epoch_from_mjd_sod(mjd_int: i64, sod: f64) -> Result<Time<UTC>, FormatError> {
let jd_whole = mjd_int + 2_400_001; let l = jd_whole + 68_569;
let n = (4 * l) / 146_097;
let l2 = l - (146_097 * n + 3) / 4;
let i = (4_000 * (l2 + 1)) / 1_461_001;
let l3 = l2 - (1_461 * i) / 4 + 31;
let j = (80 * l3) / 2_447;
let day = l3 - (2_447 * j) / 80;
let l4 = j / 11;
let month = j + 2 - 12 * l4;
let year = 100 * (n - 49) + i + l4;
let sod_int = sod as u32;
let sod_nanos = ((sod - sod_int as f64) * 1e9).round() as u32;
let hour = sod_int / 3_600;
let minute = (sod_int % 3_600) / 60;
let second = sod_int % 60;
let naive = NaiveDate::from_ymd_opt(year as i32, month as u32, day as u32)
.and_then(|d| d.and_hms_nano_opt(hour, minute, second, sod_nanos))
.ok_or_else(|| FormatError::Format(format!("CPF 10: invalid date from MJD {mjd_int}")))?;
let dt: DateTime<ChronoUtc> = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
Time::<UTC>::try_from_chrono(dt)
.map_err(|e| FormatError::Format(format!("CPF 10: UTC conversion: {e}")))
}
#[cfg(test)]
mod tests {
use super::ParseMode;
use super::*;
const SAMPLE: &str = "\
H1 CPF 2 HTS 2024 01 01 00 1\n\
H2 lageos1 1155 7603901 2024 01 01 00 00 00 0 0 ITRF2014\n\
10 1 60310 0.000 0 7000000.0 0.0 0.0\n\
10 1 60310 60.000 0 7000100.0 1000.0 -50.0\n\
99\n";
#[test]
fn parses_minimal_cpf() {
let f = parse_cpf(SAMPLE).expect("parse");
assert_eq!(f.version, "2");
assert_eq!(f.reference_frame, "ITRF2014");
assert_eq!(f.ephemeris.entries.len(), 2);
assert!(
(f.ephemeris.entries[1].position.x().value() - 7_000.1).abs() < 1e-6,
"X position parsed"
);
}
#[test]
fn ephemeris_entries_populated() {
let f = parse_cpf(SAMPLE).expect("parse");
assert_eq!(f.ephemeris.entries.len(), 2);
let e0 = &f.ephemeris.entries[0];
assert!((e0.position.x().value() - 7_000.0).abs() < 1e-6);
assert!((e0.position.y().value()).abs() < 1e-6);
}
#[test]
fn ephemeris_epoch_correct() {
let f = parse_cpf(SAMPLE).expect("parse");
use chrono::Timelike;
let dt = f.ephemeris.entries[0].epoch.try_to_chrono().unwrap();
assert_eq!(dt.hour(), 0);
assert_eq!(dt.minute(), 0);
assert_eq!(dt.second(), 0);
}
#[test]
fn ephemeris_epoch_sod_offset() {
let f = parse_cpf(SAMPLE).expect("parse");
use chrono::Timelike;
let dt = f.ephemeris.entries[1].epoch.try_to_chrono().unwrap();
assert_eq!(dt.hour(), 0);
assert_eq!(dt.minute(), 1);
assert_eq!(dt.second(), 0);
}
#[test]
fn geocentric_center_name() {
use affn::centers::ReferenceCenter;
assert_eq!(GeocentricCenter::center_name(), "Geocentric");
}
#[test]
fn round_trip_write_reparse() {
let txt = "\
H1 CPF 2 CNES 2024 01 01 00 1\n\
H2 lageos1 1155 7603901 2024 01 01 00 00 00 2024 01 01 01 00 00 60 0 ITRF2014\n\
10 1 60310 0.000 0 6000000.000 9000000.000 7000000.000\n\
10 1 60310 60.000 0 6001200.000 8999100.000 7001500.000\n\
99\n";
let f1 = parse_cpf(txt).expect("parse original");
let mut buf = Vec::new();
write_cpf(&f1, &mut buf).expect("write");
let s = String::from_utf8(buf).expect("utf8");
let f2 = parse_cpf(&s).expect("re-parse");
assert_eq!(
f1.ephemeris.entries.len(),
f2.ephemeris.entries.len(),
"ephemeris entry count preserved"
);
assert_eq!(
f1.reference_frame, f2.reference_frame,
"reference frame preserved"
);
for (p1, p2) in f1.ephemeris.entries.iter().zip(f2.ephemeris.entries.iter()) {
assert!(
(p1.position.x().value() - p2.position.x().value()).abs() < 1e-6,
"X position round-trip"
);
assert!(
(p1.position.y().value() - p2.position.y().value()).abs() < 1e-6,
"Y position round-trip"
);
assert!(
(p1.position.z().value() - p2.position.z().value()).abs() < 1e-6,
"Z position round-trip"
);
}
}
#[test]
fn strict_missing_mjd_fails() {
let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1\n";
let err = parse_cpf(bad).expect_err("missing MJD should fail");
assert!(matches!(err, FormatError::Located { .. }));
}
#[test]
fn permissive_missing_mjd_skips() {
let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1\n";
let f = parse_cpf_with_mode(bad, ParseMode::Permissive).expect("permissive ok");
assert_eq!(f.ephemeris.entries.len(), 0);
}
#[test]
fn strict_missing_xyz_fails() {
let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1 60310 0.0 0\n";
let err = parse_cpf(bad).expect_err("missing XYZ should fail");
assert!(matches!(err, FormatError::Located { .. }));
}
#[test]
fn permissive_missing_xyz_skips() {
let bad = "H1 CPF 2 TST 2024 01 01 00 1\nH2 tgt 0 0\n10 1 60310 0.0 0\n";
let f = parse_cpf_with_mode(bad, ParseMode::Permissive).expect("permissive ok");
assert_eq!(f.ephemeris.entries.len(), 0);
}
#[test]
fn unknown_tags_ignored() {
let txt = "H1 CPF 2 CNES 2024 01 01 00 1\nXX garbage\nH2 sat 0 0\n99\n";
let f = parse_cpf(txt).expect("no error");
assert_eq!(f.source, "CNES");
}
}