peacoqc-rs 0.3.0

PeacoQC quality control algorithms for flow cytometry
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use crate::PeacoQCData;
use crate::error::{PeacoQCError, Result};
use crate::stats::density::KernelDensity;
use crate::stats::median;
use derive_builder::Builder;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::debug;

/// Configuration for peak detection
#[derive(Debug, Clone, PartialEq, Builder)]
#[builder(default)]
pub struct PeakDetectionConfig {
    /// Number of events per bin
    pub events_per_bin: usize,

    /// Minimum peak height as fraction of max density (default: 1/3)
    pub peak_removal: f64,

    /// Minimum percentage of bins that must contain the most common number of peaks
    pub min_nr_bins_peakdetection: f64,

    /// Whether to remove zeros before peak detection
    pub remove_zeros: bool,

    /// Bandwidth adjustment factor for kernel density estimation (default: 1.0)
    ///
    /// Scales the bandwidth calculated by Silverman's rule.
    /// - Lower values (0.5-0.9): Narrower bandwidth, more peaks detected, more sensitive
    /// - Higher values (1.1-2.0): Wider bandwidth, fewer/smoother peaks, less sensitive
    ///
    /// **Use case**: Adjust to match R's KDE behavior or tune peak detection sensitivity
    pub kde_bandwidth_adjust: f64,

    /// Number of grid points for KDE (default: 512)
    ///
    /// Higher values provide more accurate density estimation at the cost of computation.
    /// Common values: 512 (fast), 1024 (balanced), 2048 (accurate)
    ///
    /// **Use case**: Increase for more precise peak detection on high-resolution data
    pub kde_grid_points: usize,

    /// Maximum distance for assigning peaks to clusters (default: None = unlimited)
    ///
    /// When set, peaks farther than this distance from the nearest cluster median
    /// will not be assigned to any cluster (filtered out).
    ///
    /// **Use case**: Prevent outlier peaks from being misassigned to distant clusters
    #[builder(default)]
    pub cluster_distance_threshold: Option<f64>,
}

impl Default for PeakDetectionConfig {
    fn default() -> Self {
        Self {
            events_per_bin: 1000,
            peak_removal: 1.0 / 3.0,
            min_nr_bins_peakdetection: 10.0,
            remove_zeros: false,
            kde_bandwidth_adjust: 1.0,
            kde_grid_points: 512,
            cluster_distance_threshold: None,
        }
    }
}

/// Peak information for a single bin
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeakInfo {
    pub bin: usize,
    pub peak_value: f64,
    pub cluster: usize,
}

/// Peak detection results for a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelPeakFrame {
    pub peaks: Vec<PeakInfo>,
}

/// Determine peaks for all channels
///
/// # Algorithm
/// 1. Split data into bins of `events_per_bin` events
/// 2. For each bin and channel, compute KDE and find peaks
/// 3. Cluster peaks across bins using median clustering
/// 4. Remove clusters present in <50% of bins
///
/// # Performance
/// This function processes channels in parallel using `rayon`, providing significant
/// performance improvements on multi-core systems. Each channel's bins are also
/// processed in parallel for maximum throughput.
pub fn determine_peaks_all_channels<T: PeacoQCData>(
    fcs: &T,
    channels: &[String],
    config: &PeakDetectionConfig,
) -> Result<HashMap<String, ChannelPeakFrame>> {
    let mut results = HashMap::new();

    // Create bins with 50% overlap (matching R's SplitWithOverlap)
    let n_events = fcs.n_events();
    let breaks = create_breaks(n_events, config.events_per_bin);
    let n_bins = breaks.len();

    if n_bins == 0 {
        return Err(PeacoQCError::InsufficientData {
            min: config.events_per_bin,
            actual: n_events,
        });
    }

    debug!(
        n_channels = channels.len(),
        "peak detection across channels"
    );

    // Collect channel data first (sequential, as it may not be thread-safe)
    // Then process peaks in parallel
    let channel_data: Vec<(String, Vec<f64>)> = channels
        .iter()
        .filter_map(|ch| fcs.get_channel_f64(ch).ok().map(|data| (ch.clone(), data)))
        .collect();

    // Process channels in parallel
    let channel_results: Vec<(String, Option<ChannelPeakFrame>)> = channel_data
        .par_iter()
        .map(|(channel, data)| {
            let peak_frame = determine_channel_peaks_from_data(data, &breaks, config);
            (channel.clone(), peak_frame)
        })
        .collect();

    // Collect results into HashMap
    for (channel, frame) in channel_results {
        if let Some(frame) = frame {
            results.insert(channel, frame);
        }
    }

    Ok(results)
}

