use super::{stats, Baseline, MonitorEvent, MonitorReport, Series, Side, SpanDetector, Spectrum};
use crate::iq::Cf64;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct SpectralSettings {
pub block_s: f64,
pub baseline_s: f64,
pub nfft: usize,
pub overlap: f64,
pub excess_db: f64,
pub excess_pfa: f64,
pub kurtosis_sigma: f64,
pub pulse_t: f64,
pub pulse_pfa: f64,
}
impl Default for SpectralSettings {
fn default() -> Self {
SpectralSettings {
block_s: 0.05,
baseline_s: 1.0,
nfft: 256,
overlap: 0.5,
excess_db: 0.0,
excess_pfa: 1e-4,
kurtosis_sigma: 6.0,
pulse_t: 12.0,
pulse_pfa: 1e-4,
}
}
}
pub struct SpectralMonitor {
settings: SpectralSettings,
fs_hz: f64,
block_len: usize,
excess_threshold_db: f64,
block: Vec<Cf64>,
sample: u64,
baseline_blocks: usize,
seen_blocks: usize,
base_psd: Vec<f64>,
base_power: Baseline,
base_kurt: Baseline,
freq_hz: Vec<f64>,
reference: Option<(Vec<f64>, f64, f64, f64)>,
max_hold_db: Vec<f64>,
excess: Series,
excess_f: Series,
kurt: Series,
pulse: Series,
det_excess: Option<SpanDetector>,
det_kurt: Option<SpanDetector>,
det_pulse: Option<SpanDetector>,
error: Option<String>,
}
impl SpectralMonitor {
pub fn new(fs_hz: f64, settings: SpectralSettings) -> Self {
let block_len = ((settings.block_s * fs_hz).round() as usize).max(settings.nfft);
let baseline_blocks =
((settings.baseline_s / (block_len as f64 / fs_hz)).round() as usize).max(1);
let excess_threshold_db = if settings.excess_db > 0.0 {
settings.excess_db
} else {
let k_eff = Self::k_eff(&settings, block_len);
stats::welch_excess_threshold_db(k_eff, settings.nfft, settings.excess_pfa)
};
SpectralMonitor {
settings,
fs_hz,
block_len,
excess_threshold_db,
block: Vec::with_capacity(block_len),
sample: 0,
baseline_blocks,
seen_blocks: 0,
base_psd: Vec::new(),
base_power: Baseline::default(),
base_kurt: Baseline::default(),
freq_hz: Vec::new(),
reference: None,
max_hold_db: Vec::new(),
excess: Series::new("psd_excess_db", "dB", None),
excess_f: Series::new("psd_excess_freq_hz", "Hz", None),
kurt: Series::new("kurtosis", "1", None),
pulse: Series::new("pulse_fraction", "1", None),
det_excess: None,
det_kurt: None,
det_pulse: None,
error: None,
}
}
pub fn block_len(&self) -> usize {
self.block_len
}
pub fn excess_threshold_db(&self) -> f64 {
self.excess_threshold_db
}
fn k_eff(st: &SpectralSettings, block_len: usize) -> f64 {
let ov = st.overlap.clamp(0.0, 0.95);
let step = ((st.nfft as f64) * (1.0 - ov)).round().max(1.0) as usize;
let segments = if block_len >= st.nfft {
(block_len - st.nfft) / step + 1
} else {
1
};
stats::welch_equivalent_segments(st.nfft, step, segments)
}
pub fn excess_pfa_closed_form(&self) -> f64 {
stats::welch_max_excess_pfa(
Self::k_eff(&self.settings, self.block_len),
self.settings.nfft,
self.excess_threshold_db,
)
}
pub fn push(&mut self, x: &[Cf64]) {
for &s in x {
self.block.push(s);
self.sample += 1;
if self.block.len() == self.block_len {
let t = (self.sample as f64 - self.block_len as f64 / 2.0) / self.fs_hz;
let b = std::mem::take(&mut self.block);
self.process(t, &b);
self.block = b;
self.block.clear();
}
}
}
fn process(&mut self, t: f64, b: &[Cf64]) {
let n = b.len() as f64;
let (s2, s4) = b.iter().fold((0.0, 0.0), |(a2, a4), s| {
let p = super::norm_sqr(*s);
(a2 + p, a4 + p * p)
});
let power = s2 / n;
let kurt = if s2 > 0.0 {
(s4 / n) / (power * power)
} else {
0.0
};
let psd = match crate::spectrum::welch_psd(
b,
self.fs_hz,
self.settings.nfft,
self.settings.overlap,
) {
Ok(p) => p,
Err(e) => {
self.error = Some(e);
return;
}
};
self.kurt.push(t, kurt);
match &self.reference {
None => {
if self.base_psd.is_empty() {
self.base_psd = vec![0.0; psd.psd.len()];
self.freq_hz = psd.freq_hz.clone();
}
for (a, v) in self.base_psd.iter_mut().zip(&psd.psd) {
*a += v;
}
self.base_power.push(power);
self.base_kurt.push(kurt);
self.seen_blocks += 1;
if self.seen_blocks >= self.baseline_blocks {
let k = self.seen_blocks as f64;
let base: Vec<f64> = self.base_psd.iter().map(|v| v / k).collect();
let sd_k = stats::complex_kurtosis_sd(self.block_len).max(self.base_kurt.std());
self.reference =
Some((base, self.base_power.mean(), self.base_kurt.mean(), sd_k));
self.max_hold_db = vec![f64::NEG_INFINITY; self.freq_hz.len()];
let st = self.settings;
self.det_excess = Some(SpanDetector::new(
"spectral_excess",
None,
Side::Above,
self.excess_threshold_db,
1,
));
self.det_kurt = Some(SpanDetector::new(
"kurtosis",
None,
Side::Above,
st.kurtosis_sigma,
1,
));
self.det_pulse = Some(SpanDetector::new(
"pulses",
None,
Side::Above,
-st.pulse_pfa.ln(),
1,
));
}
}
Some((base, sigma2, k0, sd_k)) => {
let (mut best, mut best_f) = (f64::NEG_INFINITY, 0.0);
for (i, (v, r)) in psd.psd.iter().zip(base).enumerate() {
let db = 10.0 * (v / r.max(1e-300)).max(1e-300).log10();
if db > self.max_hold_db[i] {
self.max_hold_db[i] = db;
}
if db > best {
best = db;
best_f = psd.freq_hz[i];
}
}
self.excess.push(t, best);
self.excess_f.push(t, best_f);
let thr = self.settings.pulse_t * sigma2;
let count = b.iter().filter(|s| super::norm_sqr(**s) > thr).count() as f64;
self.pulse.push(t, count / n);
let p = stats::pulse_pfa(self.settings.pulse_t);
let tail = stats::binomial_upper_tail(b.len() as u64, p, count as u64);
let surprise = -tail.max(1e-300).ln();
let z_kurt = ((kurt - k0) / sd_k).abs();
if let Some(d) = self.det_excess.as_mut() {
d.push(t, best);
}
if let Some(d) = self.det_kurt.as_mut() {
d.push(t, z_kurt);
}
if let Some(d) = self.det_pulse.as_mut() {
d.push(t, surprise);
}
}
}
}
pub fn events(&self) -> Vec<MonitorEvent> {
let mut v = Vec::new();
for d in [&self.det_excess, &self.det_kurt, &self.det_pulse]
.into_iter()
.flatten()
{
v.extend(d.events());
}
v
}
pub fn report(&self) -> MonitorReport {
let mut spectra = Vec::new();
let mut notes = vec![
("spectral.block_samples".into(), self.block_len.to_string()),
("spectral.nfft".into(), self.settings.nfft.to_string()),
(
"spectral.excess_threshold_db".into(),
format!("{:.3}", self.excess_threshold_db),
),
(
"spectral.excess_pfa_per_block_white_noise".into(),
format!("{:.3e}", self.excess_pfa_closed_form()),
),
(
"spectral.pulse_pfa_per_sample_white_noise".into(),
format!("{:.3e}", stats::pulse_pfa(self.settings.pulse_t)),
),
];
if let Some((base, sigma2, k0, sd_k)) = &self.reference {
spectra.push(Spectrum {
name: "psd_baseline_db".into(),
freq_hz: self.freq_hz.clone(),
value_db: base.iter().map(|v| 10.0 * v.max(1e-300).log10()).collect(),
});
spectra.push(Spectrum {
name: "psd_excess_max_hold_db".into(),
freq_hz: self.freq_hz.clone(),
value_db: self.max_hold_db.clone(),
});
notes.push(("spectral.baseline_power".into(), format!("{sigma2:.6e}")));
notes.push(("spectral.baseline_kurtosis".into(), format!("{k0:.5}")));
notes.push(("spectral.kurtosis_sd".into(), format!("{sd_k:.3e}")));
} else {
notes.push(("spectral.baseline".into(), "not reached".into()));
}
if let Some(e) = &self.error {
notes.push(("spectral.error".into(), e.clone()));
}
let mut r = MonitorReport {
series: vec![
self.excess.clone(),
self.excess_f.clone(),
self.kurt.clone(),
self.pulse.clone(),
],
events: self.events(),
spectra,
notes,
};
r.events.sort_by(|a, b| a.t_start_s.total_cmp(&b.t_start_s));
r
}
}