use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::monitors::{Monitor, TrustState};
pub fn default_weight(m: Monitor) -> f64 {
match m {
Monitor::Kinematic => 60.0,
Monitor::HeadingCourse => 40.0,
Monitor::SpeedLog => 40.0,
Monitor::SeaLevel => 30.0,
Monitor::TimeConsistency => 60.0,
Monitor::Osnma => 70.0,
Monitor::Raim => 60.0,
Monitor::Clock => 60.0,
Monitor::SolveFailure => 40.0,
Monitor::PositionJump => 60.0,
Monitor::Cn0Spread => 30.0,
Monitor::Cn0Rise => 30.0,
Monitor::Cn0Drop => 30.0,
Monitor::LossOfLock => 25.0,
Monitor::Agc => 25.0,
Monitor::JamInd => 25.0,
Monitor::Geometry => 0.0,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeductionClass {
Geometry,
Environment,
Inconsistency,
}
pub fn default_class(m: Monitor) -> DeductionClass {
match m {
Monitor::Geometry => DeductionClass::Geometry,
Monitor::Cn0Drop
| Monitor::Agc
| Monitor::JamInd
| Monitor::LossOfLock
| Monitor::Cn0Spread
| Monitor::Cn0Rise => DeductionClass::Environment,
_ => DeductionClass::Inconsistency,
}
}
pub const ALL_MONITORS: [Monitor; 16] = [
Monitor::Cn0Drop,
Monitor::Agc,
Monitor::JamInd,
Monitor::LossOfLock,
Monitor::PositionJump,
Monitor::Raim,
Monitor::Clock,
Monitor::SolveFailure,
Monitor::Kinematic,
Monitor::HeadingCourse,
Monitor::SpeedLog,
Monitor::SeaLevel,
Monitor::Cn0Spread,
Monitor::Cn0Rise,
Monitor::TimeConsistency,
Monitor::Osnma,
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ScoreCfg {
pub nominal_min: f64,
pub degraded_min: f64,
pub onset_ratio: f64,
pub full_ratio: f64,
pub evidence_hold_s: f64,
pub weights: BTreeMap<Monitor, f64>,
}
impl Default for ScoreCfg {
fn default() -> Self {
Self {
nominal_min: 90.0,
degraded_min: 55.0,
onset_ratio: 0.5,
full_ratio: 1.5,
evidence_hold_s: 10.0,
weights: BTreeMap::new(),
}
}
}
impl ScoreCfg {
pub fn is_default(&self) -> bool {
*self == ScoreCfg::default()
}
pub fn weight(&self, m: Monitor) -> f64 {
self.weights
.get(&m)
.copied()
.unwrap_or_else(|| default_weight(m))
}
pub fn effective_weights(&self) -> BTreeMap<Monitor, f64> {
ALL_MONITORS.iter().map(|m| (*m, self.weight(*m))).collect()
}
pub fn validate(&self) -> Result<(), String> {
for (name, v) in [
("nominal_min", self.nominal_min),
("degraded_min", self.degraded_min),
("onset_ratio", self.onset_ratio),
("full_ratio", self.full_ratio),
] {
if !v.is_finite() {
return Err(format!("score: {name} must be finite (got {v})"));
}
}
if !(0.0 < self.degraded_min
&& self.degraded_min < self.nominal_min
&& self.nominal_min <= 100.0)
{
return Err(format!(
"score: need 0 < degraded_min < nominal_min <= 100 (got {} and {})",
self.degraded_min, self.nominal_min
));
}
if !(self.evidence_hold_s.is_finite() && self.evidence_hold_s >= 0.0) {
return Err(format!(
"score: evidence_hold_s must be finite and >= 0 (got {})",
self.evidence_hold_s
));
}
if !(self.onset_ratio >= 0.0 && self.onset_ratio < self.full_ratio) {
return Err(format!(
"score: need 0 <= onset_ratio < full_ratio (got {} and {})",
self.onset_ratio, self.full_ratio
));
}
for (m, w) in &self.weights {
if !(w.is_finite() && (0.0..=100.0).contains(w)) {
return Err(format!(
"score: weight of {m:?} must be in [0, 100] (got {w})"
));
}
}
if self.weights.contains_key(&Monitor::Geometry) {
return Err(
"score: the geometry deduction is set in [geometry] (cap_points, full_ratio, \
floor), not in [score.weights]"
.into(),
);
}
Ok(())
}
pub fn band(&self, score: f64) -> TrustState {
if score >= self.nominal_min {
TrustState::Nominal
} else if score >= self.degraded_min {
TrustState::Degraded
} else {
TrustState::Untrusted
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Deduction {
pub monitor: Monitor,
pub ratio: f64,
pub points: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<DeductionClass>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TrustScore {
pub score: f64,
pub band: TrustState,
pub deductions: Vec<Deduction>,
}
pub fn points(weight: f64, ratio: f64, cfg: &ScoreCfg) -> f64 {
if !ratio.is_finite() {
return if ratio > 0.0 { weight } else { 0.0 };
}
let f = ((ratio - cfg.onset_ratio) / (cfg.full_ratio - cfg.onset_ratio)).clamp(0.0, 1.0);
weight * f
}
pub fn score_from_ratios(ratios: &BTreeMap<Monitor, f64>, cfg: &ScoreCfg) -> TrustScore {
score_with(ratios, cfg, None)
}
pub fn score_with(
ratios: &BTreeMap<Monitor, f64>,
cfg: &ScoreCfg,
extra: Option<Deduction>,
) -> TrustScore {
let mut deductions: Vec<Deduction> = ratios
.iter()
.map(|(m, r)| Deduction {
monitor: *m,
ratio: *r,
points: points(cfg.weight(*m), *r, cfg),
class: Some(default_class(*m)),
})
.chain(extra)
.filter(|d| d.points > 0.0)
.collect();
let total: f64 = deductions.iter().map(|d| d.points).sum();
deductions.sort_by(|a, b| {
b.points
.total_cmp(&a.points)
.then(a.monitor.cmp(&b.monitor))
});
let score = ((100.0 - total).clamp(0.0, 100.0) * 10.0).round() / 10.0;
TrustScore {
score,
band: cfg.band(score),
deductions,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one(m: Monitor, r: f64) -> TrustScore {
score_from_ratios(&BTreeMap::from([(m, r)]), &ScoreCfg::default())
}
fn lcg(seed: &mut u64) -> f64 {
*seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(*seed >> 11) as f64 / (1u64 << 53) as f64
}
#[test]
fn nothing_found_is_a_hundred_and_nominal() {
let s = score_from_ratios(&BTreeMap::new(), &ScoreCfg::default());
assert_eq!((s.score, s.band), (100.0, TrustState::Nominal));
assert!(s.deductions.is_empty());
}
#[test]
fn the_ramp_costs_nothing_to_onset_half_at_threshold_all_at_full() {
let c = ScoreCfg::default();
assert_eq!(points(40.0, 0.5, &c), 0.0);
assert_eq!(points(40.0, 1.0, &c), 20.0);
assert_eq!(points(40.0, 1.5, &c), 40.0);
assert_eq!(points(40.0, 9.0, &c), 40.0);
assert_eq!(points(40.0, f64::INFINITY, &c), 40.0);
assert_eq!(points(40.0, f64::NAN, &c), 0.0);
}
#[test]
fn score_is_monotone_non_increasing_in_every_alarm_statistic() {
let cfg = ScoreCfg::default();
let mut seed = 7u64;
for m in ALL_MONITORS {
for _ in 0..40 {
let mut bg: BTreeMap<Monitor, f64> = ALL_MONITORS
.iter()
.filter(|o| **o != m)
.map(|o| (*o, 3.0 * lcg(&mut seed)))
.collect();
let mut prev = f64::INFINITY;
for step in 0..=60 {
bg.insert(m, step as f64 * 0.05);
let s = score_from_ratios(&bg, &cfg).score;
assert!(s <= prev + 1e-12, "{m:?} step {step}: {s} > {prev}");
assert!((0.0..=100.0).contains(&s));
prev = s;
}
}
}
}
#[test]
fn score_never_rises_when_another_monitor_is_added() {
let cfg = ScoreCfg::default();
let mut seed = 11u64;
for _ in 0..200 {
let mut set: BTreeMap<Monitor, f64> = BTreeMap::new();
let mut prev = 100.0;
for m in ALL_MONITORS {
set.insert(m, 2.0 * lcg(&mut seed));
let s = score_from_ratios(&set, &cfg).score;
assert!(s <= prev, "{m:?}");
prev = s;
}
}
}
#[test]
fn healthy_statistics_stay_in_the_top_band() {
let cfg = ScoreCfg::default();
let mut seed = 3u64;
for _ in 0..200 {
let r: BTreeMap<Monitor, f64> = ALL_MONITORS
.iter()
.map(|m| (*m, cfg.onset_ratio * lcg(&mut seed)))
.collect();
let s = score_from_ratios(&r, &cfg);
assert_eq!((s.score, s.band), (100.0, TrustState::Nominal));
}
}
#[test]
fn the_geometry_deduction_is_not_a_score_weight() {
let mut cfg = ScoreCfg::default();
assert!(!cfg.effective_weights().contains_key(&Monitor::Geometry));
cfg.weights.insert(Monitor::Geometry, 10.0);
assert!(cfg.validate().unwrap_err().contains("[geometry]"));
let cfg: ScoreCfg = toml::from_str("nominal_min = 95.0\ndegraded_min = 90.0\n").unwrap();
assert!(cfg.validate().is_ok());
}
#[test]
fn an_extra_deduction_of_zero_points_leaves_the_score_as_it_was() {
let cfg = ScoreCfg::default();
let r = BTreeMap::from([(Monitor::Kinematic, 0.9), (Monitor::Cn0Drop, 0.7)]);
let zero = Deduction {
monitor: Monitor::Geometry,
ratio: 1.0,
points: 0.0,
class: Some(DeductionClass::Geometry),
};
assert_eq!(
score_with(&r, &cfg, Some(zero)),
score_from_ratios(&r, &cfg)
);
let some = Deduction {
points: 6.0,
..zero_points_geometry()
};
let s = score_with(&r, &cfg, Some(some));
assert_eq!(
s.score,
((score_from_ratios(&r, &cfg).score - 6.0) * 10.0).round() / 10.0
);
assert!(s.deductions.iter().any(|d| d.monitor == Monitor::Geometry));
}
fn zero_points_geometry() -> Deduction {
Deduction {
monitor: Monitor::Geometry,
ratio: 1.2,
points: 0.0,
class: Some(DeductionClass::Geometry),
}
}
#[test]
fn any_single_monitor_at_its_threshold_leaves_the_nominal_band() {
for m in ALL_MONITORS {
let s = one(m, 1.0);
assert_ne!(
s.band,
TrustState::Nominal,
"{m:?} at its threshold: {}",
s.score
);
assert_eq!(s.deductions.len(), 1);
assert_eq!(s.deductions[0].monitor, m);
}
}
#[test]
fn bands_follow_the_stated_edges() {
let c = ScoreCfg::default();
assert_eq!(c.band(100.0), TrustState::Nominal);
assert_eq!(c.band(90.0), TrustState::Nominal);
assert_eq!(c.band(89.9), TrustState::Degraded);
assert_eq!(c.band(55.0), TrustState::Degraded);
assert_eq!(c.band(54.9), TrustState::Untrusted);
assert_eq!(c.band(0.0), TrustState::Untrusted);
}
#[test]
fn one_clear_physics_violation_or_two_clear_sensor_disagreements_are_untrusted() {
assert_eq!(one(Monitor::Kinematic, 1.5).band, TrustState::Untrusted);
let both = score_from_ratios(
&BTreeMap::from([(Monitor::HeadingCourse, 1.5), (Monitor::SpeedLog, 1.5)]),
&ScoreCfg::default(),
);
assert_eq!((both.score, both.band), (20.0, TrustState::Untrusted));
assert_eq!(one(Monitor::HeadingCourse, 9.0).band, TrustState::Degraded);
assert_eq!(one(Monitor::Cn0Drop, 9.0).band, TrustState::Degraded);
}
#[test]
fn deductions_are_listed_largest_first_with_the_ratio_that_caused_them() {
let s = score_from_ratios(
&BTreeMap::from([
(Monitor::SeaLevel, 1.0),
(Monitor::Kinematic, 1.5),
(Monitor::Cn0Rise, 0.4),
]),
&ScoreCfg::default(),
);
let got: Vec<(Monitor, f64, f64)> = s
.deductions
.iter()
.map(|d| (d.monitor, d.ratio, d.points))
.collect();
assert_eq!(
got,
[
(Monitor::Kinematic, 1.5, 60.0),
(Monitor::SeaLevel, 1.0, 15.0)
]
);
assert_eq!(s.score, 25.0);
}
#[test]
fn overrides_and_validation() {
let mut c = ScoreCfg::default();
c.weights.insert(Monitor::SeaLevel, 10.0);
assert_eq!(c.weight(Monitor::SeaLevel), 10.0);
assert_eq!(c.weight(Monitor::Kinematic), 60.0);
assert_eq!(c.effective_weights().len(), ALL_MONITORS.len());
assert!(c.validate().is_ok());
for bad in [
ScoreCfg {
nominal_min: 50.0,
degraded_min: 60.0,
..Default::default()
},
ScoreCfg {
onset_ratio: 1.5,
..Default::default()
},
ScoreCfg {
degraded_min: 0.0,
..Default::default()
},
ScoreCfg {
weights: BTreeMap::from([(Monitor::Agc, 120.0)]),
..Default::default()
},
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
}
}