use super::{FileLocation, FormatError, ParseMode};
#[cfg(not(feature = "std"))]
use crate::qtty::{Real, Scalar};
use affn::cartesian;
use affn::centers::{AffineCenter, ReferenceCenter};
use affn::frames::ITRF;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use qtty::unit::Kilometer;
#[cfg(feature = "std")]
use std::fs;
#[cfg(feature = "std")]
use std::io::Write;
#[cfg(feature = "std")]
use std::path::Path;
use tempoch::{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 core::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> {
let ctx = tempoch::TimeContext::new();
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 {
use chrono::Timelike;
let civil = entry
.epoch
.try_to_chrono_with(&ctx)
.map_err(|e| FormatError::Format(format!("CPF 10: UTC encoding: {e}")))?;
let midnight = civil
.date_naive()
.and_time(chrono::NaiveTime::MIN)
.and_utc();
let mjd_int = Time::<UTC>::try_from_chrono_with(midnight, &ctx)
.and_then(|epoch| epoch.to_with::<tempoch::MJD>(&ctx))
.and_then(|epoch| epoch.try_raw_with(&ctx))
.map_err(|e| FormatError::Format(format!("CPF 10: UTC date: {e}")))?
.value() as i64;
let sod =
civil.num_seconds_from_midnight() as f64 + civil.nanosecond() as f64 / 1_000_000_000.0;
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 {} {:.9} 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 parse_cpf_impl(
text: &str,
mode: ParseMode,
path: Option<std::path::PathBuf>,
) -> Result<CpfFile, FormatError> {
let ctx = tempoch::TimeContext::new();
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.as_ref().map(|p| p.display().to_string()),
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.as_ref().map(|p| p.display().to_string()),
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.as_ref().map(|p| p.display().to_string()),
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 epoch = build_epoch_from_mjd_sod(mjd_i, sod, &ctx)?;
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,
ctx: &tempoch::TimeContext,
) -> Result<Time<UTC>, FormatError> {
if !sod.is_finite() || !(0.0..86_401.0).contains(&sod) {
return Err(FormatError::Format(format!(
"CPF 10: invalid civil seconds-of-day {sod}"
)));
}
let day_start =
tempoch::ModifiedJulianDate::<UTC>::try_new_with(qtty::Day::new(mjd_int as f64), ctx)
.map(|epoch| epoch.to_j2000s())
.map_err(|e| FormatError::Format(format!("CPF 10: UTC date: {e}")))?;
let midnight = day_start
.try_to_chrono_with(ctx)
.map_err(|e| FormatError::Format(format!("CPF 10: UTC date: {e}")))?;
let whole = sod.floor() as u32;
let second = whole.min(86_399);
let nanos = ((sod - second as f64) * 1_000_000_000.0).round() as u32;
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(second, nanos)
.ok_or_else(|| FormatError::Format("CPF 10: invalid civil time".into()))?;
Time::<UTC>::try_from_chrono_with(midnight.date_naive().and_time(time).and_utc(), ctx)
.map_err(|e| FormatError::Format(format!("CPF 10: UTC conversion: {e}")))
}
#[cfg(test)]
mod tests {
use super::ParseMode;
use super::*;
use chrono::Timelike;
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");
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");
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");
}
}