use std::path::Path;
#[derive(Clone, Debug, PartialEq)]
pub struct LlrNormalPoint {
pub station_name: String,
pub station_id: u32,
pub time_scale: u32,
pub target: String,
pub jd_utc: f64,
pub two_way_tof_s: f64,
pub epoch_event: u32,
pub window_s: f64,
pub raw_ranges: u32,
pub bin_rms_ps: f64,
}
impl LlrNormalPoint {
pub fn sigma_two_way_s(&self) -> Option<f64> {
if self.bin_rms_ps > 0.0 && self.raw_ranges > 0 {
Some(self.bin_rms_ps * 1.0e-12 / (self.raw_ranges as f64).sqrt())
} else {
None
}
}
pub fn sigma_range_m(&self) -> Option<f64> {
self.sigma_two_way_s()
.map(|s| s * crate::timegeo::C_M_PER_S / 2.0)
}
pub fn one_way_range_m(&self) -> f64 {
self.two_way_tof_s * crate::timegeo::C_M_PER_S / 2.0
}
}
fn jd_at_midnight(year: i32, month: u32, day: u32) -> f64 {
crate::timescales::julian_date(year, month, day, 0, 0, 0.0)
}
pub fn parse_crd(label: &str, text: &str) -> Result<Vec<LlrNormalPoint>, String> {
let mut out = Vec::new();
let mut station_name = String::new();
let mut station_id: u32 = 0;
let mut time_scale: u32 = 0;
let mut target = String::new();
let mut start_jd = f64::NAN;
let mut start_sod = f64::NAN;
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim_end();
let f: Vec<&str> = line.split_whitespace().collect();
if f.is_empty() {
continue;
}
let at = |what: &str| format!("{label}:{}: {what}", lineno + 1);
match f[0] {
"h1" | "H1" => {
if f.len() < 3 {
return Err(at("short h1 record"));
}
let v: u32 = f[2]
.parse()
.map_err(|_| at(&format!("unreadable CRD version {:?}", f[2])))?;
if v != 1 && v != 2 {
return Err(at(&format!(
"CRD format version {v} is not supported (only 1 and 2 share the \
record-11 field order this reader relies on)"
)));
}
}
"h2" | "H2" => {
if f.len() < 6 {
return Err(at("short h2 (station) record"));
}
station_name = f[1].to_string();
station_id = f[2]
.parse()
.map_err(|_| at(&format!("unreadable station id {:?}", f[2])))?;
time_scale = f[5]
.parse()
.map_err(|_| at(&format!("unreadable time scale {:?}", f[5])))?;
if !matches!(time_scale, 3 | 4 | 7) {
return Err(at(&format!(
"CRD epoch time scale {time_scale} is not one of the three the \
format specification says analysts accept (3 = UTC(USNO), \
4 = UTC(GPS), 7 = UTC(BIH)); refusing to read it as UTC"
)));
}
}
"h3" | "H3" => {
if f.len() < 2 {
return Err(at("short h3 (target) record"));
}
target = f[1].to_string();
}
"h4" | "H4" => {
if f.len() < 14 {
return Err(at("short h4 (session) record"));
}
let num = |i: usize| -> Result<f64, String> {
f[i].parse::<f64>()
.map_err(|_| at(&format!("unreadable h4 field {i}: {:?}", f[i])))
};
let (y, mo, d) = (num(2)? as i32, num(3)? as u32, num(4)? as u32);
let (h, mi, s) = (num(5)?, num(6)?, num(7)?);
start_jd = jd_at_midnight(y, mo, d);
start_sod = h * 3600.0 + mi * 60.0 + s;
}
"11" => {
if f.len() < 7 {
return Err(at("short normal-point (11) record"));
}
if station_id == 0 || target.is_empty() || !start_jd.is_finite() {
return Err(at(
"a normal-point record appeared before its h2/h3/h4 session header",
));
}
let num = |i: usize| -> Result<f64, String> {
f[i].parse::<f64>()
.map_err(|_| at(&format!("unreadable field {i}: {:?}", f[i])))
};
let mut sod = num(1)?;
let tof = num(2)?;
let epoch_event: u32 = f[4]
.parse()
.map_err(|_| at(&format!("unreadable epoch event {:?}", f[4])))?;
let window = num(5)?;
let raw_ranges: u32 = num(6)? as u32;
let bin_rms = num(7).unwrap_or(0.0);
if !(tof.is_finite() && tof > 0.0) {
return Err(at(&format!("non-physical time of flight {tof}")));
}
if sod + 43_200.0 < start_sod {
sod += 86_400.0;
}
out.push(LlrNormalPoint {
station_name: station_name.clone(),
station_id,
time_scale,
target: target.clone(),
jd_utc: start_jd + sod / 86_400.0,
two_way_tof_s: tof,
epoch_event,
window_s: window,
raw_ranges,
bin_rms_ps: bin_rms,
});
}
_ => {}
}
}
Ok(out)
}
pub fn read_crd_dir(dir: &Path) -> Result<Vec<LlrNormalPoint>, String> {
let mut paths: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| format!("cannot read normal-point directory {}: {e}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("npt"))
.collect();
paths.sort();
if paths.is_empty() {
return Err(format!(
"no *.npt normal-point files in {} — this scenario reads real archived \
measurements and will not run without them",
dir.display()
));
}
let mut all = Vec::new();
for p in &paths {
let text =
std::fs::read_to_string(p).map_err(|e| format!("cannot read {}: {e}", p.display()))?;
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("<file>")
.to_string();
all.extend(parse_crd(&name, &text)?);
}
Ok(all)
}
#[cfg(test)]
mod tests {
use super::*;
const SESSION: &str = "\
h1 CRD 1 2015 4 29 22
h2 GRSM 7845 78 1 4
h3 apollo15 103 103 0 2
h4 1 2015 4 29 21 57 46 2015 4 29 22 9 8 0 0 0 0 1 0 2 0
c0 0 1064.2 me09 las6 det2 tim2
40 78719.19 0 me09 33 33 3.716 107173 0.0 84 0.000 0.000 0.0 3 3 0
20 79114. 873.84 279.34 84 0
11 79512.253056967700 2.660923125349 me09 2 682.7 36 232.2 .400 -.600 0.0 4.9 0
50 me09 293.8 -1.000 -1.000 -1.0 1
H8
";
#[test]
fn one_session_parses_to_one_normal_point_with_its_measured_fields() {
let np = parse_crd("t", SESSION).expect("parses");
assert_eq!(np.len(), 1);
let p = &np[0];
assert_eq!(p.station_name, "GRSM");
assert_eq!(p.station_id, 7845);
assert_eq!(p.time_scale, 4);
assert_eq!(p.target, "apollo15");
assert_eq!(p.epoch_event, 2);
assert_eq!(p.raw_ranges, 36);
assert!((p.bin_rms_ps - 232.2).abs() < 1e-9);
assert!((p.two_way_tof_s - 2.660_923_125_349).abs() < 1e-15);
let want = crate::timescales::julian_date(2015, 4, 29, 0, 0, 0.0)
+ 79_512.253_056_967_7 / 86_400.0;
assert!(
(p.jd_utc - want).abs() < 1e-12,
"jd {} vs {}",
p.jd_utc,
want
);
}
#[test]
fn the_measured_sigma_is_the_bin_rms_over_root_n_and_nothing_else() {
let p = &parse_crd("t", SESSION).unwrap()[0];
let want = 232.2e-12 / 36.0_f64.sqrt();
assert!((p.sigma_two_way_s().unwrap() - want).abs() < 1e-24);
let mm = p.sigma_range_m().unwrap() * 1e3;
assert!((5.0..7.0).contains(&mm), "one-way sigma {mm} mm");
}
#[test]
fn the_archived_time_of_flight_is_a_lunar_distance() {
let p = &parse_crd("t", SESSION).unwrap()[0];
let km = p.one_way_range_m() / 1e3;
assert!(
(356_000.0..407_000.0).contains(&km),
"one-way range {km} km is outside the perigee/apogee envelope"
);
}
#[test]
fn a_point_with_an_empty_bin_has_no_measured_sigma_rather_than_a_substituted_one() {
let s = SESSION.replace(
"11 79512.253056967700 2.660923125349 me09 2 682.7 36 232.2",
"11 79512.253056967700 2.660923125349 me09 2 682.7 0 0.0",
);
let p = &parse_crd("t", &s).unwrap()[0];
assert!(p.sigma_two_way_s().is_none());
assert!(p.sigma_range_m().is_none());
}
#[test]
fn a_session_crossing_midnight_rolls_the_day_over() {
let s = SESSION
.replace(
"h4 1 2015 4 29 21 57 46 2015 4 29 22 9 8",
"h4 1 2015 4 29 23 50 0 2015 4 30 0 10 0",
)
.replace("11 79512.253056967700", "11 600.000000000000");
let p = &parse_crd("t", &s).unwrap()[0];
let want = crate::timescales::julian_date(2015, 4, 30, 0, 0, 0.0) + 600.0 / 86_400.0;
assert!((p.jd_utc - want).abs() < 1e-12);
}
#[test]
fn a_non_utc_time_scale_is_refused_not_read_as_utc() {
let s = SESSION.replace("h2 GRSM 7845 78 1 4", "h2 GRSM 7845 78 1 1");
let e = parse_crd("t", &s).expect_err("must refuse");
assert!(e.contains("time scale"), "{e}");
}
#[test]
fn an_unsupported_format_version_is_refused() {
let s = SESSION.replace("h1 CRD 1 2015", "h1 CRD 3 2015");
let e = parse_crd("t", &s).expect_err("must refuse");
assert!(e.contains("version 3"), "{e}");
}
}