Skip to main content

fastqc_rust/modules/
duplication_level.rs

1// Sequence Duplication Levels module
2// Corresponds to Modules/DuplicationLevel.java
3
4use std::collections::HashMap;
5use std::io;
6use std::sync::{Arc, Mutex};
7
8use crate::config::{Limits, LimitsExt};
9use crate::modules::overrepresented_seqs::OverRepresentedData;
10use crate::modules::QCModule;
11use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
12use crate::report::charts::CHART_WIDTH;
13use crate::sequence::Sequence;
14use crate::utils::format::java_format_double;
15use crate::utils::java_hashmap;
16
17pub struct DuplicationLevel {
18    shared_data: Arc<Mutex<OverRepresentedData>>,
19    limits: Limits,
20    // Lazily computed
21    computed: Option<ComputedLevels>,
22}
23
24struct ComputedLevels {
25    total_percentages: [f64; 16],
26    percent_different_seqs: f64,
27}
28
29/// The 16 duplication level labels, matching DuplicationLevel.java
30const LABELS: [&str; 16] = [
31    "1", "2", "3", "4", "5", "6", "7", "8", "9", ">10", ">50", ">100", ">500", ">1k", ">5k", ">10k",
32];
33
34impl DuplicationLevel {
35    pub fn new(shared_data: Arc<Mutex<OverRepresentedData>>, limits: &Limits) -> Self {
36        DuplicationLevel {
37            shared_data,
38            limits: limits.clone(),
39            computed: None,
40        }
41    }
42
43    /// Replicates calculateLevels() from DuplicationLevel.java.
44    /// Must be called after all sequences have been processed.
45    pub fn calculate_levels(&mut self) {
46        if self.computed.is_some() {
47            return;
48        }
49
50        let data = self.shared_data.lock().unwrap_or_else(|e| e.into_inner());
51
52        let mut total_percentages = [0.0f64; 16];
53
54        // Collate how many unique sequences have each duplication count
55        let mut collated_counts: HashMap<u64, u64> = HashMap::new();
56        for &count in data.sequences.values() {
57            *collated_counts.entry(count).or_insert(0) += 1;
58        }
59
60        // Apply statistical correction to each duplication level
61        let mut corrected_counts: Vec<(u64, f64)> = collated_counts
62            .into_iter()
63            .map(|(dup_level, num_observations)| {
64                let corrected = get_corrected_count(
65                    data.count_at_unique_limit,
66                    data.count,
67                    dup_level,
68                    num_observations,
69                );
70                (dup_level, corrected)
71            })
72            .collect();
73        sort_in_java_hashmap_order(&mut corrected_counts);
74
75        // Calculate raw and deduplicated totals from corrected counts
76        let mut dedup_total: f64 = 0.0;
77        let mut raw_total: f64 = 0.0;
78
79        for &(dup_level, count) in &corrected_counts {
80            dedup_total += count;
81            raw_total += count * dup_level as f64;
82
83            // Map duplication level to one of 16 bins
84            let temp_dup_slot = dup_level as i64 - 1;
85
86            // The dupSlot < 0 is a kludge to handle duplication levels > 2^31
87            let dup_slot: usize = if !(0..=9999).contains(&temp_dup_slot) {
88                15
89            } else if temp_dup_slot > 4999 {
90                14
91            } else if temp_dup_slot > 999 {
92                13
93            } else if temp_dup_slot > 499 {
94                12
95            } else if temp_dup_slot > 99 {
96                11
97            } else if temp_dup_slot > 49 {
98                10
99            } else if temp_dup_slot > 9 {
100                9
101            } else {
102                temp_dup_slot as usize
103            };
104
105            total_percentages[dup_slot] += count * dup_level as f64;
106        }
107
108        // JAVA COMPAT: no zero guard, so empty input gives NaN like Java.
109        for tp in &mut total_percentages {
110            *tp = *tp / raw_total * 100.0;
111        }
112
113        // percentDifferentSeqs = (dedupTotal/rawTotal)*100
114        let percent_different_seqs = if raw_total == 0.0 {
115            100.0
116        } else {
117            (dedup_total / raw_total) * 100.0
118        };
119
120        self.computed = Some(ComputedLevels {
121            total_percentages,
122            percent_different_seqs,
123        });
124    }
125
126    fn ensure_calculated(&self) -> &ComputedLevels {
127        // SAFETY: finalize() must be called before any reporting method.
128        // If a caller skips finalize(), we provide a static default to avoid panicking.
129        static DEFAULT: ComputedLevels = ComputedLevels {
130            total_percentages: [0.0; 16],
131            percent_different_seqs: 100.0,
132        };
133        self.computed.as_ref().unwrap_or(&DEFAULT)
134    }
135}
136
137/// JAVA COMPAT: Java sums corrected counts in `HashMap<Long, Double>` order, and
138/// float addition is order-sensitive.
139fn sort_in_java_hashmap_order(entries: &mut [(u64, f64)]) {
140    let capacity = java_hashmap::table_capacity(entries.len());
141    entries.sort_unstable_by_key(|&(key, _)| {
142        (
143            java_hashmap::bucket(java_hashmap::long_hash(key), capacity),
144            key,
145        )
146    });
147}
148
149/// Replicates getCorrectedCount() from DuplicationLevel.java.
150/// Corrects observed counts to account for sequences that might have been missed
151/// because we stopped adding new unique sequences after the observation cutoff.
152fn get_corrected_count(
153    count_at_limit: u64,
154    total_count: u64,
155    duplication_level: u64,
156    number_of_observations: u64,
157) -> f64 {
158    // Bail out early if we saw all sequences
159    if count_at_limit == total_count {
160        return number_of_observations as f64;
161    }
162
163    // If not enough sequences left to hide another sequence with this count
164    if total_count - number_of_observations < count_at_limit {
165        return number_of_observations as f64;
166    }
167
168    // Calculate probability of NOT seeing a sequence with this duplication
169    // level within the first countAtLimit sequences
170    let mut p_not_seeing_at_limit: f64 = 1.0;
171
172    // Probability below which we stop caring (won't change count by 0.01)
173    let limit_of_caring =
174        1.0 - (number_of_observations as f64 / (number_of_observations as f64 + 0.01));
175
176    for i in 0..count_at_limit {
177        p_not_seeing_at_limit *=
178            ((total_count - i) - duplication_level) as f64 / (total_count - i) as f64;
179
180        if p_not_seeing_at_limit < limit_of_caring {
181            p_not_seeing_at_limit = 0.0;
182            break;
183        }
184    }
185
186    // Invert to get chance of seeing, then scale up
187    let p_seeing_at_limit = 1.0 - p_not_seeing_at_limit;
188    number_of_observations as f64 / p_seeing_at_limit
189}
190
191impl DuplicationLevel {
192    fn build_chart_svg(&self) -> String {
193        let computed = self.ensure_calculated();
194        // Java uses fixed maxCount=100 (percentage scale 0-100%), not dynamic
195        let max_count = 100.0_f64;
196
197        let labels: Vec<String> = LABELS.iter().map(|&l| l.to_string()).collect();
198
199        // Title uses Java's DecimalFormat("#.##") — up to 2 decimals, trailing zeros stripped
200        let pct_str = format!("{:.2}", computed.percent_different_seqs);
201        let pct_str = pct_str.trim_end_matches('0').trim_end_matches('.');
202        let title = format!("Percent of seqs remaining if deduplicated {}%", pct_str);
203
204        render_line_graph(&LineGraphData {
205            width: CHART_WIDTH,
206            data: vec![computed.total_percentages.to_vec()],
207            min_y: 0.0,
208            max_y: max_count,
209            x_label: "Sequence Duplication Level".to_string(),
210            series_names: vec!["% Total sequences".to_string()],
211            x_categories: labels,
212            title,
213        })
214    }
215}
216
217impl QCModule for DuplicationLevel {
218    fn cost_hint(&self) -> u32 {
219        // No per-sequence work; results come from OverRepresentedSeqs' shared data.
220        1
221    }
222
223    fn process_sequence(&mut self, _sequence: &Sequence) {
224        // DuplicationLevel doesn't process sequences itself;
225        // it uses the shared data from OverRepresentedSeqs.
226        //
227        // This MUST stay a no-op with respect to `shared_data`. The parallel
228        // pipeline (see runner.rs) partitions modules across worker threads
229        // assuming each module's per-sequence work is independent, so
230        // DuplicationLevel and OverRepresentedSeqs may run on different threads.
231        // OverRepresentedSeqs is the sole per-sequence writer of the shared
232        // `OverRepresentedData`; DuplicationLevel only reads it, once, at
233        // finalize (after all workers have joined). Adding per-sequence writes
234        // here would introduce a data race and make output depend on thread
235        // timing — breaking the byte-identical guarantee. If that ever becomes
236        // necessary, the two modules must instead be pinned to the same worker.
237    }
238
239    fn finalize(&mut self) {
240        self.calculate_levels();
241    }
242
243    fn name(&self) -> &str {
244        "Sequence Duplication Levels"
245    }
246
247    fn description(&self) -> &str {
248        "Plots the number of sequences which are duplicated to different levels"
249    }
250
251    fn reset(&mut self) {
252        self.computed = None;
253    }
254
255    fn raises_error(&self) -> bool {
256        let threshold = self.limits.threshold("duplication\terror", 50.0);
257        let computed = self.ensure_calculated();
258        // Error if percent different seqs is BELOW the threshold
259        computed.percent_different_seqs < threshold
260    }
261
262    fn raises_warning(&self) -> bool {
263        let threshold = self.limits.threshold("duplication\twarn", 70.0);
264        let computed = self.ensure_calculated();
265        // Warning if percent different seqs is BELOW the threshold
266        computed.percent_different_seqs < threshold
267    }
268
269    fn ignore_filtered_sequences(&self) -> bool {
270        // The ignoreFilteredSequences in Java checks the ignore config
271        self.limits.is_ignored("duplication")
272    }
273
274    fn ignore_in_report(&self) -> bool {
275        self.limits.is_ignored("duplication")
276    }
277
278    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
279        let computed = self.ensure_calculated();
280
281        // First line is the total deduplicated percentage
282        writeln!(
283            writer,
284            "#Total Deduplicated Percentage\t{}",
285            java_format_double(computed.percent_different_seqs)
286        )?;
287        // Header for the duplication level data
288        writeln!(writer, "#Duplication Level\tPercentage of total")?;
289
290        // Iterate labels and percentages together
291        for (i, (label_str, tp)) in LABELS
292            .iter()
293            .zip(computed.total_percentages.iter())
294            .enumerate()
295        {
296            let label = if i == 15 {
297                format!("{}+", label_str)
298            } else {
299                label_str.to_string()
300            };
301            writeln!(writer, "{}\t{}", label, java_format_double(*tp))?;
302        }
303
304        Ok(())
305    }
306
307    // Image filename matches Java's "duplication_levels.png" in Images/
308    fn chart_image_name(&self) -> Option<&str> {
309        Some("duplication_levels")
310    }
311    fn chart_alt_text(&self) -> Option<&str> {
312        Some("Duplication level graph")
313    }
314    fn generate_chart_svg(&self) -> Option<String> {
315        Some(self.build_chart_svg())
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn java_order(keys: impl IntoIterator<Item = u64>) -> Vec<u64> {
324        let mut entries: Vec<(u64, f64)> = keys.into_iter().map(|k| (k, 0.0)).collect();
325        sort_in_java_hashmap_order(&mut entries);
326        entries.into_iter().map(|(k, _)| k).collect()
327    }
328
329    #[test]
330    fn test_java_hashmap_order() {
331        // 12 keys fit 16 buckets, so 32 and 17 wrap to buckets 0 and 1.
332        assert_eq!(
333            java_order([32, 3, 17, 1, 2, 4, 5, 6, 7, 8, 9, 10]),
334            [32, 1, 17, 2, 3, 4, 5, 6, 7, 8, 9, 10]
335        );
336        // A 13th key grows the table to 32 buckets.
337        assert_eq!(
338            java_order((1..=12).chain([40])),
339            [1, 2, 3, 4, 5, 6, 7, 8, 40, 9, 10, 11, 12]
340        );
341    }
342
343    #[test]
344    fn test_levels_match_java_past_unique_limit() {
345        // Expected values from Java's calculateLevels() on the same counts.
346        let mut data = OverRepresentedData::new();
347        data.count = 2_000_000;
348        data.count_at_unique_limit = 150_000;
349        let levels = [(1, 90_000), (17, 200), (32, 100)]
350            .into_iter()
351            .chain((2..=12).map(|level| (level, 1_000)));
352        for (level, n) in levels {
353            for _ in 0..n {
354                let key = format!("S{}", data.sequences.len());
355                data.sequences.insert(key, level);
356            }
357        }
358
359        let mut module = DuplicationLevel::new(Arc::new(Mutex::new(data)), &Limits::new());
360        module.calculate_levels();
361        let computed = module.ensure_calculated();
362
363        assert_eq!(computed.percent_different_seqs, 88.61009166696547);
364        assert_eq!(
365            computed.total_percentages[..10],
366            [
367                86.21369673475012,
368                0.9952516712377281,
369                1.033504541142911,
370                1.072685751499248,
371                1.1127913683889874,
372                1.153816362824301,
373                1.1957546322473545,
374                1.238599026439594,
375                1.2823413776257666,
376                4.701558533844009,
377            ]
378        );
379        assert!(computed.total_percentages[10..].iter().all(|&p| p == 0.0));
380    }
381}