use crate::portable_math::PortableFloat;
use std::collections::{BTreeMap, VecDeque};
use serde::{Deserialize, Serialize};
use super::monitors::Monitor;
use super::platform::{VesselLimits, KN_TO_MPS};
use super::{LogEpoch, OsnmaStatus};
const WGS84_A: f64 = 6_378_137.0;
const WGS84_E2: f64 = 0.006_694_379_990_14;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct MaritimeConfig {
pub kin_window_s: f64,
pub kin_pos_tol_m: f64,
pub kin_sog_frac: f64,
pub kin_limit_guard: f64,
pub min_speed_kn: f64,
pub hdg_cog_tol_deg: f64,
pub stw_sog_tol_kn: f64,
pub sea_level_tol_m: f64,
pub smooth_s: f64,
pub cn0_spread_frac: f64,
pub cn0_spread_min_sats: usize,
pub cn0_spread_min_base_db: f64,
pub cn0_rise_db: f64,
pub cn0_rise_sat_db: f64,
pub cn0_rise_frac: f64,
pub time_step_tol_s: f64,
pub time_offset_tol_s: f64,
}
impl Default for MaritimeConfig {
fn default() -> Self {
Self {
kin_window_s: 30.0,
kin_pos_tol_m: 12.0,
kin_sog_frac: 0.02,
kin_limit_guard: 0.8,
min_speed_kn: 3.0,
hdg_cog_tol_deg: 10.0,
stw_sog_tol_kn: 2.0,
sea_level_tol_m: 15.0,
smooth_s: 10.0,
cn0_spread_frac: 0.5,
cn0_spread_min_sats: 6,
cn0_spread_min_base_db: 2.0,
cn0_rise_db: 3.0,
cn0_rise_sat_db: 2.0,
cn0_rise_frac: 0.8,
time_step_tol_s: 1.5,
time_offset_tol_s: 1.0,
}
}
}
impl MaritimeConfig {
pub fn is_default(&self) -> bool {
*self == MaritimeConfig::default()
}
pub fn validate(&self) -> Result<(), String> {
for (name, v) in [
("kin_window_s", self.kin_window_s),
("kin_pos_tol_m", self.kin_pos_tol_m),
("min_speed_kn", self.min_speed_kn),
("hdg_cog_tol_deg", self.hdg_cog_tol_deg),
("stw_sog_tol_kn", self.stw_sog_tol_kn),
("sea_level_tol_m", self.sea_level_tol_m),
("smooth_s", self.smooth_s),
("cn0_spread_min_base_db", self.cn0_spread_min_base_db),
("cn0_rise_db", self.cn0_rise_db),
("cn0_rise_sat_db", self.cn0_rise_sat_db),
("time_step_tol_s", self.time_step_tol_s),
("time_offset_tol_s", self.time_offset_tol_s),
] {
if !(v.is_finite() && v > 0.0) {
return Err(format!("maritime: {name} must be finite and > 0 (got {v})"));
}
}
if !(self.kin_limit_guard.is_finite()
&& self.kin_limit_guard > 0.0
&& self.kin_limit_guard < 1.0)
{
return Err(format!(
"maritime: kin_limit_guard must be in (0, 1) (got {})",
self.kin_limit_guard
));
}
if !(self.kin_sog_frac.is_finite() && (0.0..=1.0).contains(&self.kin_sog_frac)) {
return Err(format!(
"maritime: kin_sog_frac must be in [0, 1] (got {})",
self.kin_sog_frac
));
}
for (name, v) in [
("cn0_spread_frac", self.cn0_spread_frac),
("cn0_rise_frac", self.cn0_rise_frac),
] {
if !(v.is_finite() && v > 0.0 && v < 1.0) {
return Err(format!("maritime: {name} must be in (0, 1) (got {v})"));
}
}
if self.cn0_spread_min_sats < 3 {
return Err("maritime: cn0_spread_min_sats must be at least 3".into());
}
if self.kin_window_s < 2.0 {
return Err("maritime: kin_window_s must be at least 2 s".into());
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MarineStats {
pub kin_speed_mps: Option<f64>,
pub kin_resid_m: Option<f64>,
pub kin_accel_mps2: Option<f64>,
pub kin_turn_dps: Option<f64>,
pub hdg_cog_deg: Option<f64>,
pub stw_sog_kn: Option<f64>,
pub sea_level_m: Option<f64>,
pub cn0_spread_db: Option<f64>,
pub cn0_rise_db: Option<f64>,
pub time_step_dev_s: Option<f64>,
pub time_offset_dev_s: Option<f64>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MarineEpoch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<[f64; 2]>,
pub stats: MarineStats,
pub ratios: Vec<(Monitor, f64)>,
}
impl MarineEpoch {
pub fn alarms(&self) -> impl Iterator<Item = Monitor> + '_ {
self.ratios
.iter()
.filter(|(_, r)| *r >= 1.0)
.map(|(m, _)| *m)
}
}
pub fn en_offset_m(lat0: f64, lon0: f64, lat1: f64, lon1: f64) -> (f64, f64) {
let phi = ((lat0 + lat1) / 2.0).to_radians();
let s2 = phi.psin().ppowi(2);
let w = (1.0 - WGS84_E2 * s2).sqrt();
let n_radius = WGS84_A / w;
let m_radius = WGS84_A * (1.0 - WGS84_E2) / (w * w * w);
let dlon = (lon1 - lon0 + 540.0).rem_euclid(360.0) - 180.0;
(
dlon.to_radians() * n_radius * phi.pcos(),
(lat1 - lat0).to_radians() * m_radius,
)
}
fn ang_diff_deg(a: f64, b: f64) -> f64 {
(a - b + 540.0).rem_euclid(360.0) - 180.0
}
pub const REWIND_RATIO: f64 = 1.5;
fn limit_ratio(x: f64, limit: f64, guard: f64) -> f64 {
((x / limit - guard) / (1.0 - guard)).max(0.0)
}
fn median(v: &[f64]) -> Option<f64> {
if v.is_empty() {
return None;
}
let mut s = v.to_vec();
s.sort_by(f64::total_cmp);
let n = s.len();
Some(if n % 2 == 1 {
s[n / 2]
} else {
0.5 * (s[n / 2 - 1] + s[n / 2])
})
}
#[derive(Default)]
struct Window(VecDeque<(f64, f64)>);
impl Window {
fn push_median(&mut self, t: f64, v: f64, span: f64) -> f64 {
self.0.push_back((t, v));
while self.0.front().is_some_and(|(t0, _)| t - *t0 > span) {
self.0.pop_front();
}
let vals: Vec<f64> = self.0.iter().map(|(_, v)| *v).collect();
median(&vals).unwrap_or(v)
}
}
#[derive(Clone, Copy)]
struct FixPt {
t: f64,
lat: f64,
lon: f64,
vel: Option<(f64, f64)>,
}
struct CnBaseline {
per_sat: BTreeMap<(String, String), f64>,
spread_db: Option<f64>,
}
pub struct MarineMonitors {
cfg: MaritimeConfig,
limits: VesselLimits,
cal_s: f64,
fixes: VecDeque<FixPt>,
hdg: Window,
stw: Window,
sea: Window,
off: Window,
cal_samples: BTreeMap<(String, String), Vec<f64>>,
cal_spreads: Vec<f64>,
cal_steps: Vec<f64>,
cal_offsets: Vec<f64>,
baseline: Option<CnBaseline>,
cadence_s: f64,
offset_base_s: Option<f64>,
}
fn spread(v: &[f64]) -> f64 {
let n = v.len() as f64;
let m = v.iter().sum::<f64>() / n;
(v.iter().map(|x| (x - m).ppowi(2)).sum::<f64>() / n).sqrt()
}
impl MarineMonitors {
pub fn new(cfg: &MaritimeConfig, limits: VesselLimits, calibration_s: f64) -> Self {
Self {
cfg: cfg.clone(),
limits,
cal_s: calibration_s,
fixes: VecDeque::new(),
hdg: Window::default(),
stw: Window::default(),
sea: Window::default(),
off: Window::default(),
cal_samples: BTreeMap::new(),
cal_spreads: Vec::new(),
cal_steps: Vec::new(),
cal_offsets: Vec::new(),
baseline: None,
cadence_s: 1.0,
offset_base_s: None,
}
}
fn velocity(e: &LogEpoch) -> Option<(f64, f64)> {
let m = e.marine.as_ref()?;
let (sog, cog) = (m.sog_kn?, m.cog_deg?);
let (s, c) = cog.to_radians().psin_cos();
Some((sog * KN_TO_MPS * s, sog * KN_TO_MPS * c))
}
fn freeze_baseline(&mut self) {
if self.baseline.is_some() {
return;
}
let per_sat = self
.cal_samples
.iter()
.filter_map(|(k, v)| median(v).map(|m| (k.clone(), m)))
.collect();
self.baseline = Some(CnBaseline {
per_sat,
spread_db: median(&self.cal_spreads),
});
let pos_steps: Vec<f64> = self
.cal_steps
.iter()
.copied()
.filter(|d| *d > 0.0)
.collect();
self.cadence_s = median(&pos_steps).unwrap_or(1.0);
self.offset_base_s = median(&self.cal_offsets);
}
pub fn push(&mut self, e: &LogEpoch) -> MarineEpoch {
let rewound = e.marine.as_ref().is_some_and(|m| m.time_rewound);
if e.t_s >= self.cal_s {
self.freeze_baseline();
}
let post = e.t_s >= self.cal_s || rewound;
let mut out = MarineEpoch {
position: e.fix.map(|f| [f.lat_deg, f.lon_deg]),
..MarineEpoch::default()
};
let mut ratios: BTreeMap<Monitor, f64> = BTreeMap::new();
let marine = e.marine.as_ref();
let valid = marine.is_none_or(|m| m.fix_valid != Some(false));
let cfg = self.cfg.clone();
let cn: Vec<f64> = e.cn0.iter().map(|c| c.cn0_dbhz).collect();
let spread_now = (cn.len() >= cfg.cn0_spread_min_sats).then(|| spread(&cn));
if !post {
for c in &e.cn0 {
self.cal_samples
.entry((c.sat.clone(), c.band.clone()))
.or_default()
.push(c.cn0_dbhz);
}
if let Some(sd) = spread_now {
self.cal_spreads.push(sd);
}
if let Some(d) = marine.and_then(|m| m.time_step_s) {
self.cal_steps.push(d);
}
if let Some(a) = marine.and_then(|m| m.arrival_s) {
self.cal_offsets.push(a - e.t_s);
}
} else if let Some(base) = &self.baseline {
out.stats.cn0_spread_db = spread_now;
if let (Some(sd), Some(sd0)) = (spread_now, base.spread_db) {
if sd0 >= cfg.cn0_spread_min_base_db {
let r = ((sd0 - sd) / (sd0 * (1.0 - cfg.cn0_spread_frac))).max(0.0);
ratios.insert(Monitor::Cn0Spread, r);
}
}
let rises: Vec<f64> = e
.cn0
.iter()
.filter_map(|c| {
base.per_sat
.get(&(c.sat.clone(), c.band.clone()))
.map(|b| c.cn0_dbhz - b)
})
.collect();
if rises.len() >= cfg.cn0_spread_min_sats.min(5) {
let mean = rises.iter().sum::<f64>() / rises.len() as f64;
let frac = rises.iter().filter(|r| **r >= cfg.cn0_rise_sat_db).count() as f64
/ rises.len() as f64;
out.stats.cn0_rise_db = Some(mean);
let r = (mean / cfg.cn0_rise_db)
.min(frac / cfg.cn0_rise_frac)
.max(0.0);
ratios.insert(Monitor::Cn0Rise, r);
}
}
if rewound {
ratios.insert(Monitor::TimeConsistency, REWIND_RATIO);
}
if let Some(m) = marine {
if post {
if let Some(d) = m.time_step_s {
let dev = d - self.cadence_s;
out.stats.time_step_dev_s = Some(dev);
let mut r = dev.abs() / cfg.time_step_tol_s;
if d < 0.0 {
r = r.max(1.0 + d.abs() / cfg.time_step_tol_s);
}
let slot = ratios.entry(Monitor::TimeConsistency).or_insert(0.0);
*slot = slot.max(r);
}
if let (Some(a), Some(base), false) = (m.arrival_s, self.offset_base_s, rewound) {
let med = self.off.push_median(e.t_s, a - e.t_s, cfg.smooth_s);
let dev = med - base;
out.stats.time_offset_dev_s = Some(dev);
let r = dev.abs() / cfg.time_offset_tol_s;
let slot = ratios.entry(Monitor::TimeConsistency).or_insert(0.0);
*slot = slot.max(r);
}
} else if let (Some(a), false) = (m.arrival_s, rewound) {
self.off.push_median(e.t_s, a - e.t_s, cfg.smooth_s);
}
}
if let (Some(f), true) = (e.fix, valid) {
let now = FixPt {
t: e.t_s,
lat: f.lat_deg,
lon: f.lon_deg,
vel: Self::velocity(e),
};
if post {
self.kinematics(now, &mut out.stats, &mut ratios);
}
self.fixes.push_back(now);
let keep = 2.0 * cfg.kin_window_s + 10.0;
while self.fixes.front().is_some_and(|p| now.t - p.t > keep) {
self.fixes.pop_front();
}
}
if let (Some(m), true) = (marine, valid) {
let sog = m.sog_kn.filter(|s| *s >= self.cfg.min_speed_kn);
if let (true, Some(h), Some(c), Some(_)) =
(self.limits.heading_sensor, m.heading_deg, m.cog_deg, sog)
{
let med = self
.hdg
.push_median(e.t_s, ang_diff_deg(h, c).abs(), cfg.smooth_s);
if post {
out.stats.hdg_cog_deg = Some(med);
ratios.insert(Monitor::HeadingCourse, med / cfg.hdg_cog_tol_deg);
}
}
if let (Some(s), Some(w)) = (m.sog_kn, m.stw_kn) {
let med = self.stw.push_median(e.t_s, (s - w).abs(), cfg.smooth_s);
if post {
out.stats.stw_sog_kn = Some(med);
ratios.insert(Monitor::SpeedLog, med / cfg.stw_sog_tol_kn);
}
}
if let (Some(h0), Some(alt)) = (self.limits.antenna_height_m, m.alt_msl_m) {
let med = self.sea.push_median(e.t_s, (alt - h0).abs(), cfg.smooth_s);
if post {
out.stats.sea_level_m = Some(med);
ratios.insert(Monitor::SeaLevel, med / cfg.sea_level_tol_m);
}
}
}
if let (true, Some(m)) = (post, marine) {
let any_failed = m.osnma == Some(OsnmaStatus::Failed)
|| m.sat_auth.iter().any(|(_, s)| *s == OsnmaStatus::Failed);
let any_ok = m.osnma == Some(OsnmaStatus::Authenticated)
|| m.sat_auth
.iter()
.any(|(_, s)| *s == OsnmaStatus::Authenticated);
if any_failed {
ratios.insert(Monitor::Osnma, 1.5);
} else if any_ok {
ratios.insert(Monitor::Osnma, 0.0);
}
}
out.ratios = ratios.into_iter().collect();
out
}
fn kinematics(&self, now: FixPt, stats: &mut MarineStats, ratios: &mut BTreeMap<Monitor, f64>) {
let c = &self.cfg;
let vmax = self.limits.max_speed_mps;
let w = c.kin_window_s;
let anchor = |from_t: f64, age: f64| -> Option<FixPt> {
self.fixes
.iter()
.rev()
.find(|p| from_t - p.t >= age)
.copied()
};
let mut r_speed: Option<f64> = None;
let mut r_resid: Option<f64> = None;
for age in [5.0_f64.min(w), w] {
let Some(a) = anchor(now.t, age) else {
continue;
};
let dt = now.t - a.t;
let (de, dn) = en_offset_m(a.lat, a.lon, now.lat, now.lon);
let dist = de.phypot(dn);
let speed = dist / dt;
if age == w {
stats.kin_speed_mps = Some(speed);
}
let over = limit_ratio(
((dist - c.kin_pos_tol_m) / dt).max(0.0),
vmax,
c.kin_limit_guard,
);
r_speed = Some(r_speed.map_or(over, |x: f64| x.max(over)));
let seg: Vec<FixPt> = self
.fixes
.iter()
.filter(|p| p.t >= a.t)
.copied()
.chain(std::iter::once(now))
.collect();
if seg.iter().all(|p| p.vel.is_some()) && seg.len() >= 2 {
let (mut pe, mut pn, mut path) = (0.0, 0.0, 0.0);
for pair in seg.windows(2) {
let (v0, v1) = (
pair[0].vel.unwrap_or_default(),
pair[1].vel.unwrap_or_default(),
);
let h = pair[1].t - pair[0].t;
let (ve, vn) = (0.5 * (v0.0 + v1.0), 0.5 * (v0.1 + v1.1));
pe += ve * h;
pn += vn * h;
path += ve.phypot(vn) * h;
}
let resid = (de - pe).phypot(dn - pn);
if age == w {
stats.kin_resid_m = Some(resid);
}
let allowed = c.kin_pos_tol_m + c.kin_sog_frac * path;
let r = resid / allowed;
r_resid = Some(r_resid.map_or(r, |x: f64| x.max(r)));
}
}
if let Some(a1) = anchor(now.t, w) {
if let Some(a2) = anchor(a1.t, w) {
let (dt2, dt1) = (now.t - a1.t, a1.t - a2.t);
let (e2, n2) = en_offset_m(a1.lat, a1.lon, now.lat, now.lon);
let (e1, n1) = en_offset_m(a2.lat, a2.lon, a1.lat, a1.lon);
let (v2, v1) = ((e2 / dt2, n2 / dt2), (e1 / dt1, n1 / dt1));
let unc = c.kin_pos_tol_m / dt2 + c.kin_pos_tol_m / dt1;
let dv = (v2.0 - v1.0).phypot(v2.1 - v1.1);
let span = 0.5 * (dt1 + dt2);
let accel = (dv - unc).max(0.0) / span;
stats.kin_accel_mps2 = Some(accel);
let r_acc = limit_ratio(accel, self.limits.max_accel_mps2, c.kin_limit_guard);
let (s1, s2) = (v1.0.phypot(v1.1), v2.0.phypot(v2.1));
let min_v = c.min_speed_kn * KN_TO_MPS;
let mut r_turn = None;
if s1 >= min_v && s2 >= min_v {
let b1 = v1.0.patan2(v1.1).to_degrees();
let b2 = v2.0.patan2(v2.1).to_degrees();
let unc_deg = ((c.kin_pos_tol_m / dt1) / s1 + (c.kin_pos_tol_m / dt2) / s2)
.patan()
.to_degrees();
let rate = (ang_diff_deg(b2, b1).abs() - unc_deg).max(0.0) / span;
stats.kin_turn_dps = Some(rate);
r_turn = Some(limit_ratio(
rate,
self.limits.max_turn_rate_dps,
c.kin_limit_guard,
));
}
let r = [Some(r_acc), r_turn, r_speed, r_resid]
.into_iter()
.flatten()
.fold(0.0_f64, f64::max);
ratios.insert(Monitor::Kinematic, r);
return;
}
}
let r = [r_speed, r_resid]
.into_iter()
.flatten()
.fold(None, |a: Option<f64>, x| Some(a.map_or(x, |y| y.max(x))));
if let Some(r) = r {
ratios.insert(Monitor::Kinematic, r);
}
}
}