/// Create bin boundaries with 50% overlap (matching R's SplitWithOverlap)
///
/// R equivalent:
/// ```r
/// SplitWithOverlap <- function(vec, seg.length, overlap) {
///     starts=seq(1, length(vec), by=seg.length-overlap)
///     ends  =starts + seg.length - 1
///     ends[ends > length(vec)]=length(vec)
/// }
/// # Called with: overlap = ceiling(events_per_bin/2)
/// ```
///
/// The overlap is 50% of the bin size (ceiling), which means:
/// - Adjacent bins share half their events
/// - This creates ~2x more bins than non-overlapping
/// - Provides smoother signal stability detection
pub fn create_breaks(n_events: usize, events_per_bin: usize) -> Vec<(usize, usize)> {
    // R: overlap = ceiling(events_per_bin/2)
    let overlap = (events_per_bin + 1) / 2;
    let step = events_per_bin - overlap;

    let mut breaks = Vec::new();
    let mut start = 0;

    while start < n_events {
        let end = (start + events_per_bin).min(n_events);
        breaks.push((start, end));
        start += step;
    }

    breaks
}

/// Determine peaks for a single channel from pre-extracted data (internal, used for parallel processing)
fn determine_channel_peaks_from_data(
    data: &[f64],
    breaks: &[(usize, usize)],
    config: &PeakDetectionConfig,
) -> Option<ChannelPeakFrame> {
    // Process bins in parallel
    let bin_peaks: Vec<Vec<f64>> = breaks
        .par_iter()
        .map(|(start, end)| {
            let bin_data: Vec<f64> = data[*start..*end].to_vec();

            let bin_data = if config.remove_zeros {
                bin_data.into_iter().filter(|&x| x != 0.0).collect()
            } else {
                bin_data
            };

            if bin_data.len() < 3 {
                return Vec::new();
            }

            // Compute KDE and find peaks
            // R's FindThemPeaks returns peaks sorted by x-value (from dens$x)
            // We need to sort peaks to match R's column ordering in the matrix
            let mut peaks = match KernelDensity::estimate(
                &bin_data,
                config.kde_bandwidth_adjust,
                config.kde_grid_points,
            ) {
                Ok(kde) => kde.find_peaks(config.peak_removal),
                Err(_) => Vec::new(),
            };
            // Sort peaks by value to match R's behavior (peaks are in dens$x order, which is sorted)
            peaks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
            peaks
        })
        .collect();

    // Convert to PeakInfo structures
    let mut all_peaks: Vec<PeakInfo> = Vec::new();
    for (bin_idx, peaks) in bin_peaks.iter().enumerate() {
        for &peak_value in peaks {
            all_peaks.push(PeakInfo {
                bin: bin_idx,
                peak_value,
                cluster: 0, // Will be assigned later
            });
        }
    }

    if all_peaks.is_empty() {
        return None;
    }

    // Cluster peaks across bins
    if cluster_peaks(&mut all_peaks, &bin_peaks, config).is_err() {
        return None;
    }

    // Remove small clusters
    if remove_small_clusters(&mut all_peaks, breaks.len()).is_err() {
        return None;
    }

    if all_peaks.is_empty() {
        return None;
    }

    Some(ChannelPeakFrame { peaks: all_peaks })
}

