use super::{Observation, Orient};
use crate::rinex_obs::parse_obs;
const MIN_CN0_DBHZ: f64 = 10.0;
const MAX_CN0_DBHZ: f64 = 70.0;
pub fn cn0_observations(text: &str) -> Result<Vec<Observation>, String> {
let rinex = parse_obs(text)?;
let mut out = Vec::new();
for epoch in &rinex.epochs {
if epoch.flag != 0 {
continue; }
for sat in &epoch.sats {
let Some(system) = sat.sat.chars().next() else {
continue;
};
let Some(codes) = rinex.header.codes_for(system) else {
continue;
};
for (k, code) in codes.iter().enumerate() {
if !code.starts_with('S') {
continue;
}
if let Some(Some(o)) = sat.obs.get(k) {
if o.value >= MIN_CN0_DBHZ && o.value <= MAX_CN0_DBHZ {
out.push(Observation::new("cn0", o.value, Orient::Negate));
}
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn place(fields: &[(usize, &str)]) -> String {
let mut s = String::new();
for (col, val) in fields {
if s.len() < *col {
s.push_str(&" ".repeat(col - s.len()));
}
s.push_str(val);
}
s
}
fn hdr(fields: &[(usize, &str)], label: &str) -> String {
let mut s = place(fields);
if s.len() < 60 {
s.push_str(&" ".repeat(60 - s.len()));
}
s.push_str(label);
s
}
fn rec(sat: &str, vals: &[f64]) -> String {
let mut fields = vec![(0usize, sat.to_string())];
for (k, v) in vals.iter().enumerate() {
fields.push((3 + k * 16, format!("{v:14.3}")));
}
let refs: Vec<(usize, &str)> = fields.iter().map(|(c, s)| (*c, s.as_str())).collect();
place(&refs)
}
fn rinex() -> String {
[
hdr(
&[(0, " 3.04"), (20, "OBSERVATION DATA"), (40, "M")],
"RINEX VERSION / TYPE",
),
hdr(
&[(0, "G"), (3, " 3"), (7, "C1C"), (11, "L1C"), (15, "S1C")],
"SYS / # / OBS TYPES",
),
hdr(&[], "END OF HEADER"),
"> 2024 01 01 00 00 0.0000000 0 2".to_string(),
rec("G01", &[23_456_789.123, 123_456.789, 45.000]),
rec("G02", &[23_987_654.321, 234_567.890, 38.500]),
"> 2024 01 01 00 00 30.0000000 0 2".to_string(),
rec("G01", &[23_456_999.123, 123_466.789, 31.000]),
rec("G02", &[23_987_111.321, 234_577.890, 37.000]),
]
.join("\n")
}
#[test]
fn extracts_one_cn0_per_sat_per_epoch_negated() {
let obs = cn0_observations(&rinex()).unwrap();
assert_eq!(obs.len(), 4);
assert!(obs.iter().all(|o| o.detector == "cn0"));
assert_eq!(obs[0].raw, 45.0);
assert_eq!(obs[0].score, -45.0);
}
#[test]
fn jammed_epoch_cn0_scores_above_clean_epoch() {
let obs = cn0_observations(&rinex()).unwrap();
let g01_clean = obs[0].score; let g01_jammed = obs[2].score; assert!(g01_jammed > g01_clean);
}
#[test]
fn rejects_out_of_range_non_dbhz_values() {
let bad = [
hdr(
&[(0, " 3.04"), (20, "OBSERVATION DATA"), (40, "M")],
"RINEX VERSION / TYPE",
),
hdr(&[(0, "G"), (3, " 1"), (7, "S1C")], "SYS / # / OBS TYPES"),
hdr(&[], "END OF HEADER"),
"> 2024 01 01 00 00 0.0000000 0 1".to_string(),
rec("G01", &[7.000]),
]
.join("\n");
let obs = cn0_observations(&bad).unwrap();
assert!(obs.is_empty(), "7.0 is an SSI scale value, not dB-Hz");
}
#[test]
fn malformed_input_is_a_clean_error_not_a_panic() {
assert!(cn0_observations("not a rinex file").is_err());
}
}