use serde::Serialize;
use super::geometry::GeometrySummary;
use super::live::{parse_live_scenario, EpochReport, LiveEngine};
use super::monitors::TrustState;
use super::scenario::{run_receiver_trust_bytes, ReceiverTrustResult};
pub const MAX_EXCERPT_BYTES: usize = 2 * 1024 * 1024;
pub const MAX_EXCERPT_EPOCHS: usize = 20_000;
pub fn assess_vessel_log(session_toml: &str, log: &[u8]) -> Result<ReceiverTrustResult, String> {
let scn = parse_live_scenario(session_toml)?;
if !scn.monitors.platform.is_vessel() {
return Err("a vessel assessment needs [platform] kind = \"vessel\"".into());
}
run_receiver_trust_bytes(&scn, log, None)
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ExcerptSummary {
pub epochs: usize,
pub calibrating: usize,
pub nominal: usize,
pub degraded: usize,
pub untrusted: usize,
pub lowest_score: Option<f64>,
pub final_score: Option<f64>,
pub first_untrusted_t_s: Option<f64>,
pub geometry: GeometrySummary,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ExcerptAssessment {
pub schema: &'static str,
pub advisory: &'static str,
pub epochs: Vec<EpochReport>,
pub last_pksht: Option<String>,
pub summary: ExcerptSummary,
}
pub fn assess_stream_excerpt(
session_toml: &str,
excerpt: &[u8],
) -> Result<ExcerptAssessment, String> {
if excerpt.len() > MAX_EXCERPT_BYTES {
return Err(format!(
"excerpt is {} bytes; the limit is {MAX_EXCERPT_BYTES}",
excerpt.len()
));
}
let scn = parse_live_scenario(session_toml)?;
let mut engine = LiveEngine::new(&scn, false)?;
engine.set_host_clock(false);
let mut epochs: Vec<EpochReport> = Vec::new();
let mut push = |out: super::live::LiveOut| -> Result<(), String> {
epochs.extend(out.reports);
if epochs.len() > MAX_EXCERPT_EPOCHS {
return Err(format!(
"excerpt holds more than {MAX_EXCERPT_EPOCHS} epochs; cut it shorter"
));
}
Ok(())
};
for line in excerpt.split_inclusive(|b| *b == b'\n') {
push(engine.feed_line(line, 0.0))?;
}
push(engine.finish())?;
if let Some(e) = epochs.iter().find_map(|e| e.note.clone()) {
return Err(e);
}
let after: Vec<&EpochReport> = epochs
.iter()
.filter(|e| e.state != TrustState::Calibrating)
.collect();
if after.is_empty() {
return Err(format!(
"the excerpt ends inside the {} s calibration window ({} epochs): nothing was scored",
scn.monitors.calibration_s,
epochs.len()
));
}
let count = |s: TrustState| after.iter().filter(|e| e.state == s).count();
let summary = ExcerptSummary {
epochs: epochs.len(),
calibrating: epochs.len() - after.len(),
nominal: count(TrustState::Nominal),
degraded: count(TrustState::Degraded),
untrusted: count(TrustState::Untrusted),
lowest_score: after.iter().filter_map(|e| e.score).min_by(f64::total_cmp),
final_score: epochs.last().and_then(|e| e.score),
first_untrusted_t_s: after
.iter()
.find(|e| e.state == TrustState::Untrusted)
.map(|e| e.t_s),
geometry: {
let mut g = GeometrySummary::default();
for e in &epochs {
g.add(
e.t_s,
&e.geometry,
e.deductions.iter().filter_map(|d| d.class),
);
}
g
},
};
let last_pksht = epochs.last().map(EpochReport::pksht);
Ok(ExcerptAssessment {
schema: "1.3",
advisory: super::ADVISORY,
epochs,
last_pksht,
summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::receiver_trust::synth::{synth_voyage, DragSpec, VoyageSpec};
const SESSION: &str = "kind = \"receiver-trust\"\n[monitors]\ncalibration_s = 30.0\n\
[platform]\nkind = \"vessel\"\nantenna_height_m = 18.0\nheading_sensor = true\n";
fn voyage(duration_s: f64, drag: bool) -> String {
synth_voyage(&VoyageSpec {
duration_s,
drag: drag.then_some(DragSpec {
onset_s: 150.0,
accel_mps2: 0.02,
speed_mps: 3.0,
bearing_rel_deg: 90.0,
cn0_common_dbhz: Some(46.0),
cn0_ramp_s: 60.0,
}),
..Default::default()
})
}
#[test]
fn batch_assessment_takes_session_and_bytes() {
let r = assess_vessel_log(SESSION, voyage(240.0, true).as_bytes()).unwrap();
assert_eq!(r.log.epochs, 241);
assert!(r.score_model.is_some());
assert!(r.states.untrusted_epochs > 0);
let j = serde_json::to_string(&r).unwrap();
assert!(j.contains("\"score_model\""));
assert!(assess_vessel_log("kind = \"receiver-trust\"\n", b"").is_err());
}
#[test]
fn excerpt_gives_the_batch_scores_in_the_live_schema() {
let text = voyage(240.0, true);
let ex = assess_stream_excerpt(SESSION, text.as_bytes()).unwrap();
let batch = assess_vessel_log(SESSION, text.as_bytes()).unwrap();
assert_eq!(ex.schema, "1.3");
assert_eq!(ex.epochs.len(), batch.epochs.len());
for (l, b) in ex.epochs.iter().zip(&batch.epochs) {
assert_eq!((l.t_s, l.state), (b.t_s, b.state));
assert_eq!(l.score, b.score.as_ref().map(|s| s.score));
assert_eq!(l.geometry, b.geometry);
assert_eq!(
l.deductions,
b.score
.as_ref()
.map(|s| s.deductions.clone())
.unwrap_or_default()
);
}
assert_eq!(ex.summary.calibrating, 30);
assert_eq!(
ex.summary.nominal + ex.summary.degraded + ex.summary.untrusted,
211
);
assert!(ex.summary.first_untrusted_t_s.is_some());
assert!(ex.last_pksht.unwrap().starts_with("$PKSHT,1,"));
}
#[test]
fn excerpt_limits_and_short_excerpts_are_errors() {
let big = vec![b'x'; MAX_EXCERPT_BYTES + 1];
assert!(assess_stream_excerpt(SESSION, &big)
.unwrap_err()
.contains("limit"));
let short = voyage(20.0, false);
let e = assess_stream_excerpt(SESSION, short.as_bytes()).unwrap_err();
assert!(e.contains("calibration window"), "{e}");
assert!(assess_stream_excerpt("kind = \"receiver-trust\"\n", b"").is_err());
}
}