/// Cluster peaks across bins using median clustering
fn cluster_peaks(
    all_peaks: &mut Vec<PeakInfo>,
    bin_peaks: &[Vec<f64>],
    config: &PeakDetectionConfig,
) -> Result<()> {
    // Count number of peaks per bin
    let peak_counts: Vec<usize> = bin_peaks.iter().map(|p| p.len()).collect();

    // Find the most common number of peaks
    let mut count_freq: HashMap<usize, usize> = HashMap::new();
    for &count in &peak_counts {
        *count_freq.entry(count).or_insert(0) += 1;
    }

    // Find the most frequent peak count that appears in enough bins
    let min_bins =
        (config.min_nr_bins_peakdetection / 100.0 * peak_counts.len() as f64).ceil() as usize;

    let most_common_count = count_freq
        .iter()
        .filter(|(_, freq)| *freq >= &min_bins)
        .max_by_key(|(count, _)| *count)
        .map(|(count, _)| *count)
        .unwrap_or(1);

    // Get bins with the most common peak count
    let mut reference_peaks: Vec<Vec<f64>> = Vec::new();
    for peaks in bin_peaks {
        if peaks.len() == most_common_count {
            reference_peaks.push(peaks.clone());
        }
    }

    if reference_peaks.is_empty() {
        // No reference peaks found, assign all peaks to a single cluster
        for peak in all_peaks.iter_mut() {
            peak.cluster = 1;
        }
        return Ok(());
    }

    // Calculate median position for each cluster
    let n_clusters = most_common_count;
    let mut cluster_medians: Vec<f64> = Vec::new();

    for cluster_idx in 0..n_clusters {
        let values: Vec<f64> = reference_peaks
            .iter()
            .filter_map(|peaks| peaks.get(cluster_idx).copied())
            .collect();

        if !values.is_empty() {
            cluster_medians.push(median(&values)?);
        }
    }

    if cluster_medians.is_empty() {
        return Ok(());
    }

    // Assign each peak to nearest cluster
    for peak in all_peaks.iter_mut() {
        let mut min_dist = f64::INFINITY;
        let mut best_cluster = 0;

        for (cluster_idx, &cluster_median) in cluster_medians.iter().enumerate() {
            let dist = (peak.peak_value - cluster_median).abs();
            if dist < min_dist {
                min_dist = dist;
                best_cluster = cluster_idx + 1; // 1-indexed
            }
        }

        // Check distance threshold
        if let Some(threshold) = config.cluster_distance_threshold {
            if min_dist > threshold {
                // Skip this peak (don't assign to any cluster)
                peak.cluster = 0; // Mark as unassigned
                continue;
            }
        }

        peak.cluster = best_cluster;
    }

    // Remove unassigned peaks (cluster = 0)
    all_peaks.retain(|peak| peak.cluster > 0);

    Ok(())
}

/// Remove clusters that appear in less than 50% of bins
fn remove_small_clusters(all_peaks: &mut Vec<PeakInfo>, n_bins: usize) -> Result<()> {
    // Count bins per cluster
    let mut cluster_bin_counts: HashMap<usize, std::collections::HashSet<usize>> = HashMap::new();

    for peak in all_peaks.iter() {
        cluster_bin_counts
            .entry(peak.cluster)
            .or_insert_with(std::collections::HashSet::new)
            .insert(peak.bin);
    }

    // Find clusters to remove
    let min_bins = (n_bins as f64 * 0.5).ceil() as usize;
    let clusters_to_keep: Vec<usize> = cluster_bin_counts
        .iter()
        .filter(|(_, bins)| bins.len() >= min_bins)
        .map(|(cluster, _)| *cluster)
        .collect();

    // Filter peaks
    all_peaks.retain(|peak| clusters_to_keep.contains(&peak.cluster));

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fcs::SimpleFcs;
    use polars::df;
    use std::collections::HashMap as StdHashMap;
    use std::sync::Arc;

    #[test]
    fn test_peak_detection_basic() {
        // Create synthetic data with stable peak
        let mut data = Vec::new();
        for _ in 0..5000 {
            data.push(100.0 + rand::random::<f64>() * 10.0);
        }

        let df = Arc::new(
            df![
                "FL1-A" => data,
            ]
            .unwrap(),
        );

        let fcs = SimpleFcs {
            data_frame: df,
            parameter_metadata: StdHashMap::new(),
        };

        let config = PeakDetectionConfig {
            events_per_bin: 1000,
            ..Default::default()
        };

        let result = determine_peaks_all_channels(&fcs, &["FL1-A".to_string()], &config);

        assert!(result.is_ok());
    }
}