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}