use crate::receiver_trust::monitors::{EpochTrust, TrustState};
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Band {
Calibrating,
Nominal,
Degraded,
Untrusted,
Unknown,
}
impl Band {
pub const ALL: [Band; 5] = [
Band::Calibrating,
Band::Nominal,
Band::Degraded,
Band::Untrusted,
Band::Unknown,
];
pub fn label(self) -> &'static str {
match self {
Band::Calibrating => "calibrating",
Band::Nominal => "nominal",
Band::Degraded => "degraded",
Band::Untrusted => "untrusted",
Band::Unknown => "unknown",
}
}
pub fn parse(s: &str) -> Band {
match s.trim().to_ascii_lowercase().as_str() {
"calibrating" | "calibration" => Band::Calibrating,
"nominal" | "trusted" | "ok" => Band::Nominal,
"degraded" | "warning" => Band::Degraded,
"untrusted" | "critical" => Band::Untrusted,
_ => Band::Unknown,
}
}
pub fn index(self) -> usize {
Band::ALL.iter().position(|b| *b == self).unwrap_or(4)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Position {
pub lat_deg: f64,
pub lon_deg: f64,
pub height_m: f64,
}
pub const GEOMETRY_STATUSES: [&str; 3] = ["good", "degraded", "unknown"];
pub const GEOMETRY_LABELS: [&str; 4] = ["good", "fair", "poor", "unknown"];
pub const CLASSES: [&str; 3] = ["inconsistency", "environment", "geometry"];
#[derive(Clone, Debug, PartialEq)]
pub struct GeometrySample {
pub status: &'static str,
pub hdop: Option<f64>,
pub pdop: Option<f64>,
pub n_used: Option<f64>,
pub vdop: Option<f64>,
pub label: Option<&'static str>,
pub classes: Vec<&'static str>,
pub signal_loss: Option<bool>,
}
fn known(set: &'static [&'static str], v: &str) -> Option<&'static str> {
set.iter().copied().find(|k| *k == v)
}
fn class_set<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<&'static str> {
let found: Vec<&'static str> = names
.into_iter()
.filter_map(|n| known(&CLASSES, n))
.collect();
CLASSES
.iter()
.copied()
.filter(|c| found.contains(c))
.collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct TrustSample {
pub t_s: f64,
pub time_label: Option<String>,
pub score: Option<f64>,
pub band: Band,
pub reasons: Vec<String>,
pub gate: Option<String>,
pub position: Option<Position>,
pub geometry: Option<GeometrySample>,
pub reason_class: std::collections::BTreeMap<String, &'static str>,
}
pub fn parse_live_line(line: &str) -> Result<TrustSample, String> {
let v: Value = serde_json::from_str(line.trim()).map_err(|e| format!("not JSON: {e}"))?;
let obj = v.as_object().ok_or("not a JSON object")?;
let num = |keys: &[&str]| -> Result<Option<f64>, String> {
for k in keys {
if let Some(x) = obj.get(*k) {
if x.is_null() {
return Ok(None);
}
let f = x.as_f64().ok_or_else(|| format!("`{k}` is not a number"))?;
if !f.is_finite() {
return Err(format!("`{k}` is not finite"));
}
return Ok(Some(f));
}
}
Ok(None)
};
let t_s = num(&["t_s", "t"])?.ok_or("missing `t_s`")?;
let score = num(&["score"])?;
if let Some(s) = score {
if !(0.0..=100.0).contains(&s) {
return Err(format!("`score` {s} outside 0..=100"));
}
}
let band = Band::parse(
obj.get("state")
.or_else(|| obj.get("band"))
.and_then(Value::as_str)
.ok_or("missing `state`")?,
);
let mut reasons: Vec<String> = Vec::new();
let mut push = |name: &str| {
if !reasons.iter().any(|r| r == name) {
reasons.push(name.to_string());
}
};
let mut reason_class = std::collections::BTreeMap::new();
if let Some(Value::Array(ds)) = obj.get("deductions") {
for d in ds {
if let (Some(m), Some(c)) = (
d.get("monitor").and_then(Value::as_str),
d.get("class")
.and_then(Value::as_str)
.and_then(|c| known(&CLASSES, c)),
) {
reason_class.insert(m.to_string(), c);
}
}
}
for key in ["deductions", "alarms", "reasons"] {
let Some(r) = obj.get(key) else { continue };
if r.is_null() {
continue;
}
for item in r
.as_array()
.ok_or_else(|| format!("`{key}` is not an array"))?
{
let name = match item {
Value::String(s) => Some(s.as_str()),
Value::Object(o) => ["monitor", "name", "reason"]
.iter()
.find_map(|k| o.get(*k).and_then(Value::as_str)),
_ => None,
};
push(name.ok_or_else(|| format!("a `{key}` entry has no name"))?);
}
}
let position = match obj.get("position") {
None | Some(Value::Null) => None,
Some(Value::Object(o)) => {
let f = |k: &str| {
o.get(k)
.and_then(Value::as_f64)
.filter(|x| x.is_finite())
.ok_or_else(|| format!("`position.{k}` is missing or not a finite number"))
};
Some(Position {
lat_deg: f("lat_deg")?,
lon_deg: f("lon_deg")?,
height_m: f("height_m")?,
})
}
Some(_) => return Err("`position` is not an object or null".into()),
};
let geometry = match obj.get("geometry") {
None | Some(Value::Null) => None,
Some(Value::Object(o)) => {
let status = o
.get("status")
.and_then(Value::as_str)
.ok_or("`geometry.status` is missing or not a string")?;
let f = |k: &str| -> Result<Option<f64>, String> {
match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(x) => x
.as_f64()
.filter(|v| v.is_finite() && *v >= 0.0)
.map(Some)
.ok_or_else(|| format!("`geometry.{k}` is not a finite number >= 0")),
}
};
Some(GeometrySample {
status: known(&GEOMETRY_STATUSES, status).unwrap_or("unknown"),
hdop: f("hdop")?,
pdop: f("pdop")?,
n_used: f("n_used")?,
vdop: f("vdop")?,
label: match o.get("label") {
None | Some(Value::Null) => None,
Some(Value::String(l)) => Some(known(&GEOMETRY_LABELS, l).unwrap_or("unknown")),
Some(_) => return Err("`geometry.label` is not a string".into()),
},
classes: class_set(reason_class.values().copied()),
signal_loss: match o.get("signal_loss") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err("`geometry.signal_loss` is not a boolean".into()),
},
})
}
Some(_) => return Err("`geometry` is not an object or null".into()),
};
let gate = obj.get("gate").and_then(Value::as_str).map(str::to_string);
let time_label = ["time", "time_label"]
.iter()
.find_map(|k| obj.get(*k).and_then(Value::as_str))
.map(str::to_string);
Ok(TrustSample {
t_s,
time_label,
score,
band,
reasons,
gate,
position,
geometry,
reason_class,
})
}
fn label_of<T: serde::Serialize>(v: &T) -> String {
serde_json::to_value(v)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default()
}
fn monitor_name(m: &crate::receiver_trust::monitors::Monitor) -> String {
serde_json::to_value(m)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{m:?}"))
}
pub fn from_epoch_trust(e: &EpochTrust) -> TrustSample {
let band = match e.state {
TrustState::Calibrating => Band::Calibrating,
TrustState::Nominal => Band::Nominal,
TrustState::Degraded => Band::Degraded,
TrustState::Untrusted => Band::Untrusted,
};
let mut reasons: Vec<String> = Vec::new();
let named = e
.score
.iter()
.flat_map(|s| s.deductions.iter().map(|d| &d.monitor))
.chain(e.alarms.iter());
for m in named {
let n = monitor_name(m);
if !reasons.contains(&n) {
reasons.push(n);
}
}
let mut reason_class = std::collections::BTreeMap::new();
for d in e.score.iter().flat_map(|s| s.deductions.iter()) {
if let Some(c) = d.class.and_then(|c| known(&CLASSES, &label_of(&c))) {
reason_class.insert(monitor_name(&d.monitor), c);
}
}
let geometry = GeometrySample {
status: known(&GEOMETRY_STATUSES, &label_of(&e.geometry.status)).unwrap_or("unknown"),
hdop: e.geometry.hdop,
pdop: e.geometry.pdop,
n_used: e.geometry.n_used.map(|n| n as f64),
vdop: e.geometry.vdop,
label: Some(e.geometry.label.as_str()),
classes: class_set(reason_class.values().copied()),
signal_loss: (e.geometry.status
!= crate::receiver_trust::geometry::GeometryStatus::Unknown)
.then_some(e.geometry.signal_loss),
};
TrustSample {
t_s: e.t_s,
time_label: None,
score: e.score.as_ref().map(|s| s.score),
band,
reasons,
gate: None,
position: None,
geometry: Some(geometry),
reason_class,
}
}
pub fn from_result_json(json: &str) -> Result<Vec<TrustSample>, String> {
let v: Value = serde_json::from_str(json).map_err(|e| format!("result is not JSON: {e}"))?;
let epochs = v.get("epochs").ok_or("result has no `epochs`")?;
let has_geometry: Vec<bool> = epochs
.as_array()
.map(|a| a.iter().map(|e| e.get("geometry").is_some()).collect())
.unwrap_or_default();
let epochs: Vec<EpochTrust> =
serde_json::from_value(epochs.clone()).map_err(|e| format!("bad `epochs`: {e}"))?;
Ok(epochs
.iter()
.enumerate()
.map(|(i, e)| {
let mut s = from_epoch_trust(e);
if !has_geometry.get(i).copied().unwrap_or(false) {
s.geometry = None;
s.reason_class.clear();
}
s
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_full_line() {
let s = parse_live_line(
r#"{"seq":13,"t_s":12.5,"time":"2026-01-01T00:00:12Z","state":"degraded","score":62.5,"deductions":[{"monitor":"kinematic","ratio":1.5,"points":20.0}],"alarms":["kinematic","heading-course"],"gate":"withheld","note":null,"position":{"lat_deg":59.5,"lon_deg":24.25,"height_m":39.4},"future_key":1}"#,
)
.unwrap();
assert_eq!(s.t_s, 12.5);
assert_eq!(s.score, Some(62.5));
assert_eq!(s.band, Band::Degraded);
assert_eq!(s.reasons, ["kinematic", "heading-course"]);
assert_eq!(s.gate.as_deref(), Some("withheld"));
assert_eq!(
s.position,
Some(Position {
lat_deg: 59.5,
lon_deg: 24.25,
height_m: 39.4
})
);
assert_eq!(s.time_label.as_deref(), Some("2026-01-01T00:00:12Z"));
}
#[test]
fn rejects_format_drift_instead_of_hiding_it() {
assert!(parse_live_line("garbage").is_err());
assert!(parse_live_line(r#"{"t_s":1,"score":140,"state":"nominal"}"#).is_err());
assert!(parse_live_line(r#"{"t_s":1,"score":50}"#).is_err());
assert!(parse_live_line(r#"{"score":50,"state":"nominal"}"#).is_err());
assert!(parse_live_line(r#"{"t_s":1,"state":"nominal","reasons":[3]}"#).is_err());
assert!(
parse_live_line(r#"{"t_s":1,"state":"nominal","position":{"lat_deg":1}}"#).is_err()
);
}
#[test]
fn schema_1_2_line_with_advisory_is_read() {
let s = parse_live_line(
r#"{"seq":941,"t_s":940.0,"time":"2025-06-14T08:15:40.000Z","state":"untrusted","score":23.4,"deductions":[{"monitor":"heading-course","ratio":2.0,"points":40.0}],"alarms":["heading-course"],"gate":"off","note":null,"position":{"lat_deg":59.8123456,"lon_deg":24.9012345,"height_m":39.4},"advisory":"Advisory software, not type-approved navigation equipment."}"#,
)
.unwrap();
assert_eq!((s.band, s.score), (Band::Untrusted, Some(23.4)));
assert_eq!(s.reasons, ["heading-course"]);
assert!(s.position.is_some());
}
#[test]
fn schema_1_3_line_with_geometry_and_classes_is_read_and_bounded() {
let s = parse_live_line(
r#"{"seq":2,"t_s":61.0,"state":"degraded","score":75.0,"deductions":[{"monitor":"loss-of-lock","ratio":1.0,"points":12.5,"class":"environment"},{"monitor":"geometry","ratio":1.2,"points":10.5,"class":"geometry"},{"monitor":"odd","ratio":1.0,"points":1.0,"class":"made-up"}],"alarms":[],"gate":"off","note":null,"position":null,"advisory":"x","geometry":{"status":"degraded","reasons":["few-satellites"],"source":"derived","n_used":4,"hdop":5.0,"pdop":7.25,"vdop":5.1}}"#,
)
.unwrap();
let g = s.geometry.unwrap();
assert_eq!(
(g.status, g.hdop, g.pdop, g.n_used),
("degraded", Some(5.0), Some(7.25), Some(4.0))
);
assert_eq!(
g.classes,
["environment", "geometry"],
"unknown class dropped"
);
assert_eq!(s.reason_class.get("geometry"), Some(&"geometry"));
assert_eq!(s.reason_class.get("odd"), None);
let s = parse_live_line(r#"{"t_s":1,"state":"nominal"}"#).unwrap();
assert_eq!((s.geometry, s.reason_class.len()), (None, 0));
assert!(parse_live_line(r#"{"t_s":1,"state":"nominal","geometry":3}"#).is_err());
assert!(parse_live_line(r#"{"t_s":1,"state":"nominal","geometry":{"hdop":1}}"#).is_err());
let s = parse_live_line(
r#"{"t_s":1,"state":"degraded","geometry":{"status":"degraded","signal_loss":true}}"#,
)
.unwrap();
assert_eq!(s.geometry.unwrap().signal_loss, Some(true));
let s = parse_live_line(
r#"{"t_s":1,"state":"nominal","geometry":{"status":"good","label":"fair","vdop":1.4}}"#,
)
.unwrap();
let g = s.geometry.unwrap();
assert_eq!((g.label, g.vdop), (Some("fair"), Some(1.4)));
let s = parse_live_line(
r#"{"t_s":1,"state":"nominal","geometry":{"status":"good","label":"odd"}}"#,
)
.unwrap();
assert_eq!(s.geometry.unwrap().label, Some("unknown"));
assert!(parse_live_line(
r#"{"t_s":1,"state":"nominal","geometry":{"status":"good","label":3}}"#
)
.is_err());
assert!(parse_live_line(
r#"{"t_s":1,"state":"nominal","geometry":{"status":"good","signal_loss":1}}"#
)
.is_err());
let s = parse_live_line(r#"{"t_s":1,"state":"nominal","geometry":{"status":"weird"}}"#)
.unwrap();
assert_eq!(s.geometry.unwrap().status, "unknown");
}
#[test]
fn calibrating_line_has_null_score_and_empty_arrays() {
let s = parse_live_line(
r#"{"seq":1,"t_s":0.0,"time":null,"state":"calibrating","score":null,"deductions":[],"alarms":[],"gate":"off","note":null}"#,
)
.unwrap();
assert_eq!(
(s.band, s.score, s.reasons.len()),
(Band::Calibrating, None, 0)
);
assert_eq!(s.time_label, None);
}
#[test]
fn unknown_band_is_labelled_not_guessed() {
let s = parse_live_line(r#"{"t_s":1,"state":"purple"}"#).unwrap();
assert_eq!(s.band, Band::Unknown);
assert_eq!(s.score, None);
}
#[test]
fn batch_epoch_without_a_score_gets_none() {
let e = EpochTrust {
t_s: 70.0,
n_sats: 8,
cn0_mean_dbhz: None,
cn0_drop_db: None,
agc: None,
agc_z: None,
jam_ind: None,
position_offset_m: None,
raim_stat: None,
raim_thr: None,
clock_innov_ns: None,
clock_bound_ns: None,
alarms: vec![crate::receiver_trust::monitors::Monitor::Cn0Drop],
state: TrustState::Degraded,
marine: None,
score: None,
geometry: crate::receiver_trust::geometry::EpochGeometry::unknown(),
};
let s = from_epoch_trust(&e);
assert_eq!(s.score, None);
assert_eq!(s.band, Band::Degraded);
assert_eq!(s.reasons, ["cn0-drop"]);
}
}