use super::{FileLocation, FormatError, ParseMode};
use chrono::{DateTime, NaiveDate, Utc as ChronoUtc};
use qtty::length::Meters;
use qtty::time::Seconds;
use std::fs;
use std::path::Path;
use tempoch::{Time, UTC};
const SPEED_OF_LIGHT_M_S: f64 = 299_792_458.0;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CrdStation {
pub name: String,
pub cdp_pad: i32,
pub sys_no: i32,
pub occ_no: i32,
pub time_zone: i32,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CrdTarget {
pub name: String,
pub sic: i32,
pub norad: String,
pub sc_flag: i32,
pub epoch_id: i32,
}
#[derive(Debug, Clone)]
pub struct NormalPoint {
pub seconds_of_day: Seconds,
pub epoch: Option<Time<UTC>>,
pub time_of_flight: Seconds,
pub range_m: Meters,
pub system_config_id: String,
pub epoch_event: u8,
pub filter_flag: u8,
pub data_quality: u8,
pub format_flag: u8,
pub num_raws: Option<u32>,
pub bin_rms_m: Option<Meters>,
pub bin_size_s: Option<Seconds>,
pub return_rate: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub struct CrdFile {
pub station_name: String,
pub station_cdp_pad: i32,
pub satellite_name: String,
pub satellite_sic: i32,
pub satellite_norad: String,
pub session_date: Option<Time<UTC>>,
pub format_version: String,
pub station: CrdStation,
pub target: CrdTarget,
pub normal_points: Vec<NormalPoint>,
pub parse_mode: ParseMode,
}
pub fn read_crd<P: AsRef<Path>>(path: P) -> Result<CrdFile, FormatError> {
let text = fs::read_to_string(path.as_ref())?;
parse_crd_impl(&text, ParseMode::Strict, Some(path.as_ref().into()))
}
pub fn parse_crd(text: &str) -> Result<CrdFile, FormatError> {
parse_crd_impl(text, ParseMode::Strict, None)
}
pub fn parse_crd_with_mode(text: &str, mode: ParseMode) -> Result<CrdFile, FormatError> {
parse_crd_impl(text, mode, None)
}
fn parse_crd_impl(
text: &str,
mode: ParseMode,
path: Option<std::path::PathBuf>,
) -> Result<CrdFile, FormatError> {
let mut out = CrdFile {
parse_mode: mode,
..Default::default()
};
let mut current_sys = String::from("std");
let mut h4_date: Option<(i32, u32, u32)> = None;
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.format_version = v.to_string();
}
}
"H2" => {
let name = tokens.next().unwrap_or("").to_string();
let cdp_pad = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let sys_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let occ_no = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let time_zone = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
out.station = CrdStation {
name: name.clone(),
cdp_pad,
sys_no,
occ_no,
time_zone,
};
out.station_name = name;
out.station_cdp_pad = cdp_pad;
}
"H3" => {
let name = tokens.next().unwrap_or("").to_string();
let sic: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let norad = tokens.next().unwrap_or("").to_string();
let sc_flag: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let epoch_id: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
out.target = CrdTarget {
name: name.clone(),
sic,
norad: norad.clone(),
sc_flag,
epoch_id,
};
out.satellite_name = name;
out.satellite_sic = sic;
out.satellite_norad = norad;
}
"H4" => {
let _kind = tokens.next();
let year: i32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let month: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let day: u32 = tokens.next().and_then(|s| s.parse().ok()).unwrap_or(0);
if let Some(naive) =
NaiveDate::from_ymd_opt(year, month, day).and_then(|d| d.and_hms_opt(0, 0, 0))
{
let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
if let Ok(t) = Time::<UTC>::try_from_chrono(dt) {
out.session_date = Some(t);
h4_date = Some((year, month, day));
}
}
}
"H5" | "H8" | "H9" => { }
"C0" => {
let _detail = tokens.next();
if let Some(s) = tokens.next() {
current_sys = s.to_string();
}
}
"C1" | "C2" | "C3" | "C4" => { }
"10" | "11" => {
let record_type: u8 = if tag == "11" { 11 } else { 10 };
let sod_str = tokens.next();
let tof_str = tokens.next();
let (sod, tof) = match (
sod_str.and_then(|s| s.parse::<f64>().ok()),
tof_str.and_then(|s| s.parse::<f64>().ok()),
) {
(Some(s), Some(t)) => (s, t),
(None, _) => {
let loc = FileLocation::new(path.clone(), Some(line_no), None);
let err = FormatError::located(
"CRD v2 §4.1",
loc,
format!("record {tag}: missing seconds-of-day"),
);
if mode == ParseMode::Strict {
return Err(err);
}
continue;
}
(_, None) => {
let loc = FileLocation::new(path.clone(), Some(line_no), None);
let err = FormatError::located(
"CRD v2 §4.1",
loc,
format!("record {tag}: missing time-of-flight"),
);
if mode == ParseMode::Strict {
return Err(err);
}
continue;
}
};
let rest: Vec<&str> = tokens.collect();
if record_type == 11 {
let epoch = h4_date.and_then(|(y, mo, d)| {
let whole = sod as i64;
let subsec_nanos = ((sod - whole as f64) * 1_000_000_000.0).round() as u32;
let hh = (whole / 3600) as u32;
let mm = ((whole % 3600) / 60) as u32;
let ss = (whole % 60) as u32;
let naive = NaiveDate::from_ymd_opt(y, mo, d)
.and_then(|nd| nd.and_hms_nano_opt(hh, mm, ss, subsec_nanos))?;
let dt = DateTime::from_naive_utc_and_offset(naive, ChronoUtc);
Time::<UTC>::try_from_chrono(dt).ok()
});
let range_m = Meters::new(tof * SPEED_OF_LIGHT_M_S / 2.0);
let sys_id = rest
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| current_sys.clone());
let epoch_event: u8 = rest.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
let filter_flag: u8 = rest.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
let data_quality: u8 = rest.get(3).and_then(|s| s.parse().ok()).unwrap_or(0);
let format_flag: u8 = rest.get(4).and_then(|s| s.parse().ok()).unwrap_or(0);
let num_raws: Option<u32> = rest.get(5).and_then(|s| s.parse().ok());
let bin_rms_m: Option<Meters> = rest
.get(6)
.and_then(|s| s.parse::<f64>().ok())
.map(|ps| Meters::new(ps * 1e-12 * SPEED_OF_LIGHT_M_S / 2.0));
let return_rate: Option<f64> = rest.get(9).and_then(|s| s.parse().ok());
out.normal_points.push(NormalPoint {
seconds_of_day: Seconds::new(sod),
epoch,
time_of_flight: Seconds::new(tof),
range_m,
system_config_id: sys_id,
epoch_event,
filter_flag,
data_quality,
format_flag,
num_raws,
bin_rms_m,
bin_size_s: None,
return_rate,
});
}
}
"12" | "20" | "21" | "30" | "40" | "41" | "50" | "60" => {}
_ => { }
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::ParseMode;
use super::*;
#[test]
fn parses_minimal_crd() {
let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 7090 7090 1 01 0\n\
H3 lageos1 1155 7603901 8820 1\n\
H4 0 2024 01 01 00 00 00 00 00 00 0 0 0 0 1 0\n\
C0 0 std\n\
11 12345.000 0.05123456789 std 1 1 1 1 0 0\n\
11 12375.000 0.05123446712 std 1 1 1 1 0 0\n\
H8\n";
let f = parse_crd(txt).expect("parse");
assert_eq!(f.station_name, "7090");
assert_eq!(f.satellite_name, "lageos1");
use chrono::Datelike;
let d = f.session_date.unwrap().try_to_chrono().unwrap();
assert_eq!(d.year(), 2024);
assert_eq!(d.month(), 1);
assert_eq!(d.day(), 1);
assert_eq!(f.normal_points.len(), 2);
assert!((f.normal_points[0].time_of_flight.value() - 0.05123456789).abs() < 1e-15);
}
#[test]
fn normal_points_populated() {
let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 GRAZ 7839 1 1 0\n\
H3 lageos1 1155 7603901 0 1\n\
H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\
C0 0 std 1 5 0 0 1 0 0 0 0 0 0 0\n\
11 28800.0 0.051234567890 std 0 0 1 0 5 8.0 0.0 0.0 0.0 90.0\n\
11 28830.0 0.051198765432 std 0 0 1 0 5 7.5 0.0 0.0 0.0 88.0\n\
H8\n";
let f = parse_crd(txt).expect("parse");
assert_eq!(f.normal_points.len(), 2);
let np = &f.normal_points[0];
assert!((np.time_of_flight.value() - 0.051234567890).abs() < 1e-12);
let expected_m = 0.051234567890 * SPEED_OF_LIGHT_M_S / 2.0;
assert!((np.range_m.value() - expected_m).abs() < 1.0);
assert_eq!(np.num_raws, Some(5));
}
#[test]
fn epoch_computed_from_session_date() {
let txt = "\
H1 CRD 2 2024 01 01 00\n\
H2 GRAZ 7839 1 1 0\n\
H3 lageos1 1155 7603901 0 1\n\
H4 0 2024 01 01 08 00 00 12 00 00 0 0 0 0 1 0\n\
11 3600.0 0.051 std 0 0 0 0\n\
H8\n";
let f = parse_crd(txt).expect("parse");
let np = &f.normal_points[0];
assert!(np.epoch.is_some(), "epoch should be computed from H4 date");
use chrono::Timelike;
let dt = np.epoch.unwrap().try_to_chrono().unwrap();
assert_eq!(dt.hour(), 1); }
#[test]
fn station_and_target_populated() {
let txt = "\
H2 GRAZ 7839 1 2 0\n\
H3 lageos1 1155 7603901 0 1\n\
H8\n";
let f = parse_crd(txt).expect("parse");
assert_eq!(f.station.name, "GRAZ");
assert_eq!(f.station.cdp_pad, 7839);
assert_eq!(f.station.sys_no, 1);
assert_eq!(f.station.occ_no, 2);
assert_eq!(f.target.name, "lageos1");
assert_eq!(f.target.sic, 1155);
assert_eq!(f.target.norad, "7603901");
}
#[test]
fn format_version_parsed() {
let txt = "H1 CRD 2 2024 01 01 00\nH8\n";
let f = parse_crd(txt).expect("parse");
assert_eq!(f.format_version, "2");
}
#[test]
fn strict_missing_sod_fails() {
let txt = "H2 S 0 0 0 0\n11\nH8\n";
let err = parse_crd(txt).expect_err("strict: missing SOD");
assert!(matches!(err, FormatError::Located { .. }));
}
#[test]
fn permissive_missing_sod_skips() {
let txt = "H2 S 0 0 0 0\n11\nH8\n";
let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok");
assert_eq!(f.normal_points.len(), 0);
}
#[test]
fn strict_missing_tof_fails() {
let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n";
let err = parse_crd(txt).expect_err("strict: missing TOF");
assert!(matches!(err, FormatError::Located { .. }));
}
#[test]
fn permissive_missing_tof_skips() {
let txt = "H2 S 0 0 0 0\n11 123.0\nH8\n";
let f = parse_crd_with_mode(txt, ParseMode::Permissive).expect("permissive ok");
assert_eq!(f.normal_points.len(), 0);
}
#[test]
fn full_rate_records_are_not_exposed_as_normal_points() {
let txt = "H2 S 0 0 0 0\n10 100.0 0.08 std\nH8\n";
let f = parse_crd(txt).expect("parse full-rate");
assert_eq!(f.normal_points.len(), 0);
}
}