Skip to main content

dsfb_rf/
standards.rs

1//! Industry standards integration: VITA 49.2, SigMF, SOSA/MORA.
2//!
3//! ## VITA 49.2 (VITA Radio Transport — VRT)
4//!
5//! VITA 49.2 defines the packet format for digitized IF/RF data transport,
6//! including sub-nanosecond timestamping and hardware context packets
7//! (gain, temperature, frequency, sample rate). DSFB consumes VRT context
8//! to enrich its platform context with hardware-level metadata.
9//!
10//! The `Vrt49Context` struct captures the fields DSFB needs from VRT
11//! context packets — gain, temperature, center frequency, and timestamp.
12//! This enables the heuristics bank to distinguish gain-drift from thermal
13//! drift from frequency offset drift at the hardware metadata level.
14//!
15//! ## SigMF (Signal Metadata Format)
16//!
17//! SigMF provides a standardized JSON schema for annotating IQ recordings.
18//! DSFB episodes are exported as SigMF annotations, enabling instant
19//! visualization in tools like IQEngine, inspectrum, and Universal Radio Hacker.
20//!
21//! Each Review/Escalate episode maps to a SigMF annotation with:
22//! - `core:sample_start` / `core:sample_count`
23//! - `core:label` = grammar state (e.g., "Boundary[SustainedOutwardDrift]")
24//! - `dsfb:motif`, `dsfb:dsa_score`, `dsfb:lyapunov_lambda`
25//!
26//! ## SOSA / MORA Alignment
27//!
28//! The Sensor Open Systems Architecture (SOSA™) and Modular Open RF
29//! Architecture (MORA) mandate software-defined, vendor-neutral RF
30//! processing components. DSFB is positioned as a MORA-compliant
31//! Software Resource:
32//! - Stateless observer with well-defined input/output interfaces
33//! - No vendor-specific hardware dependencies in the core engine
34//! - Deployable as a SOSA-aligned processing element alongside
35//!   existing signal processing chains
36//!
37//! ## Design
38//!
39//! - Core structs: `no_std`, `no_alloc`, zero `unsafe`
40//! - SigMF export: requires `serde` feature (JSON serialization)
41//! - VRT context consumption: `no_std` compatible (struct population only)
42
43// ── VITA 49.2 VRT Context ──────────────────────────────────────────────────
44
45/// Hardware context from a VITA 49.2 (VRT) context packet.
46///
47/// Populated by the integration layer from VRT context extension packets.
48/// The DSFB engine reads these fields but never writes VRT packets.
49///
50/// ## VRT Field Mapping
51///
52/// | VRT Field           | DSFB Usage                                      |
53/// |---------------------|-------------------------------------------------|
54/// | Reference Level     | Maps to admissibility envelope scaling           |
55/// | Gain                | Distinguishes AGC drift from signal-level drift  |
56/// | Temperature         | Correlates thermal drift with PA thermal motif   |
57/// | RF Reference Freq   | Detects LO offset / frequency drift              |
58/// | Timestamp (picosec) | Sub-nanosecond event timestamping                |
59/// | Bandwidth           | Contextualizes spectral mask width               |
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Vrt49Context {
62    /// Receiver gain in dB (from VRT Gain field, CIF 0 word).
63    pub gain_db: f32,
64    /// Device temperature in °C (from VRT Temperature field).
65    /// `f32::NAN` if not available.
66    pub temperature_c: f32,
67    /// RF reference frequency in Hz (from VRT RF Reference Frequency field).
68    pub rf_ref_freq_hz: f64,
69    /// Integer-seconds timestamp (from VRT Integer-Seconds Timestamp).
70    pub timestamp_int_sec: u32,
71    /// Fractional-seconds timestamp in picoseconds (from VRT Fractional Timestamp).
72    pub timestamp_frac_ps: u64,
73    /// Bandwidth in Hz (from VRT Bandwidth field).
74    pub bandwidth_hz: f32,
75    /// Sample rate in samples/sec (from VRT Sample Rate field).
76    pub sample_rate_sps: f64,
77}
78
79impl Vrt49Context {
80    /// Create a context with unknown/default values.
81    pub const fn unknown() -> Self {
82        Self {
83            gain_db: 0.0,
84            temperature_c: f32::NAN,
85            rf_ref_freq_hz: 0.0,
86            timestamp_int_sec: 0,
87            timestamp_frac_ps: 0,
88            bandwidth_hz: 0.0,
89            sample_rate_sps: 0.0,
90        }
91    }
92
93    /// Returns true if a valid temperature reading is available.
94    #[inline]
95    pub fn has_temperature(&self) -> bool {
96        !self.temperature_c.is_nan()
97    }
98
99    /// Returns true if a valid RF reference frequency is set.
100    #[inline]
101    pub fn has_rf_freq(&self) -> bool {
102        self.rf_ref_freq_hz > 0.0
103    }
104}
105
106impl Default for Vrt49Context {
107    fn default() -> Self { Self::unknown() }
108}
109
110// ── SigMF Annotation ──────────────────────────────────────────────────────
111
112/// A DSFB episode exported as a SigMF-compatible annotation.
113///
114/// Conforms to the SigMF `core` namespace plus DSFB extension fields.
115/// Serializable to JSON via `serde` for direct insertion into a
116/// `.sigmf-meta` file's `annotations` array.
117#[derive(Debug, Clone)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct SigmfAnnotation {
120    /// `core:sample_start` — first sample index of the episode.
121    #[cfg_attr(feature = "serde", serde(rename = "core:sample_start"))]
122    pub sample_start: u64,
123    /// `core:sample_count` — duration of the episode in samples.
124    #[cfg_attr(feature = "serde", serde(rename = "core:sample_count"))]
125    pub sample_count: u64,
126    /// `core:label` — grammar state label.
127    #[cfg_attr(feature = "serde", serde(rename = "core:label"))]
128    pub label: &'static str,
129    /// `core:comment` — human-readable episode summary.
130    #[cfg_attr(feature = "serde", serde(rename = "core:comment"))]
131    pub comment: &'static str,
132    /// `dsfb:motif_class` — named temporal motif.
133    #[cfg_attr(feature = "serde", serde(rename = "dsfb:motif_class"))]
134    pub motif_class: &'static str,
135    /// `dsfb:dsa_score` — Deterministic Structural Accumulator score.
136    #[cfg_attr(feature = "serde", serde(rename = "dsfb:dsa_score"))]
137    pub dsa_score: f32,
138    /// `dsfb:lyapunov_lambda` — finite-time Lyapunov exponent.
139    #[cfg_attr(feature = "serde", serde(rename = "dsfb:lyapunov_lambda"))]
140    pub lyapunov_lambda: f32,
141    /// `dsfb:policy_decision` — Silent/Watch/Review/Escalate.
142    #[cfg_attr(feature = "serde", serde(rename = "dsfb:policy_decision"))]
143    pub policy_decision: &'static str,
144}
145
146// ── MIL-STD-461G Spectral Mask Envelope ────────────────────────────────────
147
148/// A spectral emission mask point for MIL-STD-461G RE102/CE102 or
149/// ITU-R SM.1048-5 §4.3 mask-deviation tracking.
150///
151/// The DSFB spectral mask deviation residual uses these points as
152/// the outer admissibility boundary. Structural monitoring tracks
153/// whether measured PSD is drifting toward the mask boundary.
154#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct SpectralMaskPoint {
156    /// Frequency in Hz.
157    pub freq_hz: f64,
158    /// Maximum allowable power spectral density in dBm/MHz (or dBμV/m for RE102).
159    pub limit_db: f32,
160}
161
162/// A piecewise-linear spectral emission mask.
163///
164/// Fixed-capacity array of mask points, sorted by frequency.
165/// Supports MIL-STD-461G RE102 (2 MHz – 18 GHz), CE102 (10 kHz – 10 MHz),
166/// 3GPP TS 36.141 §6.3 ACLR, and ITU-R SM.1048-5 masks.
167pub struct SpectralMask<const N: usize> {
168    /// Mask points sorted by frequency.
169    points: [SpectralMaskPoint; N],
170    /// Number of valid points.
171    count: usize,
172    /// Mask identifier (e.g., "RE102_ground", "CE102", "ACLR_E-UTRA").
173    pub name: &'static str,
174}
175
176impl<const N: usize> SpectralMask<N> {
177    /// Create an empty mask.
178    pub const fn empty(name: &'static str) -> Self {
179        Self {
180            points: [SpectralMaskPoint { freq_hz: 0.0, limit_db: 0.0 }; N],
181            count: 0,
182            name,
183        }
184    }
185
186    /// Add a point. Returns false if mask is full.
187    pub fn add_point(&mut self, freq_hz: f64, limit_db: f32) -> bool {
188        if self.count >= N { return false; }
189        self.points[self.count] = SpectralMaskPoint { freq_hz, limit_db };
190        self.count += 1;
191        true
192    }
193
194    /// Interpolate the mask limit at a given frequency.
195    ///
196    /// Returns `None` if the frequency is outside the mask range.
197    /// Uses linear interpolation between adjacent points.
198    pub fn limit_at(&self, freq_hz: f64) -> Option<f32> {
199        if self.count < 2 { return None; }
200        let pts = &self.points[..self.count];
201
202        if freq_hz < pts[0].freq_hz || freq_hz > pts[self.count - 1].freq_hz {
203            return None;
204        }
205
206        for i in 0..self.count - 1 {
207            if freq_hz >= pts[i].freq_hz && freq_hz <= pts[i + 1].freq_hz {
208                let frac = ((freq_hz - pts[i].freq_hz) / (pts[i + 1].freq_hz - pts[i].freq_hz)) as f32;
209                return Some(pts[i].limit_db + frac * (pts[i + 1].limit_db - pts[i].limit_db));
210            }
211        }
212        None
213    }
214
215    /// Number of mask points.
216    #[inline]
217    pub fn len(&self) -> usize { self.count }
218
219    /// Whether the mask is empty.
220    #[inline]
221    pub fn is_empty(&self) -> bool { self.count == 0 }
222
223    /// Compute the mask deviation residual: measured_db − limit_db.
224    ///
225    /// Positive values indicate the measurement exceeds the mask (violation).
226    /// Negative values indicate margin remains.
227    #[inline]
228    pub fn deviation(&self, freq_hz: f64, measured_db: f32) -> Option<f32> {
229        self.limit_at(freq_hz).map(|limit| measured_db - limit)
230    }
231}
232
233// ── Tests ──────────────────────────────────────────────────────────────────
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn vrt_context_default() {
241        let ctx = Vrt49Context::unknown();
242        assert!(!ctx.has_temperature());
243        assert!(!ctx.has_rf_freq());
244    }
245
246    #[test]
247    fn vrt_context_with_values() {
248        let ctx = Vrt49Context {
249            gain_db: 30.0,
250            temperature_c: 45.5,
251            rf_ref_freq_hz: 2.4e9,
252            timestamp_int_sec: 1700000000,
253            timestamp_frac_ps: 500_000_000_000,
254            bandwidth_hz: 20e6,
255            sample_rate_sps: 61.44e6,
256        };
257        assert!(ctx.has_temperature());
258        assert!(ctx.has_rf_freq());
259    }
260
261    #[test]
262    fn spectral_mask_interpolation() {
263        let mut mask = SpectralMask::<4>::empty("test_mask");
264        mask.add_point(100e6, -40.0);
265        mask.add_point(200e6, -30.0);
266        mask.add_point(300e6, -50.0);
267
268        let limit = mask.limit_at(150e6).unwrap();
269        assert!((limit - (-35.0)).abs() < 0.1, "midpoint interpolation: {}", limit);
270
271        assert!(mask.limit_at(50e6).is_none(), "below range");
272        assert!(mask.limit_at(400e6).is_none(), "above range");
273    }
274
275    #[test]
276    fn spectral_mask_deviation() {
277        let mut mask = SpectralMask::<4>::empty("test");
278        mask.add_point(100e6, -30.0);
279        mask.add_point(200e6, -30.0); // flat mask at -30 dBm
280
281        // Measurement below limit
282        let dev = mask.deviation(150e6, -40.0).unwrap();
283        assert!(dev < 0.0, "below mask must be negative deviation: {}", dev);
284
285        // Measurement above limit
286        let dev2 = mask.deviation(150e6, -20.0).unwrap();
287        assert!(dev2 > 0.0, "above mask must be positive deviation: {}", dev2);
288    }
289
290    #[test]
291    fn mask_capacity_enforced() {
292        let mut mask = SpectralMask::<2>::empty("tiny");
293        assert!(mask.add_point(100.0, -10.0));
294        assert!(mask.add_point(200.0, -20.0));
295        assert!(!mask.add_point(300.0, -30.0), "must reject when full");
296        assert_eq!(mask.len(), 2);
297    }
298
299    #[test]
300    fn sigmf_annotation_fields() {
301        let ann = SigmfAnnotation {
302            sample_start: 1000,
303            sample_count: 500,
304            label: "Boundary[SustainedOutwardDrift]",
305            comment: "PA thermal drift detected",
306            motif_class: "PreFailureSlowDrift",
307            dsa_score: 2.5,
308            lyapunov_lambda: 0.015,
309            policy_decision: "Review",
310        };
311        assert_eq!(ann.sample_start, 1000);
312        assert_eq!(ann.label, "Boundary[SustainedOutwardDrift]");
313    }
314}