Skip to main content

fastqc_rust/modules/
kmer_content.rs

1// Kmer Content module
2// Corresponds to Modules/KmerContent.java
3
4use std::collections::HashMap;
5use std::io;
6
7use crate::config::{Limits, LimitsExt};
8use crate::modules::QCModule;
9use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
10use crate::report::charts::scaled_chart_width;
11use crate::sequence::Sequence;
12use crate::utils::base_group::BaseGroup;
13
14/// A tracked Kmer with its total count and per-position counts.
15struct Kmer {
16    sequence: String,
17    count: u64,
18    positions: Vec<u64>,
19}
20
21impl Kmer {
22    fn new(sequence: String, position: usize, seq_length: usize) -> Self {
23        let mut positions = vec![0u64; seq_length];
24        positions[position] = 1;
25        Kmer {
26            sequence,
27            count: 1,
28            positions,
29        }
30    }
31
32    fn increment_count(&mut self, position: usize) {
33        self.count += 1;
34        // Expand positions array if needed
35        if position >= self.positions.len() {
36            self.positions.resize(position + 1, 0);
37        }
38        self.positions[position] += 1;
39    }
40}
41
42pub struct KmerContent {
43    kmers: HashMap<String, Kmer>,
44    longest_sequence: usize,
45    /// 2D array - totalKmerCounts[position][kmer_length_index]
46    /// kmer_length_index = kmer_size - 1 (only one entry when min==max kmer size)
47    total_kmer_counts: Vec<Vec<u64>>,
48    skip_count: u64,
49    kmer_size: usize,
50    limits: Limits,
51    nogroup: bool,
52    expgroup: bool,
53    // Lazily computed
54    computed: Option<ComputedKmerResults>,
55}
56
57struct ComputedKmerResults {
58    enriched_kmers: Vec<EnrichedKmer>,
59    groups: Vec<BaseGroup>,
60}
61
62/// A post-calculation enriched kmer result for reporting.
63struct EnrichedKmer {
64    sequence: String,
65    /// count * 5 is reported (because 2% sampling = every 50th read, then * 5??)
66    /// Actually the Java code reports count*5 in getValueAt for the Count column
67    count: u64,
68    p_value: f32,
69    max_obs_exp: f32,
70    max_position_group: String,
71    /// Per-group obs/exp values for chart rendering.
72    /// Java stores these raw (not log2 transformed) even though the chart Y-axis says "Log2 Obs/Exp".
73    obs_exp_per_group: Vec<f32>,
74}
75
76impl KmerContent {
77    pub fn new(limits: &Limits, kmer_size: u8, nogroup: bool, expgroup: bool) -> Self {
78        let ks = kmer_size as usize;
79        KmerContent {
80            kmers: HashMap::with_capacity(4usize.pow(ks as u32)),
81            longest_sequence: 0,
82            total_kmer_counts: Vec::new(),
83            skip_count: 0,
84            kmer_size: ks,
85            limits: limits.clone(),
86            nogroup,
87            expgroup,
88            computed: None,
89        }
90    }
91
92    /// Replicates addKmerCount() - track total kmer counts per position.
93    /// Only counts if the kmer doesn't contain N.
94    /// Returns true if the kmer contains N (caller can skip further processing).
95    fn add_kmer_count(&mut self, position: usize, kmer_length: usize, kmer: &str) -> bool {
96        if position >= self.total_kmer_counts.len() {
97            // Expand array, new entries get a vec of size MAX_KMER_SIZE
98            let old_len = self.total_kmer_counts.len();
99            self.total_kmer_counts
100                .resize_with(position + 1, || vec![0u64; self.kmer_size]);
101            // Ensure old entries have correct length too (shouldn't be needed but safe)
102            for i in old_len..self.total_kmer_counts.len() {
103                if self.total_kmer_counts[i].len() < self.kmer_size {
104                    self.total_kmer_counts[i].resize(self.kmer_size, 0);
105                }
106            }
107        }
108
109        // Only count if kmer doesn't contain N
110        if kmer.contains('N') {
111            return true;
112        }
113
114        // kmer_length - 1 is the index (when min==max, always 0 offset from min)
115        self.total_kmer_counts[position][kmer_length - 1] += 1;
116        false
117    }
118
119    /// Replicates calculateEnrichment() from KmerContent.java.
120    fn calculate_enrichment(&mut self) {
121        if self.computed.is_some() {
122            return;
123        }
124
125        // Group positions for (longestSequence - MIN_KMER_SIZE) + 1
126        let group_length = if self.longest_sequence >= self.kmer_size {
127            (self.longest_sequence - self.kmer_size) + 1
128        } else {
129            0
130        };
131
132        let groups = BaseGroup::make_base_groups(group_length, self.nogroup, self.expgroup);
133
134        let mut uneven_kmers: Vec<(String, u64, f32, Vec<f32>, f32)> = Vec::new();
135
136        for kmer in self.kmers.values() {
137            let kmer_len = kmer.sequence.len();
138
139            // Total count of all kmers of this length across all positions
140            let mut total_kmer_count: u64 = 0;
141            for pos_counts in &self.total_kmer_counts {
142                if kmer_len - 1 < pos_counts.len() {
143                    total_kmer_count += pos_counts[kmer_len - 1];
144                }
145            }
146
147            if total_kmer_count == 0 {
148                continue;
149            }
150
151            // Expected proportion of this specific kmer
152            let expected_proportion = kmer.count as f32 / total_kmer_count as f32;
153
154            let mut obs_exp_positions = vec![0.0f32; groups.len()];
155            let mut binomial_p_values = vec![1.0f32; groups.len()];
156
157            for (g, group) in groups.iter().enumerate() {
158                let mut total_group_count: u64 = 0;
159                let mut total_group_hits: u64 = 0;
160
161                // Sum counts in this base group
162                let lower = group.lower_count; // 0-based
163                let upper = group.upper_count; // 0-based, inclusive
164
165                for p in lower..=upper {
166                    if p < self.total_kmer_counts.len()
167                        && kmer_len - 1 < self.total_kmer_counts[p].len()
168                    {
169                        total_group_count += self.total_kmer_counts[p][kmer_len - 1];
170                    }
171                    if p < kmer.positions.len() {
172                        total_group_hits += kmer.positions[p];
173                    }
174                }
175
176                let predicted = expected_proportion * total_group_count as f32;
177                // obs/exp ratio (not log2 transformed for the filter)
178                if predicted > 0.0 {
179                    obs_exp_positions[g] = total_group_hits as f32 / predicted;
180                }
181
182                // Binomial test with Bonferroni correction (4^k)
183                if total_group_hits as f32 > predicted && total_group_count > 0 {
184                    // Use the statrs binomial distribution for the p-value calculation
185                    let p_val = binomial_p_value(
186                        total_group_count,
187                        expected_proportion as f64,
188                        total_group_hits,
189                    );
190                    binomial_p_values[g] = (p_val * 4.0f64.powi(kmer_len as i32)) as f32;
191                }
192            }
193
194            // Keep if any position has p<0.01 AND obs/exp>5
195            let mut lowest_p_value: f32 = 1.0;
196            for i in 0..binomial_p_values.len() {
197                if binomial_p_values[i] < 0.01
198                    && obs_exp_positions[i] > 5.0
199                    && binomial_p_values[i] < lowest_p_value
200                {
201                    lowest_p_value = binomial_p_values[i];
202                }
203            }
204
205            if lowest_p_value < 0.01 {
206                uneven_kmers.push((
207                    kmer.sequence.clone(),
208                    kmer.count,
209                    lowest_p_value,
210                    obs_exp_positions,
211                    0.0, // max_obs_exp calculated below
212                ));
213            }
214        }
215
216        // Calculate max obs/exp and sort by it descending
217        for entry in &mut uneven_kmers {
218            entry.4 = entry.3.iter().cloned().fold(0.0f32, f32::max);
219        }
220        // Sort by highest obs/exp ratio
221        uneven_kmers.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal));
222
223        // Only report top 20
224        uneven_kmers.truncate(20);
225
226        let enriched_kmers: Vec<EnrichedKmer> = uneven_kmers
227            .iter()
228            .map(|(seq, count, p_value, obs_exp, max_oe)| {
229                // Find max position (1-based index into groups)
230                let mut max_pos = 0;
231                let mut max_val = 0.0f32;
232                for (i, &v) in obs_exp.iter().enumerate() {
233                    if v > max_val {
234                        max_val = v;
235                        max_pos = i;
236                    }
237                }
238                let max_position_group = if !groups.is_empty() {
239                    groups[max_pos].label()
240                } else {
241                    String::new()
242                };
243                EnrichedKmer {
244                    sequence: seq.clone(),
245                    // count*5 because 2% sampling (every 50th read)
246                    count: *count * 5,
247                    p_value: *p_value,
248                    max_obs_exp: *max_oe,
249                    max_position_group,
250                    obs_exp_per_group: obs_exp.clone(),
251                }
252            })
253            .collect();
254
255        self.computed = Some(ComputedKmerResults {
256            enriched_kmers,
257            groups,
258        });
259    }
260
261    fn ensure_calculated(&self) -> &ComputedKmerResults {
262        static DEFAULT: ComputedKmerResults = ComputedKmerResults {
263            enriched_kmers: Vec::new(),
264            groups: Vec::new(),
265        };
266        self.computed.as_ref().unwrap_or(&DEFAULT)
267    }
268}
269
270/// Calculate binomial p-value: P(X > k) = 1 - CDF(k) for Binomial(n, p).
271/// Uses the statrs crate for the binomial CDF.
272fn binomial_p_value(n: u64, p: f64, k: u64) -> f64 {
273    use statrs::distribution::Binomial;
274    use statrs::distribution::DiscreteCDF;
275
276    if n == 0 || p <= 0.0 || p >= 1.0 {
277        return 1.0;
278    }
279
280    match Binomial::new(p, n) {
281        Ok(binom) => {
282            // P(X > k) = 1 - P(X <= k) = 1 - CDF(k)
283            1.0 - binom.cdf(k)
284        }
285        Err(_) => 1.0,
286    }
287}
288
289impl KmerContent {
290    /// Build the SVG chart showing obs/exp ratios for top enriched kmers.
291    ///
292    /// Java's makeReport() creates a LineGraph with the top 6 enriched kmers'
293    /// obs/exp values per position group. The Y-axis label is "Log2 Obs/Exp" even though
294    /// the values are raw obs/exp ratios (not log2 transformed) -- this is a quirk in Java.
295    fn build_chart_svg(&self) -> Option<String> {
296        let computed = self.computed.as_ref()?;
297
298        if computed.enriched_kmers.is_empty() {
299            return None;
300        }
301
302        // Only plot top 6 enriched kmers on the chart
303        let num_series = computed.enriched_kmers.len().min(6);
304
305        let x_categories: Vec<String> = computed.groups.iter().map(|g| g.label()).collect();
306
307        let mut data: Vec<Vec<f64>> = Vec::with_capacity(num_series);
308        let mut series_names: Vec<String> = Vec::with_capacity(num_series);
309        let mut max_y: f64 = 0.0;
310
311        for k in 0..num_series {
312            let kmer = &computed.enriched_kmers[k];
313            let values: Vec<f64> = kmer.obs_exp_per_group.iter().map(|&v| v as f64).collect();
314            for &v in &values {
315                if v > max_y {
316                    max_y = v;
317                }
318            }
319            data.push(values);
320            series_names.push(kmer.sequence.clone());
321        }
322
323        // minGraphValue is forced to 0
324        let min_y = 0.0;
325        // Ensure max_y is at least 1 to avoid degenerate axis
326        if max_y < 1.0 {
327            max_y = 1.0;
328        }
329
330        Some(render_line_graph(&LineGraphData {
331            width: scaled_chart_width(x_categories.len()),
332            data,
333            min_y,
334            max_y,
335            x_label: "Position in read (bp)".to_string(),
336            series_names,
337            x_categories,
338            // Title says "Log2 Obs/Exp" even though values are raw obs/exp ratios
339            title: "Log2 Obs/Exp".to_string(),
340        }))
341    }
342}
343
344impl QCModule for KmerContent {
345    fn cost_hint(&self) -> u32 {
346        // Only enabled when explicitly requested; when on it is among the
347        // heaviest modules (per-position k-mer counting over the whole read).
348        12
349    }
350
351    fn process_sequence(&mut self, sequence: &Sequence) {
352        self.computed = None;
353
354        // Only sample 2% of reads (every 50th)
355        self.skip_count += 1;
356        if !self.skip_count.is_multiple_of(50) {
357            return;
358        }
359
360        // Limit read length to 500bp to avoid memory issues
361        let seq_str = std::str::from_utf8(&sequence.sequence).unwrap_or("");
362        let seq = if seq_str.len() > 500 {
363            &seq_str[..500]
364        } else {
365            seq_str
366        };
367
368        if seq.len() > self.longest_sequence {
369            self.longest_sequence = seq.len();
370        }
371
372        let kmer_size = self.kmer_size;
373
374        // Iterate over all kmers (only one size when min==max)
375        if seq.len() >= kmer_size {
376            for i in 0..=(seq.len() - kmer_size) {
377                let kmer = &seq[i..i + kmer_size];
378
379                // Always add to total counts (even if contains N).
380                // add_kmer_count returns true if kmer contains N, so we can skip
381                // the HashMap lookup without scanning for 'N' a second time.
382                if self.add_kmer_count(i, kmer_size, kmer) {
383                    continue;
384                }
385
386                if let Some(existing) = self.kmers.get_mut(kmer) {
387                    existing.increment_count(i);
388                } else {
389                    let seq_kmer_length = (seq.len() - kmer_size) + 1;
390                    self.kmers.insert(
391                        kmer.to_string(),
392                        Kmer::new(kmer.to_string(), i, seq_kmer_length),
393                    );
394                }
395            }
396        }
397    }
398
399    fn finalize(&mut self) {
400        self.calculate_enrichment();
401    }
402
403    fn name(&self) -> &str {
404        "Kmer Content"
405    }
406
407    fn description(&self) -> &str {
408        "Identifies short sequences which have uneven representation"
409    }
410
411    fn reset(&mut self) {
412        self.kmers.clear();
413        self.total_kmer_counts.clear();
414        self.longest_sequence = 0;
415        self.skip_count = 0;
416        self.computed = None;
417    }
418
419    fn raises_error(&self) -> bool {
420        let threshold = self.limits.threshold("kmer\terror", 5.0);
421        let computed = self.ensure_calculated();
422        // Error if -log10(pvalue) of most enriched kmer exceeds threshold
423        computed
424            .enriched_kmers
425            .first()
426            .is_some_and(|k| -(k.p_value as f64).log10() > threshold)
427    }
428
429    fn raises_warning(&self) -> bool {
430        let threshold = self.limits.threshold("kmer\twarn", 2.0);
431        let computed = self.ensure_calculated();
432        // Warning if -log10(pvalue) of most enriched kmer exceeds threshold
433        computed
434            .enriched_kmers
435            .first()
436            .is_some_and(|k| -(k.p_value as f64).log10() > threshold)
437    }
438
439    fn ignore_filtered_sequences(&self) -> bool {
440        true
441    }
442
443    fn ignore_in_report(&self) -> bool {
444        // Default is ignore=1 (ignored by default)
445        self.limits.threshold("kmer\tignore", 1.0) > 0.0
446    }
447
448    // Image filename matches Java's "kmer_profiles.png" in Images/
449    fn chart_image_name(&self) -> Option<&str> {
450        Some("kmer_profiles")
451    }
452    fn chart_alt_text(&self) -> Option<&str> {
453        Some("Kmer graph")
454    }
455    fn generate_chart_svg(&self) -> Option<String> {
456        self.build_chart_svg()
457    }
458
459    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
460        let computed = self.ensure_calculated();
461
462        // Table header
463        writeln!(
464            writer,
465            "#Sequence\tCount\tPValue\tObs/Exp Max\tMax Obs/Exp Position"
466        )?;
467
468        for kmer in &computed.enriched_kmers {
469            writeln!(
470                writer,
471                "{}\t{}\t{}\t{}\t{}",
472                kmer.sequence, kmer.count, kmer.p_value, kmer.max_obs_exp, kmer.max_position_group
473            )?;
474        }
475
476        Ok(())
477    }
478}