use crate::chart::esc;
use crate::palette::chart::{AMBER, BG, BLUE, CORAL, FONT_SANS, INK, LIME, MUTED, RULE, TITLE};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::ingest::read_log;
use super::monitors::{
run_monitors, AlarmRun, Baseline, EngineFixInput, EpochTrust, Monitor, MonitorConfig,
TrustState,
};
use super::LogFormat;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FileSource {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub base64: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LogCfg {
pub format: LogFormat,
#[serde(flatten)]
pub source: FileSource,
#[serde(default)]
pub nav: Option<FileSource>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
Jamming,
Spoofing,
Other,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EventCfg {
pub label: String,
pub kind: EventKind,
pub onset_s: f64,
#[serde(default)]
pub end_s: Option<f64>,
#[serde(default)]
pub predicted_cn0_drop_db: Option<f64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, default)]
pub struct CompareCfg {
pub detect_tol_s: f64,
pub horizon_s: f64,
pub cn0_tol_db: f64,
}
impl Default for CompareCfg {
fn default() -> Self {
Self {
detect_tol_s: 10.0,
horizon_s: 300.0,
cn0_tol_db: 3.0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ReceiverTrustScenario {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub name: Option<String>,
pub log: LogCfg,
#[serde(default)]
pub monitors: MonitorConfig,
#[serde(default)]
pub events: Vec<EventCfg>,
#[serde(default)]
pub compare: CompareCfg,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct LogSummary {
pub format: LogFormat,
pub sha256: String,
pub bytes: usize,
pub epochs: usize,
pub duration_s: f64,
pub start_label: Option<String>,
pub observables: Vec<String>,
pub skipped_records: usize,
pub engine_fix: bool,
}
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct StateTimes {
pub nominal_epochs: usize,
pub degraded_epochs: usize,
pub untrusted_epochs: usize,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct EventScore {
pub label: String,
pub kind: EventKind,
pub onset_s: f64,
pub window_end_s: f64,
pub evaluable: bool,
pub pre_onset_epochs: usize,
pub pre_onset_alarms: usize,
pub first_alarm_s: Option<f64>,
pub latency_s: Option<f64>,
pub first_monitors: Vec<Monitor>,
pub outcome: String,
pub observed_cn0_drop_db: Option<f64>,
pub predicted_cn0_drop_db: Option<f64>,
pub cn0_drop_error_db: Option<f64>,
pub cn0_verdict: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ReceiverTrustResult {
pub scenario_hash: String,
pub label: String,
pub name: Option<String>,
pub log: LogSummary,
pub monitor_config: MonitorConfig,
pub compare: CompareCfg,
pub monitors_run: Vec<Monitor>,
pub baseline: Baseline,
pub states: StateTimes,
pub first_alarm_s: Option<f64>,
pub alarm_runs: Vec<AlarmRun>,
pub events: Vec<EventScore>,
pub events_detected: usize,
pub events_evaluable: usize,
pub predictions_agreeing: usize,
pub predictions_evaluable: usize,
pub verdict: String,
pub epochs: Vec<EpochTrust>,
}
pub const LABEL: &str =
"MEASURED log, MODELLED assessment: the observables are the receiver's own; \
the monitors and their thresholds are the engine's and the scenario's, and a stated prediction \
is compared with the log, not fitted to it";
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>, String> {
std::fs::read(path).map_err(|e| format!("cannot read {path:?}: {e}"))
}
#[cfg(target_arch = "wasm32")]
fn read_path(path: &str) -> Result<Vec<u8>, String> {
Err(format!(
"cannot read {path:?}: the WebAssembly build has no file system; pass the file inline as `text` or `base64`"
))
}
pub fn load_source(src: &FileSource, what: &str) -> Result<Vec<u8>, String> {
match (&src.path, &src.text, &src.base64) {
(Some(p), None, None) => read_path(p),
(None, Some(t), None) => Ok(t.clone().into_bytes()),
(None, None, Some(b)) => {
let compact: String = b.chars().filter(|c| !c.is_whitespace()).collect();
crate::permalink::base64_decode(&compact)
.ok_or_else(|| format!("{what}: `base64` is not valid standard base64"))
}
(None, None, None) => Err(format!("{what}: give one of `path`, `text` or `base64`")),
_ => Err(format!(
"{what}: give only one of `path`, `text` or `base64`"
)),
}
}
fn sha256_hex(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn median(mut v: Vec<f64>) -> Option<f64> {
v.retain(|x| x.is_finite());
if v.is_empty() {
return None;
}
v.sort_by(|a, b| a.total_cmp(b));
let n = v.len();
Some(if n % 2 == 1 {
v[n / 2]
} else {
0.5 * (v[n / 2 - 1] + v[n / 2])
})
}
fn validate(scn: &ReceiverTrustScenario) -> Result<(), String> {
let c = &scn.compare;
for (name, v) in [
("compare.detect_tol_s", c.detect_tol_s),
("compare.horizon_s", c.horizon_s),
("compare.cn0_tol_db", c.cn0_tol_db),
] {
if !(v.is_finite() && v > 0.0) {
return Err(format!("{name} must be a positive number, got {v}"));
}
}
for e in &scn.events {
if !e.onset_s.is_finite() || e.onset_s < 0.0 {
return Err(format!(
"event {:?}: onset_s must be a non-negative number",
e.label
));
}
if let Some(end) = e.end_s {
if !end.is_finite() || end <= e.onset_s {
return Err(format!("event {:?}: end_s must be after onset_s", e.label));
}
}
}
Ok(())
}
pub fn run_receiver_trust(scn: &ReceiverTrustScenario) -> Result<ReceiverTrustResult, String> {
validate(scn)?;
let log_bytes = load_source(&scn.log.source, "log")?;
let timeline = read_log(scn.log.format, &log_bytes)?;
let nav_bytes = match &scn.log.nav {
Some(src) => Some(load_source(src, "log.nav")?),
None => None,
};
if nav_bytes.is_some() && scn.log.format != LogFormat::Rinex {
return Err(
"log.nav applies only to a `rinex` log: the engine fix needs its pseudoranges".into(),
);
}
let engine_inputs = match &nav_bytes {
Some(nav) => {
let obs = crate::rinex_obs::parse_obs(&String::from_utf8_lossy(&log_bytes))?;
let ephs = crate::rinex::parse_nav(&String::from_utf8_lossy(nav))?;
Some((obs, ephs))
}
None => None,
};
let engine = engine_inputs
.as_ref()
.map(|(obs, ephs)| EngineFixInput { obs, ephs });
let trust = run_monitors(&timeline, engine, &scn.monitors)?;
let mut hasher = Sha256::new();
hasher.update(serde_json::to_string(scn).unwrap_or_default().as_bytes());
hasher.update(log_bytes.as_slice());
if let Some(nav) = &nav_bytes {
hasher.update(nav.as_slice());
}
let scenario_hash = format!("{:x}", hasher.finalize());
let cal_end = scn.monitors.calibration_s;
let last_t = trust.epochs.last().map(|e| e.t_s).unwrap_or(0.0);
let mut states = StateTimes::default();
for e in &trust.epochs {
match e.state {
TrustState::Nominal => states.nominal_epochs += 1,
TrustState::Degraded => states.degraded_epochs += 1,
TrustState::Untrusted => states.untrusted_epochs += 1,
TrustState::Calibrating => {}
}
}
let mut order: Vec<usize> = (0..scn.events.len()).collect();
order.sort_by(|&a, &b| scn.events[a].onset_s.total_cmp(&scn.events[b].onset_s));
let mut scores: Vec<Option<EventScore>> = vec![None; scn.events.len()];
let mut prev_end = cal_end;
for &i in &order {
let ev = &scn.events[i];
let window_end = ev
.end_s
.unwrap_or(ev.onset_s + scn.compare.horizon_s)
.min(last_t);
let evaluable = ev.onset_s >= cal_end && ev.onset_s <= last_t;
let pre_from = prev_end.max(cal_end);
let pre: Vec<&EpochTrust> = trust
.epochs
.iter()
.filter(|e| e.t_s >= pre_from && e.t_s < ev.onset_s)
.collect();
let pre_onset_alarms = pre.iter().filter(|e| !e.alarms.is_empty()).count();
let in_window: Vec<&EpochTrust> = trust
.epochs
.iter()
.filter(|e| e.t_s >= ev.onset_s && e.t_s <= window_end)
.collect();
let first = in_window.iter().find(|e| !e.alarms.is_empty());
let first_alarm_s = first.map(|e| e.t_s);
let latency_s = first_alarm_s.map(|t| t - ev.onset_s);
let outcome = if !evaluable {
"not-evaluable"
} else {
match latency_s {
Some(l) if l <= scn.compare.detect_tol_s => "detected",
Some(_) => "late",
None => "missed",
}
};
let observed = if evaluable {
median(in_window.iter().filter_map(|e| e.cn0_drop_db).collect())
} else {
None
};
let error = match (observed, ev.predicted_cn0_drop_db) {
(Some(o), Some(p)) => Some(o - p),
_ => None,
};
let cn0_verdict = match (ev.predicted_cn0_drop_db, error) {
(None, _) => "no-prediction",
(Some(_), Some(e)) if e.abs() <= scn.compare.cn0_tol_db => "agree",
(Some(_), Some(_)) => "disagree",
(Some(_), None) => "not-evaluable",
};
scores[i] = Some(EventScore {
label: ev.label.clone(),
kind: ev.kind,
onset_s: ev.onset_s,
window_end_s: window_end,
evaluable,
pre_onset_epochs: pre.len(),
pre_onset_alarms,
first_alarm_s,
latency_s,
first_monitors: first.map(|e| e.alarms.clone()).unwrap_or_default(),
outcome: outcome.into(),
observed_cn0_drop_db: observed,
predicted_cn0_drop_db: ev.predicted_cn0_drop_db,
cn0_drop_error_db: error,
cn0_verdict: cn0_verdict.into(),
});
prev_end = prev_end.max(window_end);
}
let events: Vec<EventScore> = scores.into_iter().flatten().collect();
let events_evaluable = events.iter().filter(|e| e.evaluable).count();
let events_detected = events.iter().filter(|e| e.outcome == "detected").count();
let predictions_evaluable = events
.iter()
.filter(|e| e.cn0_verdict == "agree" || e.cn0_verdict == "disagree")
.count();
let predictions_agreeing = events.iter().filter(|e| e.cn0_verdict == "agree").count();
let after_cal = states.nominal_epochs + states.degraded_epochs + states.untrusted_epochs;
let mut verdict = match trust.first_alarm_s {
None => format!("trusted throughout: no monitor alarmed in {after_cal} epochs after calibration"),
Some(t) => format!(
"first alarm at {t:.1} s; untrusted for {} and degraded for {} of {after_cal} epochs after calibration",
states.untrusted_epochs, states.degraded_epochs
),
};
if events_evaluable > 0 {
verdict.push_str(&format!(
"; {events_detected} of {events_evaluable} stated events detected within {} s",
scn.compare.detect_tol_s
));
}
if predictions_evaluable > 0 {
verdict.push_str(&format!(
"; {predictions_agreeing} of {predictions_evaluable} C/N0 predictions within {} dB",
scn.compare.cn0_tol_db
));
}
Ok(ReceiverTrustResult {
scenario_hash,
label: LABEL.into(),
name: scn.name.clone(),
log: LogSummary {
format: scn.log.format,
sha256: sha256_hex(&log_bytes),
bytes: log_bytes.len(),
epochs: timeline.epochs.len(),
duration_s: timeline.epochs.last().map(|e| e.t_s).unwrap_or(0.0),
start_label: timeline.start_label.clone(),
observables: timeline.observables.clone(),
skipped_records: timeline.skipped_records,
engine_fix: engine_inputs.is_some(),
},
monitor_config: scn.monitors.clone(),
compare: scn.compare.clone(),
monitors_run: trust.monitors_run,
baseline: trust.baseline,
states,
first_alarm_s: trust.first_alarm_s,
alarm_runs: trust.runs,
events,
events_detected,
events_evaluable,
predictions_agreeing,
predictions_evaluable,
verdict,
epochs: trust.epochs,
})
}
pub fn summary(r: &ReceiverTrustResult) -> String {
format!(
"scenario {} | receiver-trust | {} log, {} epochs over {:.0} s | monitors {} | {}",
&r.scenario_hash[..12],
format_name(r.log.format),
r.log.epochs,
r.log.duration_s,
r.monitors_run.len(),
r.verdict
)
}
fn format_name(f: LogFormat) -> &'static str {
match f {
LogFormat::Ubx => "ubx",
LogFormat::Rinex => "rinex",
LogFormat::Android => "android",
LogFormat::Nmea => "nmea",
}
}
fn monitor_name(m: Monitor) -> &'static str {
match m {
Monitor::Cn0Drop => "cn0-drop",
Monitor::Agc => "agc",
Monitor::JamInd => "jam-ind",
Monitor::LossOfLock => "loss-of-lock",
Monitor::PositionJump => "position-jump",
Monitor::Raim => "raim",
Monitor::Clock => "clock",
Monitor::SolveFailure => "solve-failure",
}
}
fn state_name(s: TrustState) -> &'static str {
match s {
TrustState::Calibrating => "calibrating",
TrustState::Nominal => "nominal",
TrustState::Degraded => "degraded",
TrustState::Untrusted => "untrusted",
}
}
fn opt(v: Option<f64>) -> String {
v.map(|x| format!("{x}")).unwrap_or_default()
}
pub fn to_csv(r: &ReceiverTrustResult) -> String {
let mut s = String::from(
"t_s,state,n_sats,cn0_mean_dbhz,cn0_drop_db,agc,agc_z,jam_ind,position_offset_m,raim_stat,raim_thr,clock_innov_ns,clock_bound_ns,alarms\n",
);
for e in &r.epochs {
let alarms: Vec<&str> = e.alarms.iter().map(|m| monitor_name(*m)).collect();
s.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
e.t_s,
state_name(e.state),
e.n_sats,
opt(e.cn0_mean_dbhz),
opt(e.cn0_drop_db),
opt(e.agc),
opt(e.agc_z),
opt(e.jam_ind),
opt(e.position_offset_m),
opt(e.raim_stat),
opt(e.raim_thr),
opt(e.clock_innov_ns),
opt(e.clock_bound_ns),
alarms.join(";")
));
}
s
}
pub fn to_svg(r: &ReceiverTrustResult) -> String {
let (w, h) = (760.0_f64, 300.0_f64);
let (x0, x1, y0, y1) = (56.0, w - 16.0, 40.0, h - 70.0);
let t_max = r.epochs.last().map(|e| e.t_s).unwrap_or(1.0).max(1.0);
let cn0: Vec<(f64, f64)> = r
.epochs
.iter()
.filter_map(|e| e.cn0_mean_dbhz.map(|c| (e.t_s, c)))
.collect();
let (lo, hi) = cn0
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, c)| {
(lo.min(*c), hi.max(*c))
});
let (lo, hi) = if lo.is_finite() {
((lo - 2.0).floor(), (hi + 2.0).ceil())
} else {
(20.0, 50.0)
};
let sx = |t: f64| x0 + (x1 - x0) * t / t_max;
let sy = |c: f64| y1 - (y1 - y0) * (c - lo) / (hi - lo).max(1.0);
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\
<rect width=\"{w}\" height=\"{h}\" fill=\"{BG}\"/>\
<text x=\"12\" y=\"22\" fill=\"{TITLE}\" font-family=\"{FONT_SANS}\" font-size=\"13\">{}</text>\
<text x=\"12\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"11\" transform=\"rotate(-90 12 {})\">mean C/N0, dB-Hz</text>",
esc(&r.verdict.chars().take(110).collect::<String>()),
(y0 + y1) / 2.0 + 40.0,
(y0 + y1) / 2.0 + 40.0
);
for c in [lo, (lo + hi) / 2.0, hi] {
svg.push_str(&format!(
"<line x1=\"{x0}\" x2=\"{x1}\" y1=\"{y}\" y2=\"{y}\" stroke=\"{RULE}\"/>\
<text x=\"{tx}\" y=\"{ty}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\" text-anchor=\"end\">{c:.0}</text>",
y = sy(c),
tx = x0 - 4.0,
ty = sy(c) + 3.0
));
}
if !cn0.is_empty() {
let pts: Vec<String> = cn0
.iter()
.map(|(t, c)| format!("{:.1},{:.1}", sx(*t), sy(*c)))
.collect();
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"{BLUE}\" stroke-width=\"1.5\" points=\"{}\"/>",
pts.join(" ")
));
}
let band_y = y1 + 14.0;
for (i, e) in r.epochs.iter().enumerate() {
let next = r.epochs.get(i + 1).map(|n| n.t_s).unwrap_or(e.t_s + 1.0);
let colour = match e.state {
TrustState::Calibrating => RULE,
TrustState::Nominal => LIME,
TrustState::Degraded => AMBER,
TrustState::Untrusted => CORAL,
};
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{band_y}\" width=\"{:.2}\" height=\"12\" fill=\"{colour}\"/>",
sx(e.t_s),
(sx(next) - sx(e.t_s)).max(0.5)
));
}
for ev in &r.events {
let x = sx(ev.onset_s);
svg.push_str(&format!(
"<line x1=\"{x:.1}\" x2=\"{x:.1}\" y1=\"{y0}\" y2=\"{}\" stroke=\"{INK}\" stroke-dasharray=\"3 3\"/>\
<text x=\"{:.1}\" y=\"{}\" fill=\"{TITLE}\" font-family=\"{FONT_SANS}\" font-size=\"10\">{}</text>",
band_y + 12.0,
x + 3.0,
y0 + 10.0,
esc(&ev.label)
));
}
svg.push_str(&format!(
"<text x=\"{x0}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\">0 s</text>\
<text x=\"{x1}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\" text-anchor=\"end\">{t_max:.0} s</text>\
<text x=\"{x0}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\">trust: green nominal, amber degraded, red untrusted, grey calibrating</text></svg>",
band_y + 26.0,
band_y + 26.0,
band_y + 40.0
));
svg
}
#[derive(Clone, Debug)]
pub struct TrustOutput {
pub json: String,
pub csv: String,
pub svg: String,
pub summary: String,
}
pub fn run_toml(src: &str) -> Result<TrustOutput, String> {
let scn: ReceiverTrustScenario =
toml::from_str(src).map_err(|e| format!("invalid receiver-trust scenario: {e}"))?;
if let Some(k) = scn.kind.as_deref() {
if k != "receiver-trust" {
return Err(format!(
"this is a {k:?} scenario, not a receiver-trust one"
));
}
}
run_scenario(&scn)
}
pub fn run_scenario(scn: &ReceiverTrustScenario) -> Result<TrustOutput, String> {
let r = run_receiver_trust(scn)?;
Ok(TrustOutput {
json: serde_json::to_string_pretty(&r).map_err(|e| e.to_string())?,
csv: to_csv(&r),
svg: to_svg(&r),
summary: summary(&r),
})
}
pub fn resolve_paths(scn: &mut ReceiverTrustScenario, base: &std::path::Path) {
let fix = |p: &mut Option<String>| {
if let Some(s) = p.as_mut() {
let path = std::path::Path::new(s.as_str());
if path.is_relative() {
*s = base.join(path).to_string_lossy().into_owned();
}
}
};
fix(&mut scn.log.source.path);
if let Some(nav) = scn.log.nav.as_mut() {
fix(&mut nav.path);
}
}