Skip to main content

fastqc_rust/modules/
basic_stats.rs

1// Basic Statistics module
2// Corresponds to Modules/BasicStats.java
3
4use std::io;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7
8use crate::config::Limits;
9use crate::modules::QCModule;
10use crate::sequence::Sequence;
11use crate::utils::base_counts::{count_acgt, IDX_A, IDX_C, IDX_G, IDX_T};
12use crate::utils::phred;
13
14/// How often to check whether the display wants a fresh snapshot. Small enough
15/// that a slow file (nanopore reads take orders of magnitude longer than
16/// Illumina ones) still looks alive, and large enough that the per-read cost
17/// rounds to nothing.
18const PUBLISH_INTERVAL: u32 = 256;
19
20/// The raw accumulated counters behind the Basic Statistics table.
21///
22/// Kept separate from [`BasicStats`] so that exactly the same numbers can be
23/// formatted for the text/HTML report and for the live terminal table, with
24/// [`BasicStatsCounters::rows`] as the single source of truth for both — which
25/// is also why the counters themselves are private: everything outside this
26/// module wants the formatted rows, not the raw tallies. It is `Copy` so a
27/// consistent snapshot can be handed to the progress display without holding a
28/// lock while rendering.
29#[derive(Debug, Clone, Copy)]
30pub struct BasicStatsCounters {
31    actual_count: u64,
32    min_length: usize,
33    max_length: usize,
34    total_bases: u64,
35    g_count: u64,
36    c_count: u64,
37    a_count: u64,
38    t_count: u64,
39    lowest_char: u16,
40    // Set by QCModule::set_phred_encoding; see the trait docs.
41    known_encoding: Option<phred::PhredEncoding>,
42    /// Whether the base calls were converted from colorspace, taken from the
43    /// first sequence. `None` until then.
44    colorspace: Option<bool>,
45    /// Derived from [`BasicStats`]'s length histogram, which is too big to copy
46    /// into every snapshot; refreshed whenever the counters are published.
47    median_length: usize,
48}
49
50/// Spelled out rather than derived: `lowest_char` starts at a sentinel above
51/// the range and is lowered, so a derived all-zeroes default would be a state
52/// the counters can never legitimately reach — and one whose `rows()` reports
53/// a bogus encoding. This is the only constructor, so there is nowhere for that
54/// state to come from.
55impl Default for BasicStatsCounters {
56    fn default() -> Self {
57        BasicStatsCounters {
58            actual_count: 0,
59            min_length: 0,
60            max_length: 0,
61            total_bases: 0,
62            g_count: 0,
63            c_count: 0,
64            a_count: 0,
65            t_count: 0,
66            lowest_char: phred::NO_QUALITY_SEEN,
67            known_encoding: None,
68            colorspace: None,
69            median_length: 0,
70        }
71    }
72}
73
74impl BasicStatsCounters {
75    /// The measures reported, in report order, minus the leading "Filename"
76    /// row. [`Self::rows`] returns a value for each of these, in this order.
77    pub const MEASURES: [&'static str; 8] = [
78        "File type",
79        "Encoding",
80        "Total Sequences",
81        "Total Bases",
82        "Sequence length",
83        "Mean Length",
84        "Median Length",
85        "%GC",
86    ];
87
88    /// The Basic Statistics rows, minus the leading "Filename" row, in report
89    /// order. Both the text report and the live progress table render from
90    /// this, so the values on screen always agree with the values on disk.
91    pub fn rows(&self) -> Vec<(&'static str, String)> {
92        // JAVA COMPAT: Java prints its unset String as "null" for empty input.
93        let file_type = match self.colorspace {
94            None => "null",
95            Some(true) => "Colorspace converted to bases",
96            Some(false) => "Conventional base calls",
97        };
98
99        // The report propagates a resolve failure before rendering rows (see
100        // write_text_report), so "Unknown" only ever reaches the live table.
101        let encoding = phred::resolve(self.known_encoding, self.lowest_char)
102            .map(|e| e.name.to_string())
103            .unwrap_or_else(|_| "Unknown".to_string());
104
105        let sequence_length = if self.min_length == self.max_length {
106            self.min_length.to_string()
107        } else {
108            format!("{}-{}", self.min_length, self.max_length)
109        };
110
111        // JAVA COMPAT: integer division
112        let mean_length = self.total_bases.checked_div(self.actual_count).unwrap_or(0);
113
114        // JAVA COMPAT: Integer division: ((gCount+cCount)*100)/(aCount+tCount+gCount+cCount)
115        let total = self.a_count + self.t_count + self.g_count + self.c_count;
116        let gc = ((self.g_count + self.c_count) * 100)
117            .checked_div(total)
118            .unwrap_or(0);
119
120        let values = [
121            file_type.to_string(),
122            encoding,
123            self.actual_count.to_string(),
124            format_length(self.total_bases),
125            sequence_length,
126            mean_length.to_string(),
127            self.median_length.to_string(),
128            gc.to_string(),
129        ];
130        Self::MEASURES.into_iter().zip(values).collect()
131    }
132}
133
134/// A snapshot of the Basic Statistics counters that a [`BasicStats`] module
135/// publishes as it works, so another thread (the progress display) can read
136/// partial results while the file is still being analysed.
137///
138/// `None` until the first publication, which lets the reader distinguish
139/// "nothing counted yet" from "genuinely zero".
140///
141/// Publishing costs a pass over the read-length histogram for the median,
142/// which for long reads is far more than the module's per-read work. So the
143/// module only publishes when asked: once to start with, and then each time
144/// the display has drawn the last snapshot and wants another. A table that is
145/// hidden asks for nothing.
146pub struct LiveStats {
147    snapshot: Mutex<Option<BasicStatsCounters>>,
148    wanted: AtomicBool,
149}
150
151impl Default for LiveStats {
152    fn default() -> Self {
153        Self {
154            snapshot: Mutex::new(None),
155            wanted: AtomicBool::new(true),
156        }
157    }
158}
159
160impl LiveStats {
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Ask the module for a fresh snapshot at its next opportunity.
166    pub fn request(&self) {
167        self.wanted.store(true, Ordering::Relaxed);
168    }
169
170    /// Whether a snapshot has been asked for, clearing the request.
171    fn take_request(&self) -> bool {
172        self.wanted.load(Ordering::Relaxed) && self.wanted.swap(false, Ordering::Relaxed)
173    }
174
175    /// The most recently published counters, or `None` if the module has not
176    /// processed any sequences yet.
177    pub fn snapshot(&self) -> Option<BasicStatsCounters> {
178        *self.snapshot.lock().unwrap_or_else(|e| e.into_inner())
179    }
180
181    fn publish(&self, counters: BasicStatsCounters) {
182        *self.snapshot.lock().unwrap_or_else(|e| e.into_inner()) = Some(counters);
183    }
184}
185
186/// Format a base count into a human-readable string.
187///
188/// Replicates `BasicStats.formatLength(long)` exactly, including
189/// its custom decimal truncation logic (keeps at most 1 non-zero decimal digit).
190pub fn format_length(original_length: u64) -> String {
191    let mut length = original_length as f64;
192
193    let unit = if length >= 1_000_000_000.0 {
194        length /= 1_000_000_000.0;
195        " Gbp"
196    } else if length >= 1_000_000.0 {
197        length /= 1_000_000.0;
198        " Mbp"
199    } else if length >= 1_000.0 {
200        length /= 1_000.0;
201        " kbp"
202    } else {
203        " bp"
204    };
205
206    // JAVA COMPAT: Java builds `"" + length` which calls Double.toString(),
207    // then applies a custom truncation: find the dot, keep one more char if
208    // it's non-zero, otherwise drop the dot.
209    let raw = format!("{}", length);
210    let chars: Vec<char> = raw.chars().collect();
211
212    let mut last_index = 0;
213
214    // Find the dot
215    for (i, &ch) in chars.iter().enumerate() {
216        last_index = i;
217        if ch == '.' {
218            break;
219        }
220    }
221
222    // Keep next char if non-zero
223    if last_index + 1 < chars.len() && chars[last_index + 1] != '0' {
224        last_index += 1;
225    } else if last_index > 0 && chars[last_index] == '.' {
226        // Lose the dot if it would be the last character
227        last_index -= 1;
228    }
229
230    let truncated: String = chars[..=last_index].iter().collect();
231    format!("{}{}", truncated, unit)
232}
233
234pub struct BasicStats {
235    name: Option<String>,
236    counters: BasicStatsCounters,
237    // Java reads the median from the Sequence Length Distribution module,
238    // which skips filtered reads, so this does too.
239    length_counts: Vec<u64>,
240    /// Optional live snapshot sink for the terminal progress table.
241    live: Option<Arc<LiveStats>>,
242}
243
244impl BasicStats {
245    pub fn new(_limits: &Limits) -> Self {
246        BasicStats {
247            name: None,
248            counters: BasicStatsCounters::default(),
249            length_counts: Vec::new(),
250            live: None,
251        }
252    }
253
254    /// Set the filename, stripping any "stdin:" prefix.
255    ///
256    /// Matches `setFileName()` which strips "stdin:" prefix.
257    pub fn set_file_name(&mut self, name: &str) {
258        let name = name.strip_prefix("stdin:").unwrap_or(name);
259        self.name = Some(name.to_string());
260    }
261
262    /// Push the current counters to the live snapshot, if one is attached.
263    fn publish(&mut self) {
264        if let Some(ref live) = self.live {
265            self.counters.median_length = median_length(&self.length_counts);
266            live.publish(self.counters);
267        }
268    }
269}
270
271/// Replicates `SequenceLengthDistribution.medianLength()`: the upper of the
272/// two central values for an even read count, 0 when there are no reads.
273fn median_length(length_counts: &[u64]) -> usize {
274    let rank50 = length_counts.iter().sum::<u64>() / 2;
275    let mut running = 0;
276    for (len, &count) in length_counts.iter().enumerate() {
277        running += count;
278        if running > rank50 {
279            return len;
280        }
281    }
282    0
283}
284
285impl QCModule for BasicStats {
286    fn cost_hint(&self) -> u32 {
287        2
288    }
289
290    fn process_sequence(&mut self, sequence: &Sequence) {
291        // Publish a snapshot for the live progress table every so often.
292        // Taken before this sequence is added so that nothing is published
293        // until there is something to show.
294        let seen = self.counters.actual_count;
295        if seen != 0
296            && seen.is_multiple_of(PUBLISH_INTERVAL as u64)
297            && self.live.as_ref().is_some_and(|live| live.take_request())
298        {
299            self.publish();
300        }
301
302        let c = &mut self.counters;
303        c.actual_count += 1;
304        c.total_bases += sequence.sequence.len() as u64;
305
306        let len = sequence.sequence.len();
307        if !sequence.is_filtered {
308            if self.length_counts.len() <= len {
309                self.length_counts.resize(len + 1, 0);
310            }
311            self.length_counts[len] += 1;
312        }
313        if c.actual_count == 1 {
314            c.colorspace = Some(sequence.colorspace.is_some());
315            c.min_length = len;
316            c.max_length = len;
317        } else {
318            c.min_length = c.min_length.min(len);
319            c.max_length = c.max_length.max(len);
320        }
321
322        let counts = count_acgt(&sequence.sequence);
323        c.a_count += counts[IDX_A];
324        c.c_count += counts[IDX_C];
325        c.g_count += counts[IDX_G];
326        c.t_count += counts[IDX_T];
327
328        if let Some(lowest) = sequence.quality.iter().copied().min() {
329            c.lowest_char = c.lowest_char.min(lowest as u16);
330        }
331    }
332
333    fn attach_live_stats(&mut self, live: Arc<LiveStats>) {
334        self.live = Some(live);
335    }
336
337    /// Publish the final counters so the progress table ends on exactly the
338    /// values that go into the report.
339    fn finalize(&mut self) {
340        if self.counters.known_encoding.is_none() {
341            if let Some(warning) = phred::phred64_suspicion(self.counters.lowest_char) {
342                // Files run concurrently, so say which one this is about.
343                crate::progress::log_line(&match &self.name {
344                    Some(name) => format!("{name}: {warning}"),
345                    None => warning,
346                });
347            }
348        }
349        self.publish();
350    }
351
352    fn set_filename(&mut self, name: &str) {
353        self.set_file_name(name);
354    }
355
356    fn set_phred_encoding(&mut self, encoding: phred::PhredEncoding) {
357        self.counters.known_encoding = Some(encoding);
358    }
359
360    fn name(&self) -> &str {
361        "Basic Statistics"
362    }
363
364    fn description(&self) -> &str {
365        "Calculates some basic statistics about the file"
366    }
367
368    fn reset(&mut self) {
369        self.counters.min_length = 0;
370        self.counters.max_length = 0;
371        self.counters.g_count = 0;
372        self.counters.c_count = 0;
373        self.counters.a_count = 0;
374        self.counters.t_count = 0;
375    }
376
377    // BasicStats never raises error or warning
378    fn raises_error(&self) -> bool {
379        false
380    }
381
382    fn raises_warning(&self) -> bool {
383        false
384    }
385
386    fn ignore_filtered_sequences(&self) -> bool {
387        false
388    }
389
390    fn ignore_in_report(&self) -> bool {
391        false
392    }
393
394    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
395        // Java fails the whole file when the encoding can't be resolved.
396        phred::resolve(self.counters.known_encoding, self.counters.lowest_char)
397            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
398
399        // Header row matches writeTextTable output from AbstractQCModule
400        writeln!(writer, "#Measure\tValue")?;
401
402        // Row 0: Filename
403        writeln!(writer, "Filename\t{}", self.name.as_deref().unwrap_or(""))?;
404
405        let counters = BasicStatsCounters {
406            median_length: median_length(&self.length_counts),
407            ..self.counters
408        };
409        for (measure, value) in counters.rows() {
410            writeln!(writer, "{}\t{}", measure, value)?;
411        }
412
413        Ok(())
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_format_length_bp() {
423        assert_eq!(format_length(16), "16 bp");
424        assert_eq!(format_length(80), "80 bp");
425        assert_eq!(format_length(999), "999 bp");
426    }
427
428    #[test]
429    fn test_format_length_kbp() {
430        assert_eq!(format_length(1000), "1 kbp");
431        assert_eq!(format_length(1500), "1.5 kbp");
432        assert_eq!(format_length(10000), "10 kbp");
433    }
434
435    #[test]
436    fn test_format_length_mbp() {
437        assert_eq!(format_length(1_000_000), "1 Mbp");
438        assert_eq!(format_length(1_200_000), "1.2 Mbp");
439    }
440
441    #[test]
442    fn test_format_length_gbp() {
443        assert_eq!(format_length(1_000_000_000), "1 Gbp");
444    }
445
446    fn sequences(count: usize) -> Vec<Sequence> {
447        (0..count)
448            .map(|i| {
449                // Vary the length so min != max, and the GC content with it.
450                let len = 40 + (i % 7);
451                let bases: Vec<u8> = (0..len).map(|p| b"ACGTGGCN"[(i * 3 + p) % 8]).collect();
452                let quality = vec![b'I'; len];
453                Sequence::new(format!("READ{}", i), bases, quality)
454            })
455            .collect()
456    }
457
458    fn text_rows(module: &BasicStats) -> Vec<(String, String)> {
459        let mut buf = Vec::new();
460        module.write_text_report(&mut buf).expect("text report");
461        String::from_utf8(buf)
462            .expect("utf8")
463            .lines()
464            .skip(2) // "#Measure\tValue" header and the Filename row
465            .filter_map(|line| line.split_once('\t'))
466            .map(|(k, v)| (k.to_string(), v.to_string()))
467            .collect()
468    }
469
470    /// The live snapshot the progress table renders from must end up holding
471    /// exactly the values the report is written from — that equality is the
472    /// whole point of publishing counters rather than recomputing them.
473    #[test]
474    fn test_live_snapshot_matches_report() {
475        let limits = Limits::new();
476        let live = Arc::new(LiveStats::new());
477        let mut module = BasicStats::new(&limits);
478        module.set_file_name("sample.fastq");
479        module.attach_live_stats(Arc::clone(&live));
480
481        // Nothing published before any sequence has been seen, so the table
482        // can show "-" rather than a misleading row of zeroes.
483        assert!(live.snapshot().is_none());
484
485        // Enough sequences to cross the publish interval several times.
486        let seqs = sequences(PUBLISH_INTERVAL as usize * 4);
487        for seq in &seqs {
488            module.process_sequence(seq);
489        }
490
491        // Mid-run the snapshot is behind, but never ahead, of the true count.
492        let mid = live.snapshot().expect("published during the run");
493        assert!(mid.actual_count > 0);
494        assert!(mid.actual_count <= seqs.len() as u64);
495
496        module.finalize();
497        let final_snapshot = live.snapshot().expect("published at finalize");
498        assert_eq!(final_snapshot.actual_count, seqs.len() as u64);
499
500        let expected: Vec<(String, String)> = final_snapshot
501            .rows()
502            .into_iter()
503            .map(|(k, v)| (k.to_string(), v))
504            .collect();
505        assert_eq!(text_rows(&module), expected);
506    }
507
508    /// After the first snapshot, the module publishes only when the display
509    /// asks for one, so a hidden table costs nothing.
510    #[test]
511    fn test_live_stats_publish_on_request() {
512        let limits = Limits::new();
513        let live = Arc::new(LiveStats::new());
514        let mut module = BasicStats::new(&limits);
515        module.attach_live_stats(Arc::clone(&live));
516
517        let seqs = sequences(PUBLISH_INTERVAL as usize * 4);
518        let (first, rest) = seqs.split_at(PUBLISH_INTERVAL as usize * 2);
519        for seq in first {
520            module.process_sequence(seq);
521        }
522        let published = live.snapshot().expect("first snapshot").actual_count;
523        assert_eq!(published, PUBLISH_INTERVAL as u64);
524
525        let (unasked, asked) = rest.split_at(PUBLISH_INTERVAL as usize);
526        for seq in unasked {
527            module.process_sequence(seq);
528        }
529        assert_eq!(live.snapshot().unwrap().actual_count, published);
530
531        live.request();
532        for seq in asked {
533            module.process_sequence(seq);
534        }
535        assert!(live.snapshot().unwrap().actual_count > published);
536    }
537
538    /// Publishing is opt-in: a module with no sink attached still reports.
539    #[test]
540    fn test_no_live_stats_by_default() {
541        let limits = Limits::new();
542        let mut module = BasicStats::new(&limits);
543        for seq in &sequences(100) {
544            module.process_sequence(seq);
545        }
546        module.finalize();
547        let rows = text_rows(&module);
548        assert_eq!(rows[2], ("Total Sequences".to_string(), "100".to_string()));
549    }
550
551    /// Counters that have seen nothing still render every row, so the live
552    /// table has something to show before a file has been opened — including
553    /// a `%GC` that does not divide by zero.
554    #[test]
555    fn test_counters_rows_placeholder_state() {
556        let counters = BasicStatsCounters::default();
557        let rows = counters.rows();
558        assert_eq!(rows.len(), 8);
559        assert_eq!(rows[0], ("File type", "null".to_string()));
560        assert_eq!(rows[2], ("Total Sequences", "0".to_string()));
561        // No bases at all must not divide by zero.
562        assert_eq!(rows[7], ("%GC", "0".to_string()));
563    }
564}