#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum RfDisturbance {
PointwiseBounded {
d_max: f32,
},
Drift {
b: f32,
s_max: f32,
},
SlewRateBounded {
s_max: f32,
},
Impulsive {
amplitude: f32,
start_sample: u32,
duration_samples: u32,
},
PersistentElevated {
r_nominal: f32,
r_elevated: f32,
step_sample: u32,
},
}
impl RfDisturbance {
pub fn class_label(&self) -> &'static str {
match self {
Self::PointwiseBounded { .. } => "PointwiseBounded",
Self::Drift { .. } => "Drift",
Self::SlewRateBounded { .. } => "SlewRateBounded",
Self::Impulsive { .. } => "Impulsive",
Self::PersistentElevated { .. } => "PersistentElevated",
}
}
pub fn magnitude_bound(&self, k: u32) -> Option<f32> {
match self {
Self::PointwiseBounded { d_max } => Some(*d_max),
Self::Drift { b, s_max } => Some(b + s_max * k as f32),
Self::SlewRateBounded { .. } => None, Self::Impulsive { amplitude, start_sample, duration_samples } => {
let end = start_sample.wrapping_add(*duration_samples);
if k >= *start_sample && k < end {
Some(*amplitude)
} else {
Some(0.0) }
}
Self::PersistentElevated { r_elevated, step_sample, .. } => {
if k >= *step_sample {
Some(*r_elevated)
} else {
None
}
}
}
}
pub fn requires_envelope_adaptation(&self) -> bool {
matches!(
self,
Self::Drift { .. } | Self::PersistentElevated { .. }
)
}
pub fn recommended_envelope_mode_label(&self) -> &'static str {
match self {
Self::PointwiseBounded { .. } => "Fixed",
Self::Drift { .. } => "Widening",
Self::SlewRateBounded { .. } => "Fixed", Self::Impulsive { .. } => "Fixed", Self::PersistentElevated { .. } => "RegimeSwitched", }
}
}
#[derive(Debug, Clone)]
pub struct DisturbanceLog<const N: usize> {
entries: [Option<DisturbanceHypothesis>; N],
head: usize,
count: usize,
}
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DisturbanceHypothesis {
pub disturbance: RfDisturbance,
pub created_at: u32,
pub confidence: f32,
pub dsa_corroborated: bool,
}
impl<const N: usize> DisturbanceLog<N> {
pub const fn new() -> Self {
Self {
entries: [None; N],
head: 0,
count: 0,
}
}
pub fn push(&mut self, hyp: DisturbanceHypothesis) {
self.entries[self.head] = Some(hyp);
self.head = (self.head + 1) % N;
if self.count < N { self.count += 1; }
}
pub fn iter(&self) -> impl Iterator<Item = &DisturbanceHypothesis> {
self.entries.iter().filter_map(|e| e.as_ref())
}
pub fn len(&self) -> usize { self.count }
pub fn is_empty(&self) -> bool { self.count == 0 }
pub fn most_confident(&self) -> Option<&DisturbanceHypothesis> {
self.iter().max_by(|a, b| {
a.confidence.partial_cmp(&b.confidence).unwrap_or(core::cmp::Ordering::Equal)
})
}
pub fn clear(&mut self) {
self.entries = [None; N];
self.head = 0;
self.count = 0;
}
}
impl<const N: usize> Default for DisturbanceLog<N> {
fn default() -> Self { Self::new() }
}
pub struct DisturbanceClassifier {
pub excess_threshold: f32,
pub persistence_min: u32,
pub drift_lambda_min: f32,
consecutive_outside: u32,
prev_norm: f32,
has_prev: bool,
sample_idx: u32,
}
impl DisturbanceClassifier {
pub const fn default_rf() -> Self {
Self {
excess_threshold: 0.05,
persistence_min: 8,
drift_lambda_min: 0.005,
consecutive_outside: 0,
prev_norm: 0.0,
has_prev: false,
sample_idx: 0,
}
}
pub fn classify(
&mut self,
norm: f32,
rho: f32,
lambda: f32,
dsa_fired: bool,
) -> Option<DisturbanceHypothesis> {
let k = self.sample_idx;
self.sample_idx = self.sample_idx.wrapping_add(1);
let normalised_excess = if rho > 1e-30 { (norm - rho) / rho } else { 0.0 };
let outside = normalised_excess > 0.0;
let delta_norm = if self.has_prev { (norm - self.prev_norm).abs() } else { 0.0 };
self.prev_norm = norm;
self.has_prev = true;
self.update_persistence(outside, normalised_excess);
let disturbance = self.select_disturbance(norm, rho, lambda, normalised_excess, outside, delta_norm, k)?;
let confidence = self.compute_confidence(&disturbance, lambda);
Some(DisturbanceHypothesis {
disturbance,
created_at: k,
confidence,
dsa_corroborated: dsa_fired,
})
}
fn update_persistence(&mut self, outside: bool, normalised_excess: f32) {
if outside && normalised_excess > self.excess_threshold {
self.consecutive_outside = self.consecutive_outside.saturating_add(1);
} else {
self.consecutive_outside = 0;
}
}
fn select_disturbance(
&self,
norm: f32,
rho: f32,
lambda: f32,
normalised_excess: f32,
outside: bool,
delta_norm: f32,
k: u32,
) -> Option<RfDisturbance> {
if outside && self.consecutive_outside >= self.persistence_min && normalised_excess < 0.5 {
return Some(RfDisturbance::PersistentElevated {
r_nominal: rho,
r_elevated: norm,
step_sample: k.saturating_sub(self.consecutive_outside),
});
}
if lambda > self.drift_lambda_min && outside {
return Some(RfDisturbance::Drift {
b: normalised_excess * rho,
s_max: lambda * rho,
});
}
if outside && self.consecutive_outside == 1 && normalised_excess > 0.20 {
return Some(RfDisturbance::Impulsive {
amplitude: norm,
start_sample: k,
duration_samples: 1,
});
}
if delta_norm > 0.02 * rho && !outside {
return Some(RfDisturbance::SlewRateBounded { s_max: delta_norm });
}
if !outside { return None; }
Some(RfDisturbance::PointwiseBounded { d_max: norm })
}
fn compute_confidence(&self, disturbance: &RfDisturbance, lambda: f32) -> f32 {
match disturbance {
RfDisturbance::PersistentElevated { .. } => {
(self.consecutive_outside as f32 / self.persistence_min as f32).min(1.0)
}
RfDisturbance::Drift { .. } => (lambda / (self.drift_lambda_min * 5.0)).min(1.0),
RfDisturbance::Impulsive { .. } => 0.5,
RfDisturbance::SlewRateBounded { .. } => 0.3,
RfDisturbance::PointwiseBounded { .. } => 0.4,
}
}
pub fn reset(&mut self) {
self.consecutive_outside = 0;
self.prev_norm = 0.0;
self.has_prev = false;
self.sample_idx = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn class_labels_canonical() {
assert_eq!(
RfDisturbance::PointwiseBounded { d_max: 0.1 }.class_label(),
"PointwiseBounded"
);
assert_eq!(
RfDisturbance::Drift { b: 0.0, s_max: 0.001 }.class_label(),
"Drift"
);
assert_eq!(
RfDisturbance::SlewRateBounded { s_max: 0.005 }.class_label(),
"SlewRateBounded"
);
assert_eq!(
RfDisturbance::Impulsive { amplitude: 0.5, start_sample: 10, duration_samples: 3 }.class_label(),
"Impulsive"
);
assert_eq!(
RfDisturbance::PersistentElevated { r_nominal: 0.05, r_elevated: 0.20, step_sample: 50 }.class_label(),
"PersistentElevated"
);
}
#[test]
fn drift_magnitude_bound_grows() {
let d = RfDisturbance::Drift { b: 0.01, s_max: 0.001 };
let bound0 = d.magnitude_bound(0).unwrap();
let bound100 = d.magnitude_bound(100).unwrap();
assert!(bound100 > bound0, "drift bound must grow with k");
assert!((bound100 - 0.11).abs() < 1e-5, "bound100={}", bound100);
}
#[test]
fn impulsive_bound_outside_window_zero() {
let d = RfDisturbance::Impulsive { amplitude: 2.0, start_sample: 10, duration_samples: 5 };
let before = d.magnitude_bound(9).unwrap();
let after = d.magnitude_bound(15).unwrap();
assert_eq!(before, 0.0);
assert_eq!(after, 0.0);
let inside = d.magnitude_bound(12).unwrap();
assert_eq!(inside, 2.0);
}
#[test]
fn persistent_elevated_bound_after_step() {
let d = RfDisturbance::PersistentElevated { r_nominal: 0.05, r_elevated: 0.20, step_sample: 20 };
assert!(d.magnitude_bound(19).is_none(), "before step: no bound");
let after = d.magnitude_bound(20).unwrap();
assert!((after - 0.20).abs() < 1e-6);
}
#[test]
fn envelope_adaptation_flags() {
assert!(!RfDisturbance::PointwiseBounded { d_max: 0.1 }.requires_envelope_adaptation());
assert!(RfDisturbance::Drift { b: 0.0, s_max: 0.001 }.requires_envelope_adaptation());
assert!(!RfDisturbance::SlewRateBounded { s_max: 0.005 }.requires_envelope_adaptation());
assert!(RfDisturbance::PersistentElevated {
r_nominal: 0.05, r_elevated: 0.20, step_sample: 0
}.requires_envelope_adaptation());
}
#[test]
fn disturbance_log_push_and_most_confident() {
let mut log = DisturbanceLog::<4>::new();
assert!(log.is_empty());
log.push(DisturbanceHypothesis {
disturbance: RfDisturbance::PointwiseBounded { d_max: 0.1 },
created_at: 0,
confidence: 0.4,
dsa_corroborated: false,
});
log.push(DisturbanceHypothesis {
disturbance: RfDisturbance::Drift { b: 0.01, s_max: 0.001 },
created_at: 5,
confidence: 0.8,
dsa_corroborated: true,
});
assert_eq!(log.len(), 2);
let best = log.most_confident().unwrap();
assert!(
(best.confidence - 0.8).abs() < 1e-6,
"most confident should be the Drift entry"
);
}
#[test]
fn disturbance_log_ring_behaviour() {
let mut log = DisturbanceLog::<2>::new();
for i in 0..5_u32 {
log.push(DisturbanceHypothesis {
disturbance: RfDisturbance::PointwiseBounded { d_max: i as f32 * 0.01 },
created_at: i,
confidence: 0.5,
dsa_corroborated: false,
});
}
assert_eq!(log.len(), 2);
}
#[test]
fn classifier_nominal_returns_none() {
let mut clf = DisturbanceClassifier::default_rf();
for _ in 0..20 {
let h = clf.classify(0.05, 0.10, 0.0, false);
assert!(h.is_none(), "nominal operation should produce no hypothesis");
}
}
#[test]
fn classifier_detects_persistent() {
let mut clf = DisturbanceClassifier::default_rf();
let mut got_persistent = false;
for i in 0..15 {
if let Some(h) = clf.classify(0.12, 0.10, 0.002, false) {
if matches!(h.disturbance, RfDisturbance::PersistentElevated { .. }) {
got_persistent = true;
let _ = i;
break;
}
}
}
assert!(got_persistent, "persistent elevated disturbance not detected");
}
#[test]
fn classifier_detects_impulsive() {
let mut clf = DisturbanceClassifier::default_rf();
let h = clf.classify(0.50, 0.10, 0.0, false);
assert!(h.is_some(), "large spike should produce a hypothesis");
if let Some(hyp) = h {
assert!(
matches!(hyp.disturbance, RfDisturbance::Impulsive { .. }),
"large spike should be Impulsive, got {}", hyp.disturbance.class_label()
);
}
}
}