use std::collections::{BTreeMap, VecDeque};
use serde::{Deserialize, Serialize};
use super::ingest::NmeaFeed;
use super::maritime::REWIND_RATIO;
use super::monitors::{run_monitors, Monitor, TrustState};
use super::scenario::ReceiverTrustScenario;
use super::score::score_from_ratios;
use super::score::Deduction;
use super::{LogEpoch, Timeline, ADVISORY};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct LiveCfg {
pub idle_flush_s: f64,
pub gate_release_s: f64,
}
impl Default for LiveCfg {
fn default() -> Self {
Self {
idle_flush_s: 1.5,
gate_release_s: 30.0,
}
}
}
impl LiveCfg {
pub fn is_default(&self) -> bool {
*self == LiveCfg::default()
}
pub fn validate(&self) -> Result<(), String> {
for (name, v) in [
("idle_flush_s", self.idle_flush_s),
("gate_release_s", self.gate_release_s),
] {
if !(v.is_finite() && v >= 0.0) {
return Err(format!("live: {name} must be finite and >= 0 (got {v})"));
}
}
if self.idle_flush_s == 0.0 {
return Err("live: idle_flush_s must be above 0".into());
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GateAction {
Off,
Passed,
Withheld,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct EpochReport {
pub seq: u64,
pub t_s: f64,
pub time: Option<String>,
pub state: TrustState,
pub score: Option<f64>,
pub deductions: Vec<Deduction>,
pub alarms: Vec<Monitor>,
pub gate: GateAction,
pub note: Option<String>,
pub position: Option<ReportedPosition>,
pub advisory: &'static str,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ReportedPosition {
pub lat_deg: f64,
pub lon_deg: f64,
pub height_m: f64,
}
impl EpochReport {
pub fn to_json_line(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".into())
}
pub fn pksht(&self) -> String {
let tod = self.time.as_deref().and_then(label_tod).unwrap_or_default();
let score = self.score.map(|s| format!("{s:.1}")).unwrap_or_default();
let band = match self.state {
TrustState::Calibrating => 'C',
TrustState::Nominal => 'N',
TrustState::Degraded => 'D',
TrustState::Untrusted => 'U',
};
let gate = match self.gate {
GateAction::Off => '-',
GateAction::Passed => 'P',
GateAction::Withheld => 'W',
};
let reasons = self
.deductions
.iter()
.take(2)
.map(|d| format!("{}:{:.1}", monitor_label(d.monitor), d.points))
.collect::<Vec<_>>()
.join("/");
let body = format!("PKSHT,1,{tod},{score},{band},{gate},{reasons}");
format!("${body}*{:02X}", xor(&body))
}
}
fn monitor_label(m: Monitor) -> String {
serde_json::to_value(m)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default()
}
fn xor(body: &str) -> u8 {
body.bytes().fold(0, |a, b| a ^ b)
}
fn label_tod(label: &str) -> Option<String> {
let b = label.as_bytes();
let at = (0..b.len().saturating_sub(7)).find(|&i| {
let d = |k: usize| b[i + k].is_ascii_digit();
d(0) && d(1) && b[i + 2] == b':' && d(3) && d(4) && b[i + 5] == b':' && d(6) && d(7)
})?;
let hms = &label[at..at + 8];
let ms = label
.get(at + 9..at + 12)
.filter(|_| b.get(at + 8) == Some(&b'.'))
.and_then(|m| m.parse::<u32>().ok())
.unwrap_or(0);
Some(format!(
"{}{}{}.{:02}",
&hms[0..2],
&hms[3..5],
&hms[6..8],
ms / 10
))
}
pub fn parse_live_scenario(src: &str) -> Result<ReceiverTrustScenario, String> {
let mut v: toml::Table = toml::from_str(src).map_err(|e| format!("invalid session: {e}"))?;
v.entry("log").or_insert_with(|| {
let mut t = toml::Table::new();
t.insert("format".into(), "nmea".into());
t.insert("text".into(), "".into());
toml::Value::Table(t)
});
v.try_into().map_err(|e| format!("invalid session: {e}"))
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LiveOut {
pub forward: Vec<Vec<u8>>,
pub reports: Vec<EpochReport>,
}
const MAX_CYCLE_LINES: usize = 5_000;
const MAX_CAL_EPOCHS: usize = 20_000;
const MAX_RECENT_EPOCHS: usize = 20_000;
pub struct LiveEngine {
scn: ReceiverTrustScenario,
gate: bool,
feed: NmeaFeed,
pending: Vec<Vec<u8>>,
last_arrival: Option<f64>,
cal: Vec<LogEpoch>,
recent: VecDeque<LogEpoch>,
lookback_s: f64,
seq: u64,
host_clock: bool,
withheld: bool,
clear_since: Option<f64>,
}
impl LiveEngine {
pub fn new(scn: &ReceiverTrustScenario, gate: bool) -> Result<Self, String> {
let cfg = &scn.monitors;
cfg.validate()?;
if !cfg.platform.is_vessel() {
return Err(
"live mode needs [platform] kind = \"vessel\": the trust score is for a moving \
platform"
.into(),
);
}
let lookback_s = 2.0 * cfg.maritime.kin_window_s
+ 10.0
+ cfg.score.evidence_hold_s
+ cfg.maritime.smooth_s
+ 5.0;
Ok(Self {
scn: scn.clone(),
gate,
feed: NmeaFeed::new(),
pending: Vec::new(),
last_arrival: None,
cal: Vec::new(),
recent: VecDeque::new(),
lookback_s,
seq: 0,
host_clock: true,
withheld: false,
clear_since: None,
})
}
pub fn set_host_clock(&mut self, on: bool) {
self.host_clock = on;
}
pub fn feed_line(&mut self, raw: &[u8], arrival_s: f64) -> LiveOut {
self.pending.push(raw.to_vec());
self.last_arrival = Some(arrival_s);
if self.pending.len() >= MAX_CYCLE_LINES {
return self.finish();
}
let host = self.host_clock.then_some(arrival_s);
self.feed.feed(&String::from_utf8_lossy(raw), host);
if self.feed.open_epochs() > 1 {
let closed = self.feed.take_closed_epochs();
let last = self.pending.pop().unwrap_or_default();
let lines = std::mem::take(&mut self.pending);
self.pending.push(last);
return self.cycle(closed, lines, arrival_s);
}
LiveOut::default()
}
pub fn idle(&mut self, now_s: f64) -> LiveOut {
match self.last_arrival {
Some(t)
if !self.pending.is_empty() && now_s - t >= self.scn.monitors.live.idle_flush_s =>
{
let epochs = self.feed.take_epochs();
let lines = std::mem::take(&mut self.pending);
self.cycle(epochs, lines, now_s)
}
_ => LiveOut::default(),
}
}
pub fn finish(&mut self) -> LiveOut {
let epochs = self.feed.take_epochs();
let lines = std::mem::take(&mut self.pending);
let now = self.last_arrival.unwrap_or(0.0);
self.cycle(epochs, lines, now)
}
fn cycle(&mut self, epochs: Vec<LogEpoch>, lines: Vec<Vec<u8>>, now_s: f64) -> LiveOut {
let mut out = LiveOut::default();
let mut any_untrusted = false;
let mut reports = Vec::new();
for e in epochs {
let (state, score, deductions, alarms, note) = self.score(e.clone());
any_untrusted |= state == TrustState::Untrusted;
self.seq += 1;
reports.push((
EpochReport {
seq: self.seq,
t_s: e.t_s,
time: e.time_label.clone(),
state,
score,
deductions,
alarms,
gate: GateAction::Off,
note,
position: e.fix.map(|f| ReportedPosition {
lat_deg: f.lat_deg,
lon_deg: f.lon_deg,
height_m: f.height_m,
}),
advisory: ADVISORY,
},
e.t_s,
));
}
let t_now = reports.last().map(|(_, t)| *t);
if self.gate {
if any_untrusted {
self.withheld = true;
self.clear_since = None;
} else if self.withheld {
let clock = if self.host_clock { Some(now_s) } else { t_now };
if let Some(t) = clock {
let since = *self.clear_since.get_or_insert(t);
if t - since >= self.scn.monitors.live.gate_release_s {
self.withheld = false;
self.clear_since = None;
}
}
}
}
let action = match (self.gate, self.withheld) {
(false, _) => GateAction::Off,
(true, true) => GateAction::Withheld,
(true, false) => GateAction::Passed,
};
for (r, _) in reports.iter_mut() {
r.gate = action;
}
if self.gate {
for line in lines {
out.forward.push(if self.withheld {
withhold_line(&line)
} else {
line
});
}
for (r, _) in &reports {
out.forward.push(format!("{}\r\n", r.pksht()).into_bytes());
}
}
out.reports = reports.into_iter().map(|(r, _)| r).collect();
out
}
#[allow(clippy::type_complexity)]
fn score(
&mut self,
e: LogEpoch,
) -> (
TrustState,
Option<f64>,
Vec<Deduction>,
Vec<Monitor>,
Option<String>,
) {
let cal_s = self.scn.monitors.calibration_s;
if e.marine.as_ref().is_some_and(|m| m.time_rewound) {
let sc = score_from_ratios(
&BTreeMap::from([(Monitor::TimeConsistency, REWIND_RATIO)]),
&self.scn.monitors.score,
);
return (
sc.band,
Some(sc.score),
sc.deductions,
vec![Monitor::TimeConsistency],
Some("the receiver's time ran backwards: a replay or a clock fault".into()),
);
}
if e.t_s < cal_s {
if self.cal.len() < MAX_CAL_EPOCHS {
self.cal.push(e);
}
return (TrustState::Calibrating, None, Vec::new(), Vec::new(), None);
}
let t = e.t_s;
self.recent.push_back(e);
if self.recent.len() > MAX_RECENT_EPOCHS {
self.recent.pop_front();
}
while self
.recent
.front()
.is_some_and(|p| t - p.t_s > self.lookback_s)
{
self.recent.pop_front();
}
let tl = Timeline {
epochs: self.cal.iter().chain(self.recent.iter()).cloned().collect(),
..Timeline::default()
};
match run_monitors(&tl, None, &self.scn.monitors) {
Ok(r) => match r.epochs.last() {
Some(last) => {
let (score, ded) = last
.score
.as_ref()
.map(|s| (Some(s.score), s.deductions.clone()))
.unwrap_or((None, Vec::new()));
(last.state, score, ded, last.alarms.clone(), None)
}
None => (
TrustState::Untrusted,
None,
Vec::new(),
Vec::new(),
Some("no result for the epoch".into()),
),
},
Err(msg) => (
TrustState::Untrusted,
None,
Vec::new(),
Vec::new(),
Some(format!("no score could be produced: {msg}")),
),
}
}
}
pub fn withhold_line(raw: &[u8]) -> Vec<u8> {
let Ok(text) = std::str::from_utf8(raw) else {
return raw.to_vec();
};
let Some(start) = text.find('$') else {
return raw.to_vec();
};
let rest = &text[start + 1..];
let end = rest.find(['*', '\r', '\n']).unwrap_or(rest.len());
let had_checksum = rest[end..].starts_with('*');
let mut f: Vec<String> = rest[..end].split(',').map(str::to_string).collect();
let tail_start = if had_checksum {
let hex = rest
.get(end + 1..end + 3)
.filter(|h| h.bytes().all(|c| c.is_ascii_hexdigit()));
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(want) if want == xor(&rest[..end]) => end + 3,
_ => return raw.to_vec(),
}
} else {
end
};
let addr = f[0].clone();
if addr.len() != 5 || !addr.is_ascii() {
return raw.to_vec();
}
let set = |f: &mut Vec<String>, i: usize, v: &str| {
if i < f.len() {
f[i] = v.to_string();
}
};
match &addr[2..] {
"GGA" => set(&mut f, 6, "0"),
"RMC" => {
set(&mut f, 2, "V");
if f.len() > 12 && !f[12].is_empty() {
set(&mut f, 12, "N");
}
if f.len() > 13 && !f[13].is_empty() {
set(&mut f, 13, "V");
}
}
"GNS" => {
if f.len() > 6 {
let n = f[6].chars().count().max(1);
f[6] = "N".repeat(n);
}
}
"GLL" => {
set(&mut f, 6, "V");
if f.len() > 7 && !f[7].is_empty() {
set(&mut f, 7, "N");
}
}
"VTG" => {
if f.len() > 9 && !f[9].is_empty() {
set(&mut f, 9, "N");
}
}
_ => return raw.to_vec(),
}
let body = f.join(",");
let mut out = String::with_capacity(raw.len());
out.push_str(&text[..start + 1]);
out.push_str(&body);
if had_checksum {
out.push_str(&format!("*{:02X}", xor(&body)));
}
out.push_str(&rest[tail_start..]);
out.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
fn nmea(body: &str) -> String {
format!("${body}*{:02X}\r\n", xor(body))
}
#[test]
fn withhold_marks_each_fix_sentence_invalid_and_fixes_the_checksum() {
let gga = nmea("GPGGA,100000.00,5430.0000,N,01830.0000,E,1,10,0.9,18.4,M,26.5,M,,");
let out = String::from_utf8(withhold_line(gga.as_bytes())).unwrap();
assert_eq!(
out,
nmea("GPGGA,100000.00,5430.0000,N,01830.0000,E,0,10,0.9,18.4,M,26.5,M,,")
);
let rmc = nmea("GPRMC,100000.00,A,5430.0000,N,01830.0000,E,15.2,45.5,140625,,,A,V");
assert_eq!(
String::from_utf8(withhold_line(rmc.as_bytes())).unwrap(),
nmea("GPRMC,100000.00,V,5430.0000,N,01830.0000,E,15.2,45.5,140625,,,N,V")
);
let gns = nmea("GNGNS,100000.00,5430.0000,N,01830.0000,E,AA,10,0.9,18.4,26.5,,");
assert_eq!(
String::from_utf8(withhold_line(gns.as_bytes())).unwrap(),
nmea("GNGNS,100000.00,5430.0000,N,01830.0000,E,NN,10,0.9,18.4,26.5,,")
);
let gll = nmea("GPGLL,5430.0000,N,01830.0000,E,100000.00,A,A");
assert_eq!(
String::from_utf8(withhold_line(gll.as_bytes())).unwrap(),
nmea("GPGLL,5430.0000,N,01830.0000,E,100000.00,V,N")
);
let vtg = nmea("GPVTG,45.5,T,,M,15.2,N,28.2,K,A");
assert_eq!(
String::from_utf8(withhold_line(vtg.as_bytes())).unwrap(),
nmea("GPVTG,45.5,T,,M,15.2,N,28.2,K,N")
);
}
#[test]
fn withhold_does_not_launder_a_corrupt_or_malformed_checksum() {
for line in [
"$GPGGA,100000.00,5430.0,N,01830.0,E,1,10,0.9,18.4,M,26.5,M,,*00\r\n",
"$GPGGA,100000.00,5430.0,N,01830.0,E,1,10,0.9,18.4,M,26.5,M,,*5\r\n",
"$GPGGA,100000.00,5430.0,N,01830.0,E,1,10,0.9,18.4,M,26.5,M,,*+5\r\n",
] {
assert_eq!(withhold_line(line.as_bytes()), line.as_bytes(), "{line:?}");
}
}
#[test]
fn label_time_is_taken_by_position() {
assert_eq!(label_tod("2025-06-14T08:01:40.250Z").unwrap(), "080140.25");
assert_eq!(
label_tod("12:35:19.000 UTC (date not in log)").unwrap(),
"123519.00"
);
assert_eq!(label_tod("no time here"), None);
}
#[test]
fn withhold_leaves_every_other_line_byte_for_byte() {
for line in [
nmea("HEHDT,52.5,T"),
nmea("GPGSV,2,1,05,03,62,040,45"),
nmea("PKSHT,1,,,N,-,"),
"garbage with no sentence\r\n".to_string(),
"$GPVTG,45.5,T,,M,15.2,N,28.2,K\r\n".to_string(),
] {
assert_eq!(withhold_line(line.as_bytes()), line.as_bytes(), "{line:?}");
}
let bare = b"$GPGGA,100000.00,5430.0,N,01830.0,E,1,10,0.9,18.4,M,26.5,M,,\n";
assert_eq!(
withhold_line(bare),
b"$GPGGA,100000.00,5430.0,N,01830.0,E,0,10,0.9,18.4,M,26.5,M,,\n"
);
let bin = [0xB5u8, 0x62, 0xFF, b'\n'];
assert_eq!(withhold_line(&bin), bin);
}
#[test]
fn pksht_fields_and_checksum() {
let r = EpochReport {
seq: 7,
t_s: 100.0,
time: Some("2025-06-14T08:01:40.250Z".into()),
state: TrustState::Untrusted,
score: Some(23.4),
deductions: vec![
Deduction {
monitor: Monitor::HeadingCourse,
ratio: 2.0,
points: 40.0,
},
Deduction {
monitor: Monitor::Cn0Spread,
ratio: 1.6,
points: 30.0,
},
Deduction {
monitor: Monitor::SpeedLog,
ratio: 1.0,
points: 20.0,
},
],
alarms: vec![],
gate: GateAction::Withheld,
note: None,
position: None,
advisory: ADVISORY,
};
let s = r.pksht();
let body = "PKSHT,1,080140.25,23.4,U,W,heading-course:40.0/cn0-spread:30.0";
assert_eq!(s, format!("${body}*{:02X}", xor(body)));
assert!(s.len() + 2 <= 82, "NMEA 0183 sentence length limit");
let calibrating = EpochReport {
state: TrustState::Calibrating,
score: None,
deductions: vec![],
gate: GateAction::Off,
time: None,
..r
};
assert!(calibrating.pksht().starts_with("$PKSHT,1,,,C,-,*"));
}
}