dsfb_rf/disturbance.rs
1//! RF disturbance taxonomy: classification and envelope compatibility.
2//!
3//! ## Theoretical basis
4//!
5//! The DSFB-DDMF framework (de Beer 2026, Deterministic Disturbance
6//! Modelling Framework) establishes a taxonomy of disturbance types that
7//! affect residual norms in a predictable, classifiable way. This module
8//! adapts that taxonomy to the specific context of RF receivers, mapping
9//! each DDMF class to its RF physical mechanism.
10//!
11//! The taxonomy is **not probabilistic**: the parameters describe
12//! *worst-case bounds* on disturbance magnitude and rate, not distribution
13//! parameters. This is what makes the resulting envelope bounds GUM-traceable
14//! and deterministic under the DSFB framework.
15//!
16//! ## Taxonomy overview
17//!
18//! | Disturbance class | DDMF bound type | RF physical mechanism |
19//! |---|---|---|
20//! | PointwiseBounded | ‖d(k)‖ ≤ d_max all k | Thermal/Johnson–Nyquist noise, ADC dither |
21//! | Drift | ‖d(k)‖ ≤ b + s_max·k | LO frequency drift, PA thermal drift |
22//! | SlewRateBounded | ‖Δd(k)‖ ≤ s_max | Slow AGC transient, temperature ramp |
23//! | Impulsive | spike at specific time, bounded amplitude | Jamming onset, ESD, lightning near-field |
24//! | PersistentElevated | step change to sustained high level | CW interference, in-band carrier, co-site blocker |
25//!
26//! ## Design
27//!
28//! - `no_std`, `no_alloc`, zero `unsafe`
29//! - All types `Clone + Copy`
30//! - Optional `serde` feature for JSON serialisation into SigMF annotations
31//! - The `classify()` function provides a heuristic assignment from observed
32//! residual statistics (from the DSA score / grammar outputs)
33
34/// DDMF disturbance class with RF-specific parameters.
35///
36/// Each variant holds the worst-case bound parameters for that class.
37/// The parameter names mirror the DSFB-DDMF notation exactly for
38/// cross-reference traceability.
39#[derive(Debug, Clone, Copy, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub enum RfDisturbance {
42 /// Pointwise-bounded disturbance: ‖d(k)‖ ≤ d_max for all k.
43 ///
44 /// RF interpretation: thermal noise floor, ADC quantisation dither.
45 /// DDMF parameter: `d_max` in normalised residual norm units.
46 ///
47 /// The admissibility envelope radius is valid as calibrated when this
48 /// is the only active disturbance class (no additional envelope expansion
49 /// is required beyond the 3σ nominal margin).
50 PointwiseBounded {
51 /// Maximum instantaneous disturbance magnitude d_max.
52 d_max: f32,
53 },
54
55 /// Drift disturbance: ‖d(k)‖ ≤ b + s_max · k.
56 ///
57 /// RF interpretation: LO frequency drift (±n Hz / s), PA thermal drift,
58 /// slow aging of calibration state.
59 ///
60 /// DDMF parameters: b = initial offset, s_max = maximum drift slope.
61 /// Envelope action: envelope must be widened at rate s_max per sample
62 /// to remain a valid bound. Recommend `EnvelopeMode::Widening` from
63 /// `regime` module.
64 Drift {
65 /// Initial bias b (offset at k=0, normalised).
66 b: f32,
67 /// Maximum drift slope s_max (normalised units per sample).
68 s_max: f32,
69 },
70
71 /// Slew-rate-bounded disturbance: ‖d(k) − d(k−1)‖ ≤ s_max.
72 ///
73 /// RF interpretation: slow automatic gain control (AGC) transient,
74 /// temperature-driven gain variation, antenna pattern scan.
75 /// Bounds the *rate of change* rather than the absolute magnitude.
76 ///
77 /// Note: a SlewRateBounded disturbance can still have large accumulated
78 /// magnitude if sustained long enough; pair with a Drift bound for
79 /// long-duration validity.
80 SlewRateBounded {
81 /// Maximum per-sample change s_max (normalised).
82 s_max: f32,
83 },
84
85 /// Impulsive disturbance: a single large spike over a bounded window.
86 ///
87 /// RF interpretation: jamming onset pulse, near-field EMP, ESD event,
88 /// radar pulse (non-self) cross-coupling, lightning discharge.
89 ///
90 /// The DSFB grammar layer naturally handles these via the
91 /// `AbruptSlewViolation` reason code. The `Impulsive` class provides
92 /// the adversary model that bounds the spike amplitude and duration.
93 Impulsive {
94 /// Peak amplitude A (normalised residual norm units).
95 amplitude: f32,
96 /// Onset sample index (samples since epoch, wrapping).
97 start_sample: u32,
98 /// Duration in samples (window during which amplitude ≤ A).
99 duration_samples: u32,
100 },
101
102 /// Persistent elevated disturbance: step to sustained elevated residual.
103 ///
104 /// RF interpretation: continuous-wave (CW) in-band interference,
105 /// broadband noise jammer, co-site RF blocker, transmitter failure
106 /// in radiating mode.
107 ///
108 /// This is the most operationally significant class for SIGINT / EW
109 /// applications because a persistent elevated residual is often
110 /// indistinguishable from a modulation change without the DSFB framework.
111 PersistentElevated {
112 /// Nominal (pre-step) residual norm level r_nom.
113 r_nominal: f32,
114 /// Elevated (post-step) residual norm level r_high.
115 r_elevated: f32,
116 /// Sample at which the step occurred.
117 step_sample: u32,
118 },
119}
120
121impl RfDisturbance {
122 /// Return the DDMF class label string for provenance annotation.
123 pub fn class_label(&self) -> &'static str {
124 match self {
125 Self::PointwiseBounded { .. } => "PointwiseBounded",
126 Self::Drift { .. } => "Drift",
127 Self::SlewRateBounded { .. } => "SlewRateBounded",
128 Self::Impulsive { .. } => "Impulsive",
129 Self::PersistentElevated { .. } => "PersistentElevated",
130 }
131 }
132
133 /// Upper bound on the instantaneous disturbance magnitude at sample k.
134 ///
135 /// Returns `Some(bound)` for classes where a finite bound exists.
136 /// Returns `None` for `Impulsive` outside its active window (no bound
137 /// outside the window, and inside the window it is `amplitude`).
138 pub fn magnitude_bound(&self, k: u32) -> Option<f32> {
139 match self {
140 Self::PointwiseBounded { d_max } => Some(*d_max),
141 Self::Drift { b, s_max } => Some(b + s_max * k as f32),
142 Self::SlewRateBounded { .. } => None, // bounds rate, not magnitude
143 Self::Impulsive { amplitude, start_sample, duration_samples } => {
144 let end = start_sample.wrapping_add(*duration_samples);
145 if k >= *start_sample && k < end {
146 Some(*amplitude)
147 } else {
148 Some(0.0) // outside window: negligible
149 }
150 }
151 Self::PersistentElevated { r_elevated, step_sample, .. } => {
152 if k >= *step_sample {
153 Some(*r_elevated)
154 } else {
155 None
156 }
157 }
158 }
159 }
160
161 /// Returns true if this disturbance requires envelope adaptation.
162 ///
163 /// `PointwiseBounded` and `SlewRateBounded` (bounded change rate) do
164 /// not require the envelope to widen over time. `Drift` and
165 /// `PersistentElevated` do.
166 pub fn requires_envelope_adaptation(&self) -> bool {
167 matches!(
168 self,
169 Self::Drift { .. } | Self::PersistentElevated { .. }
170 )
171 }
172
173 /// Recommended `EnvelopeMode` from the `regime` module for this disturbance.
174 pub fn recommended_envelope_mode_label(&self) -> &'static str {
175 match self {
176 Self::PointwiseBounded { .. } => "Fixed",
177 Self::Drift { .. } => "Widening",
178 Self::SlewRateBounded { .. } => "Fixed", // bounded-rate, no net trend
179 Self::Impulsive { .. } => "Fixed", // brief; grammar handles it
180 Self::PersistentElevated { .. } => "RegimeSwitched", // snap to new level
181 }
182 }
183}
184
185/// A fixed-capacity log of active disturbance hypotheses.
186///
187/// The DSFB observer does not create disturbances; it classifies the
188/// residual trajectories it observes into candidate disturbance types.
189/// This log accumulates those hypotheses for the operator advisory.
190///
191/// `N` = maximum number of simultaneous hypotheses (default: 4).
192/// Older entries are overwritten when the log is full (oldest-first ring).
193#[derive(Debug, Clone)]
194pub struct DisturbanceLog<const N: usize> {
195 entries: [Option<DisturbanceHypothesis>; N],
196 head: usize,
197 count: usize,
198}
199
200/// A single disturbance hypothesis entry.
201#[derive(Debug, Clone, Copy)]
202#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
203pub struct DisturbanceHypothesis {
204 /// Classified disturbance type.
205 pub disturbance: RfDisturbance,
206 /// Sample index at which this hypothesis was created.
207 pub created_at: u32,
208 /// Confidence score [0, 1] — heuristic, not probabilistic.
209 ///
210 /// Derived from how well the observed residual trajectory matches the
211 /// predicted trajectory under this disturbance model.
212 pub confidence: f32,
213 /// Whether this hypothesis has been corroborated by the DSA score.
214 pub dsa_corroborated: bool,
215}
216
217impl<const N: usize> DisturbanceLog<N> {
218 /// Create an empty log.
219 pub const fn new() -> Self {
220 Self {
221 entries: [None; N],
222 head: 0,
223 count: 0,
224 }
225 }
226
227 /// Record a new disturbance hypothesis.
228 pub fn push(&mut self, hyp: DisturbanceHypothesis) {
229 self.entries[self.head] = Some(hyp);
230 self.head = (self.head + 1) % N;
231 if self.count < N { self.count += 1; }
232 }
233
234 /// Iterate over all current hypotheses (oldest first).
235 pub fn iter(&self) -> impl Iterator<Item = &DisturbanceHypothesis> {
236 self.entries.iter().filter_map(|e| e.as_ref())
237 }
238
239 /// Number of recorded hypotheses.
240 pub fn len(&self) -> usize { self.count }
241
242 /// True if the log is empty.
243 pub fn is_empty(&self) -> bool { self.count == 0 }
244
245 /// Most confident hypothesis.
246 pub fn most_confident(&self) -> Option<&DisturbanceHypothesis> {
247 self.iter().max_by(|a, b| {
248 a.confidence.partial_cmp(&b.confidence).unwrap_or(core::cmp::Ordering::Equal)
249 })
250 }
251
252 /// Clear all entries.
253 pub fn clear(&mut self) {
254 self.entries = [None; N];
255 self.head = 0;
256 self.count = 0;
257 }
258}
259
260impl<const N: usize> Default for DisturbanceLog<N> {
261 fn default() -> Self { Self::new() }
262}
263
264/// Heuristic disturbance classifier.
265///
266/// Given observable quantities from the grammar/DSA/Lyapunov pipeline,
267/// produces a candidate `RfDisturbance` hypothesis with a confidence score.
268///
269/// This is a **structural** classifier — it operates on the *shape* of the
270/// residual trajectory, not on modulation features. It is therefore
271/// modulation-agnostic by construction.
272///
273/// ## Decision rules
274///
275/// The rules are derived from the DDMF disturbance model signatures:
276///
277/// | Observation | Likely disturbance |
278/// |---|---|
279/// | Large λ + sustained outward drift | Drift |
280/// | Abrupt step in ‖r‖ with sustained elevation | PersistentElevated |
281/// | Single spike above ρ then return | Impulsive |
282/// | Slowly increasing ‖r̈‖ trend | SlewRateBounded |
283/// | Stationary bounded noise | PointwiseBounded |
284pub struct DisturbanceClassifier {
285 /// Threshold: normalised-excess (‖r‖−ρ)/ρ above which a sample is "notably outside."
286 pub excess_threshold: f32,
287 /// Minimum consecutive samples above threshold to classify as PersistentElevated.
288 pub persistence_min: u32,
289 /// Lyapunov λ threshold below which Drift is not inferred.
290 pub drift_lambda_min: f32,
291 /// Running consecutive outside count.
292 consecutive_outside: u32,
293 /// Previous norm (for slew estimation).
294 prev_norm: f32,
295 /// Whether a previous norm has been observed (skips slew check on first call).
296 has_prev: bool,
297 /// Current sample index.
298 sample_idx: u32,
299}
300
301impl DisturbanceClassifier {
302 /// Construct with default RF thresholds.
303 pub const fn default_rf() -> Self {
304 Self {
305 excess_threshold: 0.05,
306 persistence_min: 8,
307 drift_lambda_min: 0.005,
308 consecutive_outside: 0,
309 prev_norm: 0.0,
310 has_prev: false,
311 sample_idx: 0,
312 }
313 }
314
315 /// Classify one observation.
316 ///
317 /// - `norm`: current ‖r(k)‖
318 /// - `rho`: admissibility envelope radius
319 /// - `lambda`: Lyapunov exponent from `lyapunov` module (pass 0.0 if unknown)
320 /// - `dsa_fired`: whether the DSA motif-fired flag is active
321 ///
322 /// Returns `Some(DisturbanceHypothesis)` when a classification is made;
323 /// `None` during nominal operation.
324 pub fn classify(
325 &mut self,
326 norm: f32,
327 rho: f32,
328 lambda: f32,
329 dsa_fired: bool,
330 ) -> Option<DisturbanceHypothesis> {
331 let k = self.sample_idx;
332 self.sample_idx = self.sample_idx.wrapping_add(1);
333
334 let normalised_excess = if rho > 1e-30 { (norm - rho) / rho } else { 0.0 };
335 let outside = normalised_excess > 0.0;
336 let delta_norm = if self.has_prev { (norm - self.prev_norm).abs() } else { 0.0 };
337 self.prev_norm = norm;
338 self.has_prev = true;
339
340 self.update_persistence(outside, normalised_excess);
341
342 let disturbance = self.select_disturbance(norm, rho, lambda, normalised_excess, outside, delta_norm, k)?;
343 let confidence = self.compute_confidence(&disturbance, lambda);
344
345 Some(DisturbanceHypothesis {
346 disturbance,
347 created_at: k,
348 confidence,
349 dsa_corroborated: dsa_fired,
350 })
351 }
352
353 fn update_persistence(&mut self, outside: bool, normalised_excess: f32) {
354 if outside && normalised_excess > self.excess_threshold {
355 self.consecutive_outside = self.consecutive_outside.saturating_add(1);
356 } else {
357 self.consecutive_outside = 0;
358 }
359 }
360
361 fn select_disturbance(
362 &self,
363 norm: f32,
364 rho: f32,
365 lambda: f32,
366 normalised_excess: f32,
367 outside: bool,
368 delta_norm: f32,
369 k: u32,
370 ) -> Option<RfDisturbance> {
371 if outside && self.consecutive_outside >= self.persistence_min && normalised_excess < 0.5 {
372 return Some(RfDisturbance::PersistentElevated {
373 r_nominal: rho,
374 r_elevated: norm,
375 step_sample: k.saturating_sub(self.consecutive_outside),
376 });
377 }
378 if lambda > self.drift_lambda_min && outside {
379 return Some(RfDisturbance::Drift {
380 b: normalised_excess * rho,
381 s_max: lambda * rho,
382 });
383 }
384 if outside && self.consecutive_outside == 1 && normalised_excess > 0.20 {
385 return Some(RfDisturbance::Impulsive {
386 amplitude: norm,
387 start_sample: k,
388 duration_samples: 1,
389 });
390 }
391 if delta_norm > 0.02 * rho && !outside {
392 return Some(RfDisturbance::SlewRateBounded { s_max: delta_norm });
393 }
394 if !outside { return None; }
395 Some(RfDisturbance::PointwiseBounded { d_max: norm })
396 }
397
398 fn compute_confidence(&self, disturbance: &RfDisturbance, lambda: f32) -> f32 {
399 match disturbance {
400 RfDisturbance::PersistentElevated { .. } => {
401 (self.consecutive_outside as f32 / self.persistence_min as f32).min(1.0)
402 }
403 RfDisturbance::Drift { .. } => (lambda / (self.drift_lambda_min * 5.0)).min(1.0),
404 RfDisturbance::Impulsive { .. } => 0.5,
405 RfDisturbance::SlewRateBounded { .. } => 0.3,
406 RfDisturbance::PointwiseBounded { .. } => 0.4,
407 }
408 }
409
410 /// Reset internal state.
411 pub fn reset(&mut self) {
412 self.consecutive_outside = 0;
413 self.prev_norm = 0.0;
414 self.has_prev = false;
415 self.sample_idx = 0;
416 }
417}
418
419// ---------------------------------------------------------------
420// Tests
421// ---------------------------------------------------------------
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn class_labels_canonical() {
428 assert_eq!(
429 RfDisturbance::PointwiseBounded { d_max: 0.1 }.class_label(),
430 "PointwiseBounded"
431 );
432 assert_eq!(
433 RfDisturbance::Drift { b: 0.0, s_max: 0.001 }.class_label(),
434 "Drift"
435 );
436 assert_eq!(
437 RfDisturbance::SlewRateBounded { s_max: 0.005 }.class_label(),
438 "SlewRateBounded"
439 );
440 assert_eq!(
441 RfDisturbance::Impulsive { amplitude: 0.5, start_sample: 10, duration_samples: 3 }.class_label(),
442 "Impulsive"
443 );
444 assert_eq!(
445 RfDisturbance::PersistentElevated { r_nominal: 0.05, r_elevated: 0.20, step_sample: 50 }.class_label(),
446 "PersistentElevated"
447 );
448 }
449
450 #[test]
451 fn drift_magnitude_bound_grows() {
452 let d = RfDisturbance::Drift { b: 0.01, s_max: 0.001 };
453 let bound0 = d.magnitude_bound(0).unwrap();
454 let bound100 = d.magnitude_bound(100).unwrap();
455 assert!(bound100 > bound0, "drift bound must grow with k");
456 assert!((bound100 - 0.11).abs() < 1e-5, "bound100={}", bound100);
457 }
458
459 #[test]
460 fn impulsive_bound_outside_window_zero() {
461 let d = RfDisturbance::Impulsive { amplitude: 2.0, start_sample: 10, duration_samples: 5 };
462 // Outside window
463 let before = d.magnitude_bound(9).unwrap();
464 let after = d.magnitude_bound(15).unwrap();
465 assert_eq!(before, 0.0);
466 assert_eq!(after, 0.0);
467 // Inside window
468 let inside = d.magnitude_bound(12).unwrap();
469 assert_eq!(inside, 2.0);
470 }
471
472 #[test]
473 fn persistent_elevated_bound_after_step() {
474 let d = RfDisturbance::PersistentElevated { r_nominal: 0.05, r_elevated: 0.20, step_sample: 20 };
475 assert!(d.magnitude_bound(19).is_none(), "before step: no bound");
476 let after = d.magnitude_bound(20).unwrap();
477 assert!((after - 0.20).abs() < 1e-6);
478 }
479
480 #[test]
481 fn envelope_adaptation_flags() {
482 assert!(!RfDisturbance::PointwiseBounded { d_max: 0.1 }.requires_envelope_adaptation());
483 assert!(RfDisturbance::Drift { b: 0.0, s_max: 0.001 }.requires_envelope_adaptation());
484 assert!(!RfDisturbance::SlewRateBounded { s_max: 0.005 }.requires_envelope_adaptation());
485 assert!(RfDisturbance::PersistentElevated {
486 r_nominal: 0.05, r_elevated: 0.20, step_sample: 0
487 }.requires_envelope_adaptation());
488 }
489
490 #[test]
491 fn disturbance_log_push_and_most_confident() {
492 let mut log = DisturbanceLog::<4>::new();
493 assert!(log.is_empty());
494
495 log.push(DisturbanceHypothesis {
496 disturbance: RfDisturbance::PointwiseBounded { d_max: 0.1 },
497 created_at: 0,
498 confidence: 0.4,
499 dsa_corroborated: false,
500 });
501 log.push(DisturbanceHypothesis {
502 disturbance: RfDisturbance::Drift { b: 0.01, s_max: 0.001 },
503 created_at: 5,
504 confidence: 0.8,
505 dsa_corroborated: true,
506 });
507
508 assert_eq!(log.len(), 2);
509 let best = log.most_confident().unwrap();
510 assert!(
511 (best.confidence - 0.8).abs() < 1e-6,
512 "most confident should be the Drift entry"
513 );
514 }
515
516 #[test]
517 fn disturbance_log_ring_behaviour() {
518 let mut log = DisturbanceLog::<2>::new();
519 for i in 0..5_u32 {
520 log.push(DisturbanceHypothesis {
521 disturbance: RfDisturbance::PointwiseBounded { d_max: i as f32 * 0.01 },
522 created_at: i,
523 confidence: 0.5,
524 dsa_corroborated: false,
525 });
526 }
527 // Ring size 2: only 2 entries should be visible
528 assert_eq!(log.len(), 2);
529 }
530
531 #[test]
532 fn classifier_nominal_returns_none() {
533 let mut clf = DisturbanceClassifier::default_rf();
534 // Deep inside envelope — should return None
535 for _ in 0..20 {
536 let h = clf.classify(0.05, 0.10, 0.0, false);
537 assert!(h.is_none(), "nominal operation should produce no hypothesis");
538 }
539 }
540
541 #[test]
542 fn classifier_detects_persistent() {
543 let mut clf = DisturbanceClassifier::default_rf();
544 // 10 samples consistently outside envelope
545 let mut got_persistent = false;
546 for i in 0..15 {
547 if let Some(h) = clf.classify(0.12, 0.10, 0.002, false) {
548 if matches!(h.disturbance, RfDisturbance::PersistentElevated { .. }) {
549 got_persistent = true;
550 let _ = i;
551 break;
552 }
553 }
554 }
555 assert!(got_persistent, "persistent elevated disturbance not detected");
556 }
557
558 #[test]
559 fn classifier_detects_impulsive() {
560 let mut clf = DisturbanceClassifier::default_rf();
561 // Single large spike
562 let h = clf.classify(0.50, 0.10, 0.0, false);
563 assert!(h.is_some(), "large spike should produce a hypothesis");
564 if let Some(hyp) = h {
565 assert!(
566 matches!(hyp.disturbance, RfDisturbance::Impulsive { .. }),
567 "large spike should be Impulsive, got {}", hyp.disturbance.class_label()
568 );
569 }
570 }
571}