use crate::chart::esc;
use crate::palette::chart::{AMBER, BG, BLUE, CORAL, FONT_SANS, INK, LIME, MUTED, RULE, TITLE};
use crate::portable_math::PortableFloat;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::geometry::{GeometryCfg, GeometryModel, GeometrySummary};
use super::ingest::read_log;
use super::live::LiveCfg;
use super::maritime::MaritimeConfig;
use super::monitors::{
run_monitors, AlarmRun, Baseline, EngineFixInput, EpochTrust, Monitor, MonitorConfig,
TrustState,
};
use super::platform::PlatformCfg;
use super::score::ScoreCfg;
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(from = "RawScenario")]
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(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawScenario {
#[serde(default)]
kind: Option<String>,
#[serde(default)]
name: Option<String>,
log: LogCfg,
#[serde(default)]
monitors: MonitorConfig,
#[serde(default)]
events: Vec<EventCfg>,
#[serde(default)]
compare: CompareCfg,
#[serde(default)]
platform: PlatformCfg,
#[serde(default)]
maritime: MaritimeConfig,
#[serde(default)]
score: ScoreCfg,
#[serde(default)]
live: LiveCfg,
#[serde(default)]
geometry: GeometryCfg,
#[serde(default)]
skyline: Option<crate::skyline::SkylineCfg>,
}
impl From<RawScenario> for ReceiverTrustScenario {
fn from(r: RawScenario) -> Self {
let mut monitors = r.monitors;
monitors.platform = r.platform;
monitors.maritime = r.maritime;
monitors.score = r.score;
monitors.live = r.live;
monitors.geometry = r.geometry;
monitors.skyline = r.skyline;
Self {
kind: r.kind,
name: r.name,
log: r.log,
monitors,
events: r.events,
compare: r.compare,
}
}
}
#[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,
#[serde(skip_serializing_if = "Option::is_none")]
pub advisory: Option<&'static str>,
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,
#[serde(skip_serializing_if = "Option::is_none")]
pub score_model: Option<ScoreModel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_note: Option<&'static str>,
pub verdict: String,
pub epochs: Vec<EpochTrust>,
pub geometry_model: GeometryModel,
pub geometry_summary: GeometrySummary,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct ScoreModel {
pub nominal_min: f64,
pub degraded_min: f64,
pub onset_ratio: f64,
pub full_ratio: f64,
pub evidence_hold_s: f64,
pub weights: std::collections::BTreeMap<Monitor, f64>,
}
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> {
scn.monitors.platform.validate()?;
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 nav_bytes = match &scn.log.nav {
Some(src) => Some(load_source(src, "log.nav")?),
None => None,
};
run_receiver_trust_bytes(scn, &log_bytes, nav_bytes.as_deref())
}
pub fn run_receiver_trust_bytes(
scn: &ReceiverTrustScenario,
log_bytes: &[u8],
nav_bytes: Option<&[u8]>,
) -> Result<ReceiverTrustResult, String> {
validate(scn)?;
let timeline = read_log(scn.log.format, log_bytes)?;
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);
if let Some(nav) = nav_bytes {
hasher.update(nav);
}
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
));
}
let score_model = scn.monitors.platform.is_vessel().then(|| ScoreModel {
nominal_min: scn.monitors.score.nominal_min,
degraded_min: scn.monitors.score.degraded_min,
onset_ratio: scn.monitors.score.onset_ratio,
full_ratio: scn.monitors.score.full_ratio,
evidence_hold_s: scn.monitors.score.evidence_hold_s,
weights: scn.monitors.score.effective_weights(),
});
if score_model.is_some() {
let scores: Vec<f64> = trust
.epochs
.iter()
.filter_map(|e| e.score.as_ref().map(|s| s.score))
.collect();
if let (Some(min), Some(last)) = (
scores.iter().copied().min_by(f64::total_cmp),
scores.last().copied(),
) {
verdict.push_str(&format!("; trust score lowest {min:.1}, final {last:.1}"));
}
}
Ok(ReceiverTrustResult {
scenario_hash,
label: LABEL.into(),
advisory: scn.monitors.platform.is_vessel().then_some(super::ADVISORY),
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: {
let mut o = timeline.observables.clone();
if scn.monitors.platform.is_vessel()
&& timeline.epochs.iter().any(|e| e.marine.is_some())
{
o.push("marine".to_string());
}
o
},
skipped_records: timeline.skipped_records,
engine_fix: engine_inputs.is_some(),
},
monitor_config: scn.monitors.with_profile()?.into_owned(),
profile_note: scn
.monitors
.profile
.as_ref()
.map(|_| crate::receiver_trust::monitors::PROFILE_NOTE),
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,
score_model,
verdict,
geometry_model: GeometryModel::new(
&scn.monitors.geometry,
scn.monitors.score.evidence_hold_s,
scn.monitors.score.degraded_min,
)
.with_skyline(
scn.monitors.skyline.as_ref(),
scn.monitors.stated_skyline()?.as_ref(),
),
geometry_summary: {
let mut g = GeometrySummary::default();
for e in &trust.epochs {
g.add(
e.t_s,
&e.geometry,
e.score
.iter()
.flat_map(|s| s.deductions.iter().filter_map(|d| d.class)),
);
}
g
},
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",
Monitor::Kinematic => "kinematic",
Monitor::HeadingCourse => "heading-course",
Monitor::SpeedLog => "speed-log",
Monitor::SeaLevel => "sea-level",
Monitor::Cn0Spread => "cn0-spread",
Monitor::Cn0Rise => "cn0-rise",
Monitor::TimeConsistency => "time-consistency",
Monitor::Osnma => "osnma",
Monitor::Geometry => "geometry",
}
}
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 vessel = r.score_model.is_some();
let mut s = String::new();
if vessel {
s.push_str(&format!("# {}\n", super::ADVISORY));
}
s.push_str(
"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",
);
if vessel {
s.push_str(",score,score_reasons");
}
s.push('\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(";")
));
if vessel {
s.pop(); let (score, reasons) = match &e.score {
Some(sc) => (
format!("{:.1}", sc.score),
sc.deductions
.iter()
.map(|d| format!("{}:{:.1}", monitor_name(d.monitor), d.points))
.collect::<Vec<_>>()
.join(";"),
),
None => (String::new(), String::new()),
};
s.push_str(&format!(",{score},{reasons}\n"));
}
}
s
}
fn to_svg_vessel(r: &ReceiverTrustResult) -> String {
use super::maritime::en_offset_m;
let (w, h) = (760.0_f64, 540.0_f64);
let (x0, x1) = (56.0_f64, w - 16.0);
let colour = |s: TrustState| match s {
TrustState::Calibrating => RULE,
TrustState::Nominal => LIME,
TrustState::Degraded => AMBER,
TrustState::Untrusted => CORAL,
};
let model = r.score_model.as_ref();
let (nominal_min, degraded_min) =
model.map_or((90.0, 55.0), |m| (m.nominal_min, m.degraded_min));
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">\
<desc>{}</desc>\
<rect width=\"{w}\" height=\"{h}\" fill=\"{BG}\"/>\
<text x=\"12\" y=\"22\" fill=\"{TITLE}\" font-family=\"{FONT_SANS}\" font-size=\"13\">{}</text>",
esc(super::ADVISORY),
esc(&r.verdict.chars().take(110).collect::<String>())
);
let pts: Vec<(f64, f64, TrustState)> = {
let origin = r
.epochs
.iter()
.find_map(|e| e.marine.as_ref().and_then(|m| m.position));
match origin {
Some([lat0, lon0]) => r
.epochs
.iter()
.filter_map(|e| {
let [la, lo] = e.marine.as_ref()?.position?;
let (east, north) = en_offset_m(lat0, lon0, la, lo);
Some((east, north, e.state))
})
.collect(),
None => Vec::new(),
}
};
let (ty0, ty1) = (46.0_f64, 292.0_f64);
svg.push_str(&format!(
"<text x=\"12\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"11\">receiver-reported track, north up</text>",
ty0 - 6.0
));
if pts.len() >= 2 {
let (mut e_lo, mut e_hi, mut n_lo, mut n_hi) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
for (e, n, _) in &pts {
e_lo = e_lo.min(*e);
e_hi = e_hi.max(*e);
n_lo = n_lo.min(*n);
n_hi = n_hi.max(*n);
}
let span_e = (e_hi - e_lo).max(500.0);
let span_n = (n_hi - n_lo).max(500.0);
let scale = ((x1 - x0) / span_e).min((ty1 - ty0) / span_n);
let (cx, cy) = ((x0 + x1) / 2.0, (ty0 + ty1) / 2.0);
let px = |e: f64| cx + (e - (e_lo + e_hi) / 2.0) * scale;
let py = |n: f64| cy - (n - (n_lo + n_hi) / 2.0) * scale;
let mut i = 0;
while i < pts.len() {
let band = pts[i].2;
let mut j = i;
while j + 1 < pts.len() && pts[j + 1].2 == band {
j += 1;
}
let end = (j + 1).min(pts.len() - 1);
let path: Vec<String> = pts[i..=end]
.iter()
.map(|(e, n, _)| format!("{:.1},{:.1}", px(*e), py(*n)))
.collect();
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"{}\" stroke-width=\"2\" stroke-linejoin=\"round\" points=\"{}\"/>",
colour(band),
path.join(" ")
));
i = j + 1;
}
let (first, last) = (pts[0], pts[pts.len() - 1]);
svg.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"4\" fill=\"{INK}\"/>\
<text x=\"{:.1}\" y=\"{:.1}\" fill=\"{TITLE}\" font-family=\"{FONT_SANS}\" font-size=\"10\">start</text>\
<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"4\" fill=\"none\" stroke=\"{INK}\" stroke-width=\"1.5\"/>\
<text x=\"{:.1}\" y=\"{:.1}\" fill=\"{TITLE}\" font-family=\"{FONT_SANS}\" font-size=\"10\">end of log</text>",
px(first.0), py(first.1), px(first.0) + 7.0, py(first.1) + 3.0,
px(last.0), py(last.1), px(last.0) - 52.0, py(last.1) - 8.0,
));
let target = 0.2 * (x1 - x0) / scale;
let mag = 10f64.ppowf(target.plog10().floor());
let bar_m = [1.0, 2.0, 5.0, 10.0]
.iter()
.map(|m| m * mag)
.filter(|b| *b <= target)
.fold(mag, f64::max);
let label = if bar_m >= 1000.0 {
format!("{:.0} km", bar_m / 1000.0)
} else {
format!("{bar_m:.0} m")
};
svg.push_str(&format!(
"<line x1=\"{x0}\" x2=\"{:.1}\" y1=\"{}\" y2=\"{}\" stroke=\"{INK}\"/>\
<text x=\"{x0}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\">{label}</text>",
x0 + bar_m * scale,
ty1 + 2.0,
ty1 + 2.0,
ty1 + 14.0
));
} else {
svg.push_str(&format!(
"<text x=\"{x0}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"11\">no positions in the log</text>",
(ty0 + ty1) / 2.0
));
}
let (sy0, sy1) = (338.0_f64, 462.0_f64);
let t_max = r.epochs.last().map(|e| e.t_s).unwrap_or(1.0).max(1.0);
let sx = |t: f64| x0 + (x1 - x0) * t / t_max;
let sc = |v: f64| sy1 - (sy1 - sy0) * v / 100.0;
svg.push_str(&format!(
"<text x=\"12\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"11\">trust score, 0 to 100</text>",
sy0 - 8.0
));
for (v, label) in [
(100.0, "100"),
(nominal_min, ""),
(degraded_min, ""),
(0.0, "0"),
] {
svg.push_str(&format!(
"<line x1=\"{x0}\" x2=\"{x1}\" y1=\"{y:.1}\" y2=\"{y:.1}\" stroke=\"{RULE}\" stroke-dasharray=\"{}\"/>\
<text x=\"{}\" y=\"{:.1}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"10\" text-anchor=\"end\">{}</text>",
if label.is_empty() { "4 3" } else { "0" },
x0 - 4.0,
sc(v) + 3.0,
if label.is_empty() { format!("{v:.0}") } else { label.to_string() },
y = sc(v)
));
}
let line: Vec<String> = r
.epochs
.iter()
.filter_map(|e| {
e.score
.as_ref()
.map(|s| format!("{:.1},{:.1}", sx(e.t_s), sc(s.score)))
})
.collect();
if !line.is_empty() {
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"{BLUE}\" stroke-width=\"1.5\" points=\"{}\"/>",
line.join(" ")
));
}
let band_y = sy1 + 12.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);
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{band_y}\" width=\"{:.2}\" height=\"12\" fill=\"{}\"/>",
sx(e.t_s),
(sx(next) - sx(e.t_s)).max(0.5),
colour(e.state)
));
}
for ev in &r.events {
let x = sx(ev.onset_s);
svg.push_str(&format!(
"<line x1=\"{x:.1}\" x2=\"{x:.1}\" y1=\"{sy0}\" 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,
sy0 + 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 (score at or above {nominal_min:.0}), amber degraded (at or above {degraded_min:.0}), red untrusted, grey calibrating</text>\
<text x=\"{x0}\" y=\"{}\" fill=\"{MUTED}\" font-family=\"{FONT_SANS}\" font-size=\"9\">Advisory software, not type-approved navigation equipment; the operator remains responsible.</text></svg>",
band_y + 26.0,
band_y + 26.0,
band_y + 42.0,
band_y + 56.0
));
svg
}
pub fn to_svg(r: &ReceiverTrustResult) -> String {
if r.score_model.is_some() {
return to_svg_vessel(r);
}
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);
}
}
#[cfg(test)]
mod tests {
use super::*;
const LOG: &str = "log = { format = \"nmea\", text = \"$GPGGA,000000,,,,,0,00,,,,,,,*00\" }";
#[test]
fn platform_table_is_read_into_the_monitor_config() {
let scn: ReceiverTrustScenario = toml::from_str(&format!(
"{LOG}\n[platform]\nkind = \"vessel\"\nmax_speed_kn = 18.0\n"
))
.unwrap();
assert!(scn.monitors.platform.is_vessel());
assert_eq!(scn.monitors.platform.max_speed_kn, Some(18.0));
}
#[test]
fn static_scenarios_serialise_without_a_platform() {
let scn: ReceiverTrustScenario = toml::from_str(LOG).unwrap();
assert!(scn.monitors.platform.is_static());
let json = serde_json::to_string(&scn).unwrap();
assert!(!json.contains("platform"), "{json}");
let explicit: ReceiverTrustScenario =
toml::from_str(&format!("{LOG}\n[platform]\nkind = \"static\"\n")).unwrap();
assert_eq!(scn, explicit);
}
#[test]
fn platform_under_monitors_is_rejected_and_bad_limits_error_at_run() {
assert!(toml::from_str::<ReceiverTrustScenario>(&format!(
"{LOG}\n[monitors.platform]\nkind = \"vessel\"\n"
))
.is_err());
let scn: ReceiverTrustScenario = toml::from_str(&format!(
"{LOG}\n[platform]\nkind = \"vessel\"\nmax_speed_kn = -1.0\n"
))
.unwrap();
let e = run_receiver_trust(&scn).unwrap_err();
assert!(e.contains("max_speed_kn"), "{e}");
}
}