Skip to content

Commit 43e4acd

Browse files
luispedroclaude
andcommitted
NEW FASTQ QC statistics and qcstats TSV (milestone 4)
Collect per-file FASTQ statistics (base composition, GC / non-ATCG fractions, sequence-length range, encoding) as fastq/paired/preprocess run, in registration order. qcstats({fastq}) serialises them to the transposed TSV produced by writeOutputTSV and returns a counts file; write copies it out. This required a faithful port of Haskell's `show :: Double -> String` (fixed-point for exponents 0..7, scientific otherwise, e.g. 3.896103896103896e-2) in values::show_double, reusing Rust's shortest-round-tripping digits. Also register qcstats as an always-loaded builtin (it comes from the stats module), add the file-backed NGOCounts value, and label preprocess output stats with the statement line number (preproc.lnoN.*). With this, tests/preprocess3 passes against the Rust binary: paired preprocess with gzip output (compared by decompressed content via check.sh) and a byte-identical output.fqstats.tsv. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 70703cb commit 43e4acd

6 files changed

Lines changed: 320 additions & 48 deletions

File tree

rust-migration.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,21 @@
2929
> files (encoding-checked, empty singles dropped), `preprocess` processes mates in lockstep
3030
> (both survive → pair, one survives → singleton via `keep_singles`), and `write` derives
3131
> `pair.1`/`pair.2`/`singles` names (`_formatFQOname`) and concatenates per-slot files.
32+
> FASTQ QC statistics are collected as `fastq`/`paired`/`preprocess` run (per-file base
33+
> composition, GC/non-ATCG fractions, sequence-length range, encoding), and `qcstats({fastq})`
34+
> serialises them to the transposed TSV (mirroring `writeOutputTSV`); `write` of the resulting
35+
> counts file copies it out. This needed a faithful port of Haskell's `show :: Double -> String`
36+
> (fixed vs. scientific notation, e.g. `3.896103896103896e-2`) in `values::show_double`.
3237
> **First functional tests now pass against the Rust binary** (identical output to the
3338
> committed `expected.*`): `tests/write_fq`, `tests/write_fq_inline`, `tests/preprocess`
34-
> (all six outputs: copy, substrim, endstrim, smoothtrim, `avg_quality` filter, and
35-
> `n_to_zero_quality`), `tests/regression-fqgz` (gz input → uncompressed output), and
36-
> `tests/preprocess3_empty_singles` (paired preprocess `pair.1`/`pair.2`). Their
39+
> (copy, substrim, endstrim, smoothtrim, `avg_quality` filter, `n_to_zero_quality`),
40+
> `tests/regression-fqgz` (gz input), `tests/preprocess3_empty_singles` (paired preprocess),
41+
> and `tests/preprocess3` (paired preprocess + gz output + `qcstats` TSV). Their
3742
> `ngless "1.1"` headers were bumped to `"1.5"` (these features have only minimum-version
3843
> checks, no version-conditional behavior, so Haskell output is unchanged).
3944
> Simplifications to lift next: files are read whole rather than streamed (no
40-
> `FileOrStream`/bounded queues), bzip2/zstd compression, and FASTQ QC statistics
41-
> (`qcstats`, needed by `tests/preprocess3`). Still not started: module loading and the
42-
> `map`/`count`/SAM subsystems.
45+
> `FileOrStream`/bounded queues), bzip2/zstd compression, and per-position quality
46+
> percentiles. Still not started: module loading and the `map`/`count`/SAM subsystems.
4347
4448
## Context
4549

