1use 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
23pub const LOCATION_POWER: f32 = 1.0;
26
27#[derive(Debug, Clone)]
29pub struct StreamingDetectionResult {
30 pub channels: usize,
32 pub total_samples: u64,
34 pub sample_rate_hz: f64,
36 pub halos: (u64, u64),
38 pub channel_sigmas_uv: Vec<f32>,
40 pub total_raw_crossings: u64,
42 pub total_dedup_spikes: u64,
44 pub channel_spike_counts: Vec<u64>,
46 pub channel_templates: Vec<Option<WaveformTemplate>>,
48 pub spikes: Vec<DeduplicatedSpike>,
50 pub spike_locations_um: Vec<[f32; 3]>,
53}
54
55impl StreamingDetectionResult {
56 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 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
109pub struct StreamingDetector {
111 config: StreamingDetectionConfig,
112}
113
114impl StreamingDetector {
115 pub fn new(config: StreamingDetectionConfig) -> Self {
116 Self { config }
117 }
118
119 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 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 let channel_sigmas_uv =
193 calibrate_noise(source, &mut workspace, &self.config, (left_halo, right_halo))?;
194
195 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 let margin = extraction_margin(self.config.apply_sinc_shift);
206 let detect_range = self.config.detection_range(fs, total_samples);
207
208 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 debug_assert!(left_halo as usize >= refrac_samples + pre_samples + margin);
226 debug_assert!(right_halo as usize >= spacing.distance());
227
228 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 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 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 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 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 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 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
371pub 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 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}