#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vrt49Context {
pub gain_db: f32,
pub temperature_c: f32,
pub rf_ref_freq_hz: f64,
pub timestamp_int_sec: u32,
pub timestamp_frac_ps: u64,
pub bandwidth_hz: f32,
pub sample_rate_sps: f64,
}
impl Vrt49Context {
pub const fn unknown() -> Self {
Self {
gain_db: 0.0,
temperature_c: f32::NAN,
rf_ref_freq_hz: 0.0,
timestamp_int_sec: 0,
timestamp_frac_ps: 0,
bandwidth_hz: 0.0,
sample_rate_sps: 0.0,
}
}
#[inline]
pub fn has_temperature(&self) -> bool {
!self.temperature_c.is_nan()
}
#[inline]
pub fn has_rf_freq(&self) -> bool {
self.rf_ref_freq_hz > 0.0
}
}
impl Default for Vrt49Context {
fn default() -> Self { Self::unknown() }
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SigmfAnnotation {
#[cfg_attr(feature = "serde", serde(rename = "core:sample_start"))]
pub sample_start: u64,
#[cfg_attr(feature = "serde", serde(rename = "core:sample_count"))]
pub sample_count: u64,
#[cfg_attr(feature = "serde", serde(rename = "core:label"))]
pub label: &'static str,
#[cfg_attr(feature = "serde", serde(rename = "core:comment"))]
pub comment: &'static str,
#[cfg_attr(feature = "serde", serde(rename = "dsfb:motif_class"))]
pub motif_class: &'static str,
#[cfg_attr(feature = "serde", serde(rename = "dsfb:dsa_score"))]
pub dsa_score: f32,
#[cfg_attr(feature = "serde", serde(rename = "dsfb:lyapunov_lambda"))]
pub lyapunov_lambda: f32,
#[cfg_attr(feature = "serde", serde(rename = "dsfb:policy_decision"))]
pub policy_decision: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpectralMaskPoint {
pub freq_hz: f64,
pub limit_db: f32,
}
pub struct SpectralMask<const N: usize> {
points: [SpectralMaskPoint; N],
count: usize,
pub name: &'static str,
}
impl<const N: usize> SpectralMask<N> {
pub const fn empty(name: &'static str) -> Self {
Self {
points: [SpectralMaskPoint { freq_hz: 0.0, limit_db: 0.0 }; N],
count: 0,
name,
}
}
pub fn add_point(&mut self, freq_hz: f64, limit_db: f32) -> bool {
if self.count >= N { return false; }
self.points[self.count] = SpectralMaskPoint { freq_hz, limit_db };
self.count += 1;
true
}
pub fn limit_at(&self, freq_hz: f64) -> Option<f32> {
if self.count < 2 { return None; }
let pts = &self.points[..self.count];
if freq_hz < pts[0].freq_hz || freq_hz > pts[self.count - 1].freq_hz {
return None;
}
for i in 0..self.count - 1 {
if freq_hz >= pts[i].freq_hz && freq_hz <= pts[i + 1].freq_hz {
let frac = ((freq_hz - pts[i].freq_hz) / (pts[i + 1].freq_hz - pts[i].freq_hz)) as f32;
return Some(pts[i].limit_db + frac * (pts[i + 1].limit_db - pts[i].limit_db));
}
}
None
}
#[inline]
pub fn len(&self) -> usize { self.count }
#[inline]
pub fn is_empty(&self) -> bool { self.count == 0 }
#[inline]
pub fn deviation(&self, freq_hz: f64, measured_db: f32) -> Option<f32> {
self.limit_at(freq_hz).map(|limit| measured_db - limit)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vrt_context_default() {
let ctx = Vrt49Context::unknown();
assert!(!ctx.has_temperature());
assert!(!ctx.has_rf_freq());
}
#[test]
fn vrt_context_with_values() {
let ctx = Vrt49Context {
gain_db: 30.0,
temperature_c: 45.5,
rf_ref_freq_hz: 2.4e9,
timestamp_int_sec: 1700000000,
timestamp_frac_ps: 500_000_000_000,
bandwidth_hz: 20e6,
sample_rate_sps: 61.44e6,
};
assert!(ctx.has_temperature());
assert!(ctx.has_rf_freq());
}
#[test]
fn spectral_mask_interpolation() {
let mut mask = SpectralMask::<4>::empty("test_mask");
mask.add_point(100e6, -40.0);
mask.add_point(200e6, -30.0);
mask.add_point(300e6, -50.0);
let limit = mask.limit_at(150e6).unwrap();
assert!((limit - (-35.0)).abs() < 0.1, "midpoint interpolation: {}", limit);
assert!(mask.limit_at(50e6).is_none(), "below range");
assert!(mask.limit_at(400e6).is_none(), "above range");
}
#[test]
fn spectral_mask_deviation() {
let mut mask = SpectralMask::<4>::empty("test");
mask.add_point(100e6, -30.0);
mask.add_point(200e6, -30.0);
let dev = mask.deviation(150e6, -40.0).unwrap();
assert!(dev < 0.0, "below mask must be negative deviation: {}", dev);
let dev2 = mask.deviation(150e6, -20.0).unwrap();
assert!(dev2 > 0.0, "above mask must be positive deviation: {}", dev2);
}
#[test]
fn mask_capacity_enforced() {
let mut mask = SpectralMask::<2>::empty("tiny");
assert!(mask.add_point(100.0, -10.0));
assert!(mask.add_point(200.0, -20.0));
assert!(!mask.add_point(300.0, -30.0), "must reject when full");
assert_eq!(mask.len(), 2);
}
#[test]
fn sigmf_annotation_fields() {
let ann = SigmfAnnotation {
sample_start: 1000,
sample_count: 500,
label: "Boundary[SustainedOutwardDrift]",
comment: "PA thermal drift detected",
motif_class: "PreFailureSlowDrift",
dsa_score: 2.5,
lyapunov_lambda: 0.015,
policy_decision: "Review",
};
assert_eq!(ann.sample_start, 1000);
assert_eq!(ann.label, "Boundary[SustainedOutwardDrift]");
}
}