Skip to main content

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}