src/fastq.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,72 @@ impl FastQEncoding {
5252
FastQEncoding::Solexa => 64,
5353
}
5454
}
55+
56+
/// Human-readable name, matching `encodingName` (used in the qcstats TSV).
57+
pub fn name(self) -> &'static str {
58+
match self {
59+
FastQEncoding::Sanger => "Sanger (33 offset)",
60+
FastQEncoding::Solexa => "Solexa (64 offset)",
61+
}
62+
}
63+
}
64+
65+
/// Per-file FASTQ statistics, mirroring the relevant fields of `FQStatistics` plus the derived
66+
/// `gcFraction`/`nonATCGFrac`/`nBasepairs`.
67+
#[derive(Clone, Debug, PartialEq)]
68+
pub struct FastQStats {
69+
pub n_seq: i64,
70+
pub min_len: i64,
71+
pub max_len: i64,
72+
/// Base counts: (A, C, G, T, other), each case-insensitive.
73+
pub bp: (i64, i64, i64, i64, i64),
74+
}
75+
76+
impl FastQStats {
77+
pub fn num_basepairs(&self) -> i64 {
78+
let (a, c, g, t, o) = self.bp;
79+
a + c + g + t + o
80+
}
81+
82+
/// GC fraction over A/C/G/T only (mirrors `gcFraction`).
83+
pub fn gc_fraction(&self) -> f64 {
84+
let (a, c, g, t, _) = self.bp;
85+
(c + g) as f64 / (a + c + g + t) as f64
86+
}
87+
88+
/// Fraction of non-ATCG bases over all bases (mirrors `nonATCGFrac`).
89+
pub fn non_atcg_fraction(&self) -> f64 {
90+
let (a, c, g, t, o) = self.bp;
91+
o as f64 / (a + c + t + g + o) as f64
92+
}
93+
}
94+
95+
/// Compute FASTQ statistics from decoded reads (mirrors `fqStatsC`). For an empty input the
96+
/// sequence-length range is `(maxBound, 0)` as in the Haskell code.
97+
pub fn stats_from_reads(reads: &[ShortRead]) -> FastQStats {
98+
let (mut a, mut c, mut g, mut t, mut o) = (0i64, 0i64, 0i64, 0i64, 0i64);
99+
let mut min_len = i64::MAX;
100+
let mut max_len = 0i64;
101+
for r in reads {
102+
let len = r.sequence.len() as i64;
103+
min_len = min_len.min(len);
104+
max_len = max_len.max(len);
105+
for &b in r.sequence.as_bytes() {
106+
match b.to_ascii_lowercase() {
107+
b'a' => a += 1,
108+
b'c' => c += 1,
109+
b'g' => g += 1,
110+
b't' => t += 1,
111+
_ => o += 1,
112+
}
113+
}
114+
}
115+
FastQStats {
116+
n_seq: reads.len() as i64,
117+
min_len,
118+
max_len,
119+
bp: (a, c, g, t, o),
120+
}
55121
}
56122

57123
impl ShortRead {
@@ -435,6 +501,25 @@ mod tests {
435501
);
436502
}
437503

504+
#[test]
505+
fn stats_from_reads_cases() {
506+
// Two reads, case-insensitive base counting, one N (non-ATCG).
507+
let reads = vec![
508+
ShortRead::new("a", "ACGT", vec![30, 30, 30, 30]),
509+
ShortRead::new("b", "acgN", vec![20, 20, 20, 20]),
510+
];
511+
let st = stats_from_reads(&reads);
512+
assert_eq!(st.n_seq, 2);
513+
assert_eq!(st.min_len, 4);
514+
assert_eq!(st.max_len, 4);
515+
assert_eq!(st.bp, (2, 2, 2, 1, 1)); // A, C, G, T, other
516+
assert_eq!(st.num_basepairs(), 8);
517+
// GC over ATCG only: (C+G)/(A+C+G+T) = 4/7.
518+
assert_eq!(st.gc_fraction(), 4.0 / 7.0);
519+
// non-ATCG over all: 1/8.
520+
assert_eq!(st.non_atcg_fraction(), 1.0 / 8.0);
521+
}
522+
438523
#[test]
439524
fn read_quality_methods() {
440525
// avg_quality: mean of the quality values.

0 commit comments

Comments
 (0)