Skip to main content

dspk_synapse/streaming/
runner.rs

1//! Out-of-core streaming spike detection: `RecordingSource` → `PipelineWorkspace` filtering in
2//! halo windows → device threshold detection → exact streaming deduplication → device snippet
3//! extraction → per-channel templates (`TemplateAccumulator`). No clustering: templates and
4//! spike lists are per primary channel.
5
6use cubecl::prelude::Client;
7use cubecl::CubeElement;
8use dspk_core::compute::{ComputeTarget, ComputeTask};
9
10use dspk_ops::math::execute_channel_noise_std;
11use dspk_ops::pipeline::{Pipeline, PipelineWorkspace};
12use dspk_core::{ChunkSchedule, DspError, DspResult, HaloWindow, RecordingSource, SampleFormat, WindowLoader};
13use crate::io::probe::{precompute_knn_table, SensorLayout};
14
15use crate::core::{DeduplicatedSpike, SortedUnit, SortingOutput, WaveformTemplate};
16use crate::detection::{SpikeSpacing, StreamingDedup, detection_heights, execute_detect_spikes_in_vram};
17use crate::extraction::{execute_extract_sinc_in_vram, extraction_margin};
18use super::accumulator::TemplateAccumulator;
19use super::config::StreamingDetectionConfig;
20use super::kernels::{execute_reduce_templates_in_vram, execute_snippet_peak_to_peak};
21use crate::spatial::localize_spike_center_of_mass;
22
23/// Exponent of the peak-to-peak heights in the centre of mass that locates each spike (1: the
24/// heights themselves, as `CenterOfMassLocalizer`'s default).
25pub const LOCATION_POWER: f32 = 1.0;
26
27/// Result of running out-of-core threshold spike sorting over a recording.
28#[derive(Debug, Clone)]
29pub struct StreamingDetectionResult {
30    /// Number of channels in the recording.
31    pub channels: usize,
32    /// Total samples processed across the recording.
33    pub total_samples: u64,
34    /// Sampling rate in Hz.
35    pub sample_rate_hz: f64,
36    /// Computed `(left_halo, right_halo)` in samples used during chunked streaming.
37    pub halos: (u64, u64),
38    /// Calibrated Quiroga MAD noise floor ($\sigma_n$ in $\mu\text{V}$) per channel.
39    pub channel_sigmas_uv: Vec<f32>,
40    /// Total raw threshold crossings across all channels before spatial deduplication.
41    pub total_raw_crossings: u64,
42    /// Total spatially deduplicated spikes across the recording.
43    pub total_dedup_spikes: u64,
44    /// Deduplicated spike count per primary channel.
45    pub channel_spike_counts: Vec<u64>,
46    /// Online Welford-accumulated waveform template (`mean` $\pm$ `std`) per primary channel.
47    pub channel_templates: Vec<Option<WaveformTemplate>>,
48    /// Deduplicated spike events with global `sample_index` across the recording.
49    pub spikes: Vec<DeduplicatedSpike>,
50    /// Location of each spike of `spikes` (µm, centre of mass over its K neighbours); empty when
51    /// not located (`StreamingDetectionConfig::locate`).
52    pub spike_locations_um: Vec<[f32; 3]>,
53}
54
55impl StreamingDetectionResult {
56    /// This result as a [`SortingOutput`] with **one unit per primary channel** that has spikes
57    /// (a channel-level summary, not a spike sorting: unit metrics describe channels).
58    pub fn to_sorting_output(
59        &self,
60        sorter_name: impl Into<String>,
61        probe: Option<SensorLayout>,
62    ) -> SortingOutput {
63        let mut per_ch_samples: Vec<Vec<u64>> = vec![Vec::new(); self.channels];
64        let mut per_ch_amps: Vec<Vec<f32>> = vec![Vec::new(); self.channels];
65        let mut per_ch_locs: Vec<Vec<[f32; 3]>> = vec![Vec::new(); self.channels];
66        let located = self.spike_locations_um.len() == self.spikes.len();
67        for (i, s) in self.spikes.iter().enumerate() {
68            if s.primary_channel < self.channels {
69                per_ch_samples[s.primary_channel].push(s.sample_index);
70                per_ch_amps[s.primary_channel].push(s.peak_amplitude_uv);
71                if located {
72                    per_ch_locs[s.primary_channel].push(self.spike_locations_um[i]);
73                }
74            }
75        }
76
77        let mut units = Vec::new();
78        for ch in 0..self.channels {
79            if per_ch_samples[ch].is_empty() {
80                continue;
81            }
82            let template = self.channel_templates.get(ch).and_then(|t| t.clone());
83            // No calibrated σ (not expected): NaN rather than an invented noise level
84            let noise_sd = self.channel_sigmas_uv.get(ch).copied().unwrap_or(f32::NAN);
85            units.push(SortedUnit::from_spikes(
86                ch,
87                ch,
88                std::mem::take(&mut per_ch_samples[ch]),
89                std::mem::take(&mut per_ch_amps[ch]),
90                std::mem::take(&mut per_ch_locs[ch]),
91                template,
92                self.sample_rate_hz,
93                self.total_samples,
94                noise_sd,
95            ));
96        }
97
98        SortingOutput::new(
99            sorter_name,
100            self.sample_rate_hz,
101            self.total_samples,
102            probe,
103            units,
104            None,
105        )
106    }
107}
108
109/// Out-of-core streaming spike detector with per-channel templates (see the module docs).
110pub struct StreamingDetector {
111    config: StreamingDetectionConfig,
112}
113
114impl StreamingDetector {
115    pub fn new(config: StreamingDetectionConfig) -> Self {
116        Self { config }
117    }
118
119    /// Streams `source` out-of-core on `target`.
120    ///
121    /// # Errors
122    ///
123    /// [`DspError::ComputeError`] when `target` is not available; otherwise as [`Self::run_on`].
124    pub fn run_with(
125        &self,
126        target: ComputeTarget,
127        source: &dyn RecordingSource,
128        pipeline: &Pipeline,
129        probe: &SensorLayout,
130    ) -> DspResult<StreamingDetectionResult> {
131        struct Task<'a> {
132            runner: &'a StreamingDetector,
133            source: &'a dyn RecordingSource,
134            pipeline: &'a Pipeline,
135            probe: &'a SensorLayout,
136        }
137        impl ComputeTask for Task<'_> {
138            type Output = DspResult<StreamingDetectionResult>;
139            fn run(self, client: Client) -> Self::Output {
140                self.runner.run_on(client, self.source, self.pipeline, self.probe)
141            }
142        }
143        target
144            .run(Task { runner: self, source, pipeline, probe })
145            .map_err(|e| DspError::ComputeError(e.to_string()))?
146    }
147
148    /// Streams `source` out-of-core in halo-padded batches using double-buffered I/O prefetching
149    /// and a persistent `PipelineWorkspace` on `client`'s runtime, returning global spikes and
150    /// per-channel templates.
151    ///
152    /// # Errors
153    ///
154    /// When the pipeline is invalid at the recording's rate, or reading `source` fails.
155    pub fn run_on(
156        &self,
157        client: Client,
158        source: &dyn RecordingSource,
159        pipeline: &Pipeline,
160        probe: &SensorLayout,
161    ) -> DspResult<StreamingDetectionResult> {
162        let info = source.info();
163        let channels = info.channel_count();
164        let total_samples = info.samples;
165        let fs = info.sample_rate_hz();
166
167        let (left_halo, right_halo) = self.config.compute_halos(fs, pipeline)?;
168        let batch_samples = self.config.batch_samples(fs);
169        let refrac_samples = self.config.refractory_samples(fs);
170        let pre_samples = self.config.pre_samples(fs);
171        let post_samples = self.config.post_samples(fs);
172        let snippet_samples = pre_samples + post_samples;
173        let k_neighbors = self.config.k_neighbors.clamp(1, channels.max(1));
174
175        let schedule = ChunkSchedule::full_recording(
176            total_samples,
177            batch_samples,
178            left_halo,
179            right_halo,
180        );
181
182        let mut workspace = PipelineWorkspace::new(
183            client,
184            pipeline.clone(),
185            channels,
186            schedule.max_read_samples().max(1),
187            fs,
188        )
189        .map_err(|e| dspk_core::DspError::InvalidConfig(e.to_string()))?;
190
191        // Phase 1: Calibrate fixed per-channel noise floor (sigma_n) from chunks spread over the recording
192        let channel_sigmas_uv =
193            calibrate_noise(source, &mut workspace, &self.config, (left_halo, right_halo))?;
194
195        // Pre-upload per-channel detection heights and K-nearest neighbor table to VRAM once
196        let heights = detection_heights(&channel_sigmas_uv, self.config.threshold_factor);
197        let heights_handle = workspace.client().create_from_slice(f32::as_bytes(&heights));
198        let spacing = SpikeSpacing { refractory_samples: refrac_samples, rule: self.config.distance_rule };
199        let knn_table = precompute_knn_table(probe, channels, k_neighbors);
200        let knn_handle = workspace
201            .client()
202            .create_from_slice(u32::as_bytes(&knn_table));
203
204        // Spikes are detected where a full snippet (plus sinc taps) can be cut from the recording.
205        let margin = extraction_margin(self.config.apply_sinc_shift);
206        let detect_range = self.config.detection_range(fs, total_samples);
207
208        // Phase 2: Stream HaloWindows with double-buffered prefetching and zero-readback VRAM kernels
209        let mut accumulators: Vec<TemplateAccumulator> = (0..channels)
210            .map(|ch| {
211                let ids = knn_table[ch * k_neighbors..(ch + 1) * k_neighbors].iter().map(|&c| c as usize).collect();
212                TemplateAccumulator::new(ids, snippet_samples)
213            })
214            .collect();
215        let mut channel_spike_counts = vec![0u64; channels];
216        let mut total_raw_crossings = 0u64;
217        let mut total_dedup_spikes = 0u64;
218        let mut global_spikes = Vec::new();
219        let mut global_locations: Vec<[f32; 3]> = Vec::new();
220        let locate = self.config.locate;
221        let elems_per_spike = k_neighbors * snippet_samples;
222        let mut dedup = StreamingDedup::new(probe, self.config.spatial_radius_um, refrac_samples as u64);
223        // Detection looks `spacing.distance()` samples past each window's own range (exact
224        // streaming with `DistanceRule::LocallyExclusive`); the halos cover it
225        debug_assert!(left_halo as usize >= refrac_samples + pre_samples + margin);
226        debug_assert!(right_halo as usize >= spacing.distance());
227
228        // Integer recordings upload their stored values and are scaled on the device
229        let stored = matches!(info.format, SampleFormat::I8 | SampleFormat::I16 | SampleFormat::U16 | SampleFormat::I32)
230            && total_samples > 0
231            && source.read_stored(&[0], 0..1, &mut vec![0u8; info.format.bytes()]).is_ok();
232        if stored {
233            let gains: Vec<f32> = info.channels.iter().map(|c| c.gain).collect();
234            let offsets: Vec<f32> = info.channels.iter().map(|c| c.offset).collect();
235            workspace.set_stored_scaling(&gains, &offsets);
236        }
237        let client = workspace.client().clone();
238
239        let mut process = |win: &HaloWindow, filt_handle: cubecl::server::Handle| -> DspResult<()> {
240            let n_read = win.read_len();
241            let read_start = win.read_global.start;
242
243            // 2. Detect crossings in this window's share of the detection range (spacing decided
244            //    with the neighbouring samples of the halo)
245            let det_start = win.valid_global.start.max(detect_range.start);
246            let det_end = win.valid_global.end.min(detect_range.end);
247            if det_start < det_end {
248                let local = |g: u64| (g - read_start) as usize;
249                let crossings = execute_detect_spikes_in_vram(
250                    &client,
251                    &filt_handle,
252                    &heights_handle,
253                    channels,
254                    n_read,
255                    local(det_start)..local(det_end),
256                    read_start,
257                    self.config.polarity,
258                    spacing,
259                );
260                total_raw_crossings += crossings.len() as u64;
261                dedup.push(&crossings);
262            }
263
264            // 3. Spatial deduplication of every crossing whose ±refractory neighbourhood is complete
265            let finalized = if win.valid_global.end >= total_samples {
266                dedup.finish()
267            } else {
268                dedup.finalize_before(win.valid_global.end)
269            };
270            total_dedup_spikes += finalized.len() as u64;
271            if finalized.is_empty() {
272                return Ok(());
273            }
274            let local: Vec<DeduplicatedSpike> = finalized
275                .iter()
276                .map(|d| DeduplicatedSpike { sample_index: d.sample_index - read_start, ..d.clone() })
277                .collect();
278
279            // 4. Extract Blackman-Harris sinc-realigned snippets and reduce moments directly in VRAM
280            //    (and locate each spike from its snippet's heights; unknown, NaN, if not extracted)
281            let mut locations = vec![[f32::NAN; 3]; if locate { finalized.len() } else { 0 }];
282            if let Some(extracted) = execute_extract_sinc_in_vram::<f32>(
283                &client,
284                &filt_handle,
285                &knn_handle,
286                channels,
287                n_read,
288                &local,
289                k_neighbors,
290                pre_samples,
291                post_samples,
292                self.config.apply_sinc_shift,
293            ) {
294                debug_assert_eq!(extracted.dropped, 0, "finalized spikes lie inside the window");
295                if locate {
296                    let ptp = execute_snippet_peak_to_peak(&client, &extracted.snippets, extracted.kept.len(), k_neighbors, snippet_samples);
297                    for (row, (&spike, &primary)) in extracted.kept.iter().zip(&extracted.primaries).enumerate() {
298                        let p = primary as usize;
299                        let ids: Vec<usize> = knn_table[p * k_neighbors..(p + 1) * k_neighbors].iter().map(|&c| c as usize).collect();
300                        locations[spike] = localize_spike_center_of_mass(&ids, &ptp[row * k_neighbors..(row + 1) * k_neighbors], probe, LOCATION_POWER);
301                    }
302                }
303                let batch_stats = execute_reduce_templates_in_vram(
304                    &client,
305                    &extracted.snippets,
306                    &extracted.primaries,
307                    channels,
308                    k_neighbors,
309                    snippet_samples,
310                );
311
312                // 5. Merge batch moments into online Welford/Chan accumulators in O(1) memory
313                for (ch, acc) in accumulators.iter_mut().enumerate() {
314                    let count = batch_stats.counts.get(ch).copied().unwrap_or(0) as u64;
315                    if count > 0 {
316                        let start = ch * elems_per_spike;
317                        let end = start + elems_per_spike;
318                        acc.merge_batch(
319                            count,
320                            &batch_stats.mean[start..end],
321                            &batch_stats.m2[start..end],
322                        );
323                    }
324                }
325            }
326
327            // 6. Record global spike timestamps (and locations)
328            global_locations.extend(locations);
329            for d in finalized {
330                if d.primary_channel < channels {
331                    channel_spike_counts[d.primary_channel] += 1;
332                }
333                global_spikes.push(d);
334            }
335
336            Ok(())
337        };
338
339        // 1. Filter each padded chunk in VRAM (persistent ping-pong buffers, no host readback)
340        let loader = WindowLoader::new(source);
341        if stored {
342            loader.stream_stored(schedule.windows(), |win, bytes| {
343                let filt_handle = workspace.process_stored_chunk_in_vram(bytes, info.format, win.read_len())?;
344                process(win, filt_handle)
345            })?;
346        } else {
347            loader.stream(schedule.windows(), |win, raw_padded| {
348                let filt_handle = workspace.process_chunk_in_vram(raw_padded, win.read_len());
349                process(win, filt_handle)
350            })?;
351        }
352
353        let channel_templates = accumulators.iter().map(|a| a.finalize()).collect();
354
355        Ok(StreamingDetectionResult {
356            channels,
357            total_samples,
358            sample_rate_hz: fs,
359            halos: (left_halo, right_halo),
360            channel_sigmas_uv,
361            total_raw_crossings,
362            total_dedup_spikes,
363            channel_spike_counts,
364            channel_templates,
365            spikes: global_spikes,
366            spike_locations_um: global_locations,
367        })
368    }
369}
370
371/// Per-channel noise floor σ (Quiroga MAD) as the median over `config.calibration_chunks` chunks
372/// spread evenly across the recording (total `config.calibration_duration_sec`). Each chunk is
373/// filtered with `halos` of context so its interior is settled. Independent of the batch size.
374///
375/// # Errors
376///
377/// When reading `source` fails.
378pub fn calibrate_noise(
379    source: &dyn RecordingSource,
380    workspace: &mut PipelineWorkspace,
381    config: &StreamingDetectionConfig,
382    halos: (u64, u64),
383) -> DspResult<Vec<f32>> {
384    let info = source.info();
385    let (channels, total, fs) = (info.channel_count(), info.samples, info.sample_rate_hz());
386    let mut per_chunk: Vec<Vec<f32>> = vec![Vec::new(); channels];
387    let windows: Vec<HaloWindow> = config
388        .calibration_chunks(fs, total)
389        .into_iter()
390        .enumerate()
391        .map(|(index, chunk)| HaloWindow::around(index, chunk, halos.0, halos.1, total))
392        .collect();
393    WindowLoader::new(source).stream(&windows, |window, raw| {
394        // Filtered chunk stays on the device; only one σ per channel is downloaded
395        let filt = workspace.process_chunk_in_vram(raw, window.read_len());
396        let sigmas = execute_channel_noise_std::<f32>(
397            workspace.client(),
398            &filt,
399            channels,
400            window.read_len(),
401            window.valid_local.clone(),
402        );
403        for (acc, sigma) in per_chunk.iter_mut().zip(sigmas) {
404            acc.push(sigma as f32);
405        }
406        Ok(())
407    })?;
408
409    Ok(per_chunk
410        .into_iter()
411        .map(|mut v| {
412            if v.is_empty() {
413                return 0.0;
414            }
415            v.sort_by(f32::total_cmp);
416            let m = v.len() / 2;
417            if v.len() % 2 == 1 { v[m] } else { 0.5 * (v[m - 1] + v[m]) }
418        })
419        .collect())
420}