From c9a8ca758abc0249c6425b83b94af22bade80999 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 21:33:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(mat):=20real=203-point=20parabolic=20peak?= =?UTF-8?q?=20interpolation=20in=20find=5Fdominant=5Ffrequency=20(ADR-158?= =?UTF-8?q?=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed interpolation but the function returned the bin center, capping breathing-rate resolution at +/-half a bin. Implemented quadratic (3-point parabolic) peak interpolation: delta = 0.5*(yL-yR)/(yL-2y0+yR), clamped to [-0.5,0.5], with an edge fallback to bin center. For a parabola-shaped peak the recovery is exact (delta=0.4 for a true peak at bin 10.4). Test asserts the result lands within half a bin of truth and strictly beats the old bin-center estimate. Co-Authored-By: claude-flow --- .../src/detection/breathing.rs | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/v2/crates/wifi-densepose-mat/src/detection/breathing.rs b/v2/crates/wifi-densepose-mat/src/detection/breathing.rs index 3a4a6867..20f5675b 100644 --- a/v2/crates/wifi-densepose-mat/src/detection/breathing.rs +++ b/v2/crates/wifi-densepose-mat/src/detection/breathing.rs @@ -240,8 +240,36 @@ impl BreathingDetector { return None; } - // Interpolate for better frequency estimate - let freq = max_bin_idx as f64 * freq_resolution; + // 3-point parabolic (quadratic) peak interpolation. + // + // The true spectral peak rarely lands exactly on a bin center; returning + // the bin center alone caps frequency (hence breathing-rate) resolution at + // ±half a bin. Fitting a parabola through the peak bin and its two + // neighbours recovers the sub-bin location: + // + // δ = 0.5 * (yₗ - yᵣ) / (yₗ - 2y₀ + yᵣ), δ ∈ [-0.5, 0.5] + // + // where y₀ is the peak magnitude and yₗ/yᵣ its neighbours. true_bin = k+δ. + let interpolated_bin = if max_bin_idx > 0 && max_bin_idx + 1 < spectrum.len() { + let y_left = spectrum[max_bin_idx - 1]; + let y_center = spectrum[max_bin_idx]; + let y_right = spectrum[max_bin_idx + 1]; + + let denom = y_left - 2.0 * y_center + y_right; + if denom.abs() > f64::EPSILON { + // Concave-down peak: denom < 0. δ is well-defined; clamp to the + // bin's own interval to stay robust against noisy shoulders. + let delta = (0.5 * (y_left - y_right) / denom).clamp(-0.5, 0.5); + max_bin_idx as f64 + delta + } else { + max_bin_idx as f64 + } + } else { + // Peak at spectrum edge: no neighbour pair, fall back to bin center. + max_bin_idx as f64 + }; + + let freq = interpolated_bin * freq_resolution; Some((freq, max_amplitude)) } @@ -384,6 +412,54 @@ mod tests { assert!(matches!(pattern.pattern_type, BreathingType::Labored)); } + /// Parabolic interpolation regression (FAILS on the old bin-center code). + /// + /// Build a spectrum whose true peak sits at a known non-integer bin (10.4), + /// shaped as a downward parabola so quadratic interpolation is exact. The + /// returned frequency must land within half a bin of the true frequency, and + /// strictly closer than the bin-center estimate (10.0) the old code returned. + #[test] + fn test_find_dominant_frequency_parabolic_interpolation() { + let detector = BreathingDetector::with_defaults(); + + // Spectrum of length L so the "original FFT size" n = 2L. Choose values + // so freq_resolution is convenient. With sample_rate = 64, n = 128 -> the + // breathing band (4..40 bpm = 0.0667..0.667 Hz) covers bins ~0.13..1.33, + // which is too coarse, so use a higher sample_rate to spread the band. + let spectrum_len = 64usize; // n = 128 + let sample_rate = 12.8_f64; // freq_resolution = 12.8/128 = 0.1 Hz/bin + let true_bin = 10.4_f64; + + // Downward parabola peaked at true_bin (positive magnitudes via offset). + let mut spectrum = vec![0.0_f64; spectrum_len]; + for (i, s) in spectrum.iter_mut().enumerate() { + let d = i as f64 - true_bin; + *s = (5.0 - d * d).max(0.0); + } + + // Band wide enough to contain bin 10 (0.0..2.0 Hz). + let result = detector.find_dominant_frequency(&spectrum, sample_rate, 0.0, 2.0); + let (freq, _amp) = result.expect("peak should be found"); + + let freq_resolution = sample_rate / (spectrum_len * 2) as f64; // 0.1 Hz + let true_freq = true_bin * freq_resolution; + let bin_center_freq = 10.0 * freq_resolution; + + let err_interp = (freq - true_freq).abs(); + let err_bin_center = (bin_center_freq - true_freq).abs(); + + // Within half a bin of truth. + assert!( + err_interp < 0.5 * freq_resolution, + "interpolated freq {freq} not within half a bin of true {true_freq} (err {err_interp})" + ); + // And strictly better than the old bin-center answer. + assert!( + err_interp < err_bin_center, + "interpolation ({err_interp}) must beat bin-center ({err_bin_center})" + ); + } + #[test] fn test_no_detection_on_noise() { let detector = BreathingDetector::with_defaults();