Skip to main content

dspk_synapse/streaming/
config.rs

1//! Configuration and dynamic halo computation for out-of-core streaming detection.
2
3use dspk_ops::peaks::DistanceRule;
4use dspk_ops::pipeline::Pipeline;
5use dspk_core::{DspError, DspResult};
6
7use crate::detection::SpikePolarity;
8
9/// Samples of halo the sub-sample realignment needs beyond a snippet: the windowed-sinc radius of
10/// extraction (one source of truth, [`crate::extraction::SINC_KERNEL_RADIUS`]).
11pub const SINC_RESAMPLE_MARGIN: usize = crate::extraction::SINC_KERNEL_RADIUS;
12
13/// Shortest refractory period and snippet sides (ms), shortest batch and calibration (s).
14const MIN_WINDOW_MS: f64 = 0.1;
15const MIN_BATCH_SEC: f64 = 0.5;
16const MIN_CALIBRATION_SEC: f64 = 0.1;
17/// Sample rate floor used in the conversions (Hz; a real rate is far above it).
18const MIN_SAMPLE_RATE_HZ: f64 = 1.0;
19
20/// Configuration for [`super::StreamingDetector`].
21#[derive(Debug, Clone)]
22pub struct StreamingDetectionConfig {
23    /// Duration of each valid interior streaming batch in seconds (default: `10.0` s).
24    pub batch_duration_sec: f64,
25    /// Total duration of the noise-floor calibration in seconds (default: `5.0` s), split into
26    /// `calibration_chunks` chunks spread evenly over the recording.
27    pub calibration_duration_sec: f64,
28    /// Number of calibration chunks; σ per channel is the median of the per-chunk estimates
29    /// (default: `5`).
30    pub calibration_chunks: usize,
31    /// Threshold multiplier in units of Quiroga $\sigma_n$ (default: `5.0`).
32    pub threshold_factor: f32,
33    /// Refractory period in milliseconds (default: `1.0` ms).
34    pub refractory_ms: f64,
35    /// Which extrema are spikes (default: negative troughs).
36    pub polarity: SpikePolarity,
37    /// How crossings nearer than the refractory period are resolved (default:
38    /// [`DistanceRule::LocallyExclusive`], for which streaming equals whole-recording detection;
39    /// [`DistanceRule::Scipy`] can differ at batch edges).
40    pub distance_rule: DistanceRule,
41    /// Spatial deduplication radius in micrometers (default: `150.0` $\mu\text{m}$).
42    pub spatial_radius_um: f32,
43    /// Number of $K$-nearest neighbor channels extracted per waveform snippet (default: `4`).
44    pub k_neighbors: usize,
45    /// Pre-trough snippet window in milliseconds (default: `1.0` ms).
46    pub pre_ms: f64,
47    /// Post-trough snippet window in milliseconds (default: `2.0` ms).
48    pub post_ms: f64,
49    /// Whether to apply sub-sample windowed-sinc (Blackman-Harris) realignment (default: `true`).
50    pub apply_sinc_shift: bool,
51    /// Whether to locate every spike: the centre of mass of its snippet's peak-to-peak heights
52    /// over its K neighbours (default: `true`; else locations are unknown, NaN).
53    pub locate: bool,
54}
55
56impl Default for StreamingDetectionConfig {
57    fn default() -> Self {
58        Self {
59            batch_duration_sec: 10.0,
60            calibration_duration_sec: 5.0,
61            calibration_chunks: 5,
62            threshold_factor: 5.0,
63            refractory_ms: 1.0,
64            polarity: SpikePolarity::Negative,
65            distance_rule: DistanceRule::LocallyExclusive,
66            spatial_radius_um: 150.0,
67            k_neighbors: 4,
68            pre_ms: 1.0,
69            post_ms: 2.0,
70            apply_sinc_shift: true,
71            locate: true,
72        }
73    }
74}
75
76impl StreamingDetectionConfig {
77    /// The threshold detector's settings these detections use (factor, polarity, refractory
78    /// period, spacing rule).
79    pub fn threshold_detector(&self) -> crate::detection::ThresholdSpikeDetector {
80        crate::detection::ThresholdSpikeDetector {
81            threshold_factor: self.threshold_factor,
82            refractory_ms: self.refractory_ms,
83            polarity: self.polarity,
84            distance_rule: self.distance_rule,
85        }
86    }
87
88    /// Refractory period in samples at `sample_rate` Hz.
89    #[inline]
90    pub fn refractory_samples(&self, sample_rate: f64) -> usize {
91        ((self.refractory_ms.max(MIN_WINDOW_MS) * 1e-3) * sample_rate.max(MIN_SAMPLE_RATE_HZ)).round() as usize
92    }
93
94    /// Pre-trough snippet length in samples at `sample_rate` Hz.
95    #[inline]
96    pub fn pre_samples(&self, sample_rate: f64) -> usize {
97        ((self.pre_ms.max(MIN_WINDOW_MS) * 1e-3) * sample_rate.max(MIN_SAMPLE_RATE_HZ)).round() as usize
98    }
99
100    /// Post-trough snippet length in samples at `sample_rate` Hz.
101    #[inline]
102    pub fn post_samples(&self, sample_rate: f64) -> usize {
103        ((self.post_ms.max(MIN_WINDOW_MS) * 1e-3) * sample_rate.max(MIN_SAMPLE_RATE_HZ)).round() as usize
104    }
105
106    /// Total snippet length (`pre_samples + post_samples`) at `sample_rate` Hz.
107    #[inline]
108    pub fn snippet_samples(&self, sample_rate: f64) -> usize {
109        self.pre_samples(sample_rate) + self.post_samples(sample_rate)
110    }
111
112    /// Valid interior batch size in samples at `sample_rate` Hz.
113    #[inline]
114    pub fn batch_samples(&self, sample_rate: f64) -> u64 {
115        ((self.batch_duration_sec.max(MIN_BATCH_SEC) * sample_rate.max(MIN_SAMPLE_RATE_HZ)).round() as u64).max(1)
116    }
117
118    /// Global sample range where spikes are detected: a full snippet plus the realignment margin
119    /// fits inside the recording.
120    pub fn detection_range(&self, sample_rate: f64, total_samples: u64) -> std::ops::Range<u64> {
121        let margin = crate::extraction::extraction_margin(self.apply_sinc_shift);
122        let start = (self.pre_samples(sample_rate) + margin) as u64;
123        let end = total_samples.saturating_sub((self.post_samples(sample_rate) + margin) as u64);
124        start..end.max(start)
125    }
126
127    /// Calibration chunks: `calibration_chunks` ranges totalling `calibration_duration_sec`,
128    /// spread evenly across `0..total_samples` (fewer / shorter for short recordings).
129    pub fn calibration_chunks(&self, sample_rate: f64, total_samples: u64) -> Vec<std::ops::Range<u64>> {
130        let k = self.calibration_chunks.max(1) as u64;
131        let total_len = ((self.calibration_duration_sec.max(MIN_CALIBRATION_SEC) * sample_rate.max(MIN_SAMPLE_RATE_HZ)).round() as u64).max(k);
132        let len = (total_len / k).min(total_samples);
133        if len == 0 {
134            return Vec::new();
135        }
136        let span = total_samples - len;
137        (0..k)
138            .map(|i| {
139                let start = if k == 1 { 0 } else { span * i / (k - 1) };
140                start..start + len
141            })
142            .collect()
143    }
144
145    /// Computes the required `(left_halo, right_halo)` in samples at `sample_rate` Hz
146    /// given the filter stages in `pipeline`:
147    ///
148    /// - `left_halo = settling_left + pre_samples(fs) + refractory_samples(fs) + SINC_RESAMPLE_MARGIN`
149    /// - `right_halo = settling_right + post_samples(fs) + refractory_samples(fs) + SINC_RESAMPLE_MARGIN`
150    ///
151    /// where `(settling_left, settling_right) = pipeline.settling(fs)` (forward-backward filters need
152    /// both sides).
153    ///
154    /// # Errors
155    ///
156    /// [`DspError::InvalidConfig`] when a filter stage's design is invalid at `sample_rate`.
157    pub fn compute_halos(&self, sample_rate: f64, pipeline: &Pipeline) -> DspResult<(u64, u64)> {
158        let (settle_left, settle_right) = pipeline
159            .settling(sample_rate)
160            .map_err(|e| DspError::InvalidConfig(e.to_string()))?;
161        let pre = self.pre_samples(sample_rate);
162        let post = self.post_samples(sample_rate);
163        let refrac = self.refractory_samples(sample_rate);
164
165        let left = (settle_left + pre + refrac + SINC_RESAMPLE_MARGIN) as u64;
166        let right = (settle_right + post + refrac + SINC_RESAMPLE_MARGIN) as u64;
167        Ok((left, right))
168    }
169}