Skip to main content

seqtk_rs/
fqchk.rs

1use crate::io_utils::{FqReader, Output};
2use crate::stats::{convert_p_err_to_q_score, Q2PConverter};
3use rayon::prelude::*;
4use std::fmt::Write;
5
6/// Parses FASTQ data without quality threshold and computes per-position statistics.
7/// Outputs the results to [`std::io::stdout()`].
8///
9/// The output columns are:
10/// - `POS`: Position in the read
11/// - `#bases`: Number of bases at this position
12/// - `%A`, `%C`, `%G`, `%T`, `%N`: Percentage of each nucleotide
13/// - `avgQ`: Average quality score `(Q₁ + Q₂ + ... + Qₙ) / N`
14/// - `errQ`: Estimated average base error probability, converted to a Phred-scaled quality score. `-10 * log₁₀{(P₁ + P₂ + ... + Pₙ) / N}`
15/// - `%Qx`: Percentage of each quality score
16///
17/// # Arguments
18///
19/// * `path` - FASTQ path
20/// * `asciibase` - Quality scores equal to the score plus a base offset asciibase.
21///
22/// # Errors
23///
24/// Return an error if the operation cannot be completed.
25///
26/// # Notes
27///
28/// Some tools treat quality scores less than 3 (`Q < 3`) as 3 to avoid instability in downstream metrics.
29/// For example, `Q = 0` yields an error probability `P = 1.0`, `Q = 1` gives `P ≈ 0.794`, and `Q = 2` gives `P ≈ 0.630`.
30/// These low Q-scores can heavily skew error rate calculations (e.g., `errQ`), which is why they are often floored to 3.
31/// However, this adjustment can lead to results that are inconsistent with the original definition.
32/// Therefore, this tool preserves the original quality scores as-is.
33pub fn get_result_wo_qthreshold(path: &str, asciibase: usize) -> Result<(), std::io::Error> {
34    let (maxlen, qualset) = get_maxlen_and_qualset(path)?;
35    cal_seq_all(path, maxlen, &qualset, asciibase)?;
36    Ok(())
37}
38
39/// Parses FASTQ data with quality threshold and computes per-position statistics.
40/// Outputs the results to [`std::io::stdout()`].
41///
42/// The output columns are:
43/// - `POS`: Position in the read
44/// - `#bases`: Number of bases at this position
45/// - `%A`, `%C`, `%G`, `%T`, `%N`: Percentage of each nucleotide
46/// - `avgQ`: Average quality score `(Q₁ + Q₂ + ... + Qₙ) / N`
47/// - `errQ`: Estimated error rate `-10 * log₁₀((P₁ + P₂ + ... + Pₙ) / N)`
48/// - `%low`, `%high`: Percentage of the nucleotide that the quality scores below or above the threshold, respectively.
49///
50/// # Arguments
51///
52/// * `path` - FASTQ path
53/// * `q_plus_ascii` - The sum of quality threshold and asciibase.
54/// * `asciibase` - Quality score equal to the score plus a base offset asciibase.
55///
56/// # Errors
57///
58/// Return an error if the operation cannot be completed.
59///
60/// # Notes
61///
62/// Some tools treat quality scores less than 3 (`Q < 3`) as 3 to avoid instability in downstream metrics.
63/// For example, `Q = 0` yields an error probability `P = 1.0`, `Q = 1` gives `P ≈ 0.794`, and `Q = 2` gives `P ≈ 0.630`.
64/// These low Q-scores can heavily skew error rate calculations (e.g., `errQ`), which is why they are often floored to 3.
65/// However, this adjustment can lead to results that are inconsistent with the original definition.
66/// Therefore, this tool preserves the original quality scores as-is.
67pub fn get_result_with_qthreshold(
68    path: &str,
69    q_plus_ascii: u8,
70    asciibase: usize,
71) -> Result<(), std::io::Error> {
72    let (maxlen, qualset) = get_maxlen_and_qualset(path)?;
73    cal_seq_with_q(path, maxlen, &qualset, asciibase, q_plus_ascii)?;
74    Ok(())
75}
76
77fn cal_seq_all(
78    path: &str,
79    maxlen: usize,
80    qual_set: &[usize],
81    asciibases: usize,
82) -> Result<(), std::io::Error> {
83    let qplookup = Q2PConverter::new(asciibases as u8);
84    let mut seq_count_mat: Vec<[usize; 256]> = vec![[0; 256]; maxlen];
85    let mut seq_pos_sum: Vec<usize> = vec![0; maxlen];
86    let mut seq_all: [usize; 256] = [0; 256];
87    let mut qual_count_mat: Vec<[usize; 256]> = vec![[0; 256]; maxlen];
88    let mut qual_all: [usize; 256] = [0; 256];
89
90    let fq = FqReader::new(path)?;
91    for record in fq.records() {
92        match record {
93            Ok(read) => {
94                for (i, (&b, &q)) in read.seq().iter().zip(read.qual()).enumerate() {
95                    let ub = b as usize;
96                    seq_count_mat[i][ub] += 1;
97                    seq_all[ub] += 1;
98                    seq_pos_sum[i] += 1;
99                    let uq = q as usize;
100                    qual_count_mat[i][uq] += 1;
101                    qual_all[uq] += 1;
102                }
103            }
104            Err(e) => eprintln!("Error read FASTQ: {}", e),
105        }
106    }
107
108    let mut output = Output::new();
109    let mut buf = String::new();
110
111    buf.push_str("POS\t#bases\t%A\t%C\t%G\t%T\t%N\tavgQ\terrQ\t");
112    get_qual_cols(&mut buf, qual_set, asciibases);
113    output.write(&buf)?;
114    buf.clear();
115
116    let mut total: usize = seq_all.iter().sum();
117    let mut total_f64: f64 = total as f64;
118    write!(&mut buf, "All\t{}\t", total).unwrap();
119    get_seq_result(&mut buf, total_f64, &seq_all);
120    get_avg_err(
121        &mut buf, total_f64, &qual_all, qual_set, &qplookup, asciibases,
122    );
123    get_qual_result(&mut buf, total_f64, &qual_all, qual_set);
124    output.write(&buf)?;
125
126    for i in 0..maxlen {
127        buf.clear();
128
129        total = seq_pos_sum[i];
130        total_f64 = total as f64;
131        write!(&mut buf, "{}\t{}\t", i + 1, total).unwrap();
132        get_seq_result(&mut buf, total_f64, &seq_count_mat[i]);
133        get_avg_err(
134            &mut buf,
135            total_f64,
136            &qual_count_mat[i],
137            qual_set,
138            &qplookup,
139            asciibases,
140        );
141        get_qual_result(&mut buf, total_f64, &qual_count_mat[i], qual_set);
142        output.write(&buf)?;
143    }
144    Ok(())
145}
146fn cal_seq_with_q(
147    path: &str,
148    maxlen: usize,
149    qual_set: &[usize],
150    asciibases: usize,
151    q_plus_ascii: u8,
152) -> Result<(), std::io::Error> {
153    let qplookup = Q2PConverter::new(asciibases as u8);
154    let mut seq_count_mat: Vec<[usize; 256]> = vec![[0; 256]; maxlen];
155    let mut seq_all: [usize; 256] = [0; 256];
156    let mut qual_count_mat: Vec<[usize; 256]> = vec![[0; 256]; maxlen];
157    let mut qual_all: [usize; 256] = [0; 256];
158    let mut qual_q_count: Vec<[usize; 2]> = vec![[0; 2]; maxlen]; // low, high
159    let mut qual_q_count_all: [usize; 2] = [0; 2];
160
161    let fq = FqReader::new(path)?;
162    for record in fq.records() {
163        match record {
164            Ok(read) => {
165                for (i, (&b, &q)) in read.seq().iter().zip(read.qual()).enumerate() {
166                    let ub = b as usize;
167                    seq_count_mat[i][ub] += 1;
168                    seq_all[ub] += 1;
169                    let uq = q as usize;
170                    qual_count_mat[i][uq] += 1;
171                    qual_all[uq] += 1;
172                    if q >= q_plus_ascii {
173                        qual_q_count[i][1] += 1;
174                        qual_q_count_all[1] += 1;
175                    } else {
176                        qual_q_count[i][0] += 1;
177                        qual_q_count_all[0] += 1;
178                    }
179                }
180            }
181            Err(e) => eprintln!("Error read fASTQ: {}", e),
182        }
183    }
184
185    let mut output = Output::new();
186    let mut buf = String::with_capacity(1024);
187
188    let column = "POS\t#bases\t%A\t%C\t%G\t%T\t%N\tavgQ\terrQ\t%low\t%high\n";
189    output.write(column)?;
190
191    let mut total = qual_q_count_all[0] + qual_q_count_all[1];
192    let mut total_f64 = total as f64;
193    write!(buf, "All\t{}\t", total).unwrap();
194    get_seq_result(&mut buf, total_f64, &seq_all);
195    get_avg_err(
196        &mut buf, total_f64, &qual_all, qual_set, &qplookup, asciibases,
197    );
198    get_qual_result_with_q(&mut buf, total_f64, &qual_q_count_all);
199    output.write(&buf)?;
200
201    for i in 0..maxlen {
202        buf.clear();
203        total = qual_q_count[i][0] + qual_q_count[i][1];
204        total_f64 = total as f64;
205        write!(buf, "{}\t{}\t", i + 1, total).unwrap();
206        get_seq_result(&mut buf, total_f64, &seq_count_mat[i]);
207        get_avg_err(
208            &mut buf,
209            total_f64,
210            &qual_count_mat[i],
211            qual_set,
212            &qplookup,
213            asciibases,
214        );
215        get_qual_result_with_q(&mut buf, total_f64, &qual_q_count[i]);
216        output.write(&buf)?;
217    }
218    Ok(())
219}
220fn get_maxlen_and_qualset(path: &str) -> Result<(usize, Vec<usize>), std::io::Error> {
221    let fq = FqReader::new(path)?;
222    let mut maxlen: usize = 0;
223    let mut qual_set: [bool; 256] = [false; 256];
224    for record in fq.records() {
225        match record {
226            Ok(read) => {
227                let len = read.seq().len();
228                if len > maxlen {
229                    maxlen = len;
230                }
231
232                for &qual in read.qual() {
233                    qual_set[qual as usize] = true;
234                }
235            }
236            Err(e) => eprintln!("Error read FASTQ: {}", e),
237        }
238    }
239    let uniq_qset: Vec<usize> = qual_set
240        .iter()
241        .enumerate()
242        .filter_map(|(i, &b)| if b { Some(i) } else { None })
243        .collect();
244    Ok((maxlen, uniq_qset))
245}
246
247/// [Note] Some tools treat Q < 3 as Q = 3. I don't do that.
248/// Q = 0 leads to P = 1.0 ; Q = 1 → P = 0.794 ; Q = 2 → P = 0.630.
249/// These small Qs significantly affect and skew the result of errQ.
250/// Therefore, they treat Q < 3 as Q = 3.
251fn get_avg_err(
252    buf: &mut String,
253    total: f64,
254    qual_count: &[usize; 256],
255    qual_set: &[usize],
256    qplookup: &Q2PConverter,
257    asciibases: usize,
258) {
259    let sum: f64 = qual_set
260        .par_iter()
261        .map(|&q| ((q - asciibases) as f64) * (qual_count[q] as f64))
262        .sum();
263    let avg_q = sum / total;
264    let sum: f64 = qual_set
265        .par_iter()
266        .map(|&q| qplookup.get_prob(q as u8) * (qual_count[q] as f64))
267        .sum();
268    let err_q = convert_p_err_to_q_score(sum / total);
269
270    write!(buf, "{:.1}\t{:.1}\t", avg_q, f64::abs(err_q)).unwrap();
271}
272/// Output: %Qx
273fn get_qual_result(buf: &mut String, total: f64, qual_count: &[usize; 256], qual_set: &[usize]) {
274    for (i, &q) in qual_set.iter().enumerate() {
275        if i > 0 {
276            buf.push('\t');
277        }
278        write!(buf, "{:.1}", qual_count[q] as f64 * 100.0 / total).unwrap();
279    }
280    buf.push('\n');
281}
282/// Output: %low, %high
283fn get_qual_result_with_q(buf: &mut String, total_f64: f64, qual_count: &[usize; 2]) {
284    writeln!(
285        buf,
286        "{:.1}\t{:.1}",
287        qual_count[0] as f64 * 100.0 / total_f64,
288        qual_count[1] as f64 * 100.0 / total_f64,
289    )
290    .unwrap();
291}
292fn get_seq_result(buf: &mut String, total_f64: f64, seq_count: &[usize; 256]) {
293    write!(
294        buf,
295        "{:.1}\t{:.1}\t{:.1}\t{:.1}\t{:.1}\t",
296        100.0 * (seq_count[b'A' as usize] + seq_count[b'a' as usize]) as f64 / total_f64,
297        100.0 * (seq_count[b'C' as usize] + seq_count[b'c' as usize]) as f64 / total_f64,
298        100.0 * (seq_count[b'G' as usize] + seq_count[b'g' as usize]) as f64 / total_f64,
299        100.0 * (seq_count[b'T' as usize] + seq_count[b't' as usize]) as f64 / total_f64,
300        100.0 * (seq_count[b'N' as usize] + seq_count[b'n' as usize]) as f64 / total_f64,
301    )
302    .unwrap();
303}
304fn get_qual_cols(buf: &mut String, qual_set: &[usize], asciibases: usize) {
305    for (i, &q) in qual_set.iter().enumerate() {
306        if i > 0 {
307            buf.push('\t');
308        }
309        write!(buf, "%Q{}", q - asciibases).unwrap();
310    }
311    buf.push('\n');
312}