Skip to main content

fastqc_rust/modules/
per_base_quality.rs

1// Per Base Sequence Quality module
2// Corresponds to Modules/PerBaseQualityScores.java
3
4use std::io;
5
6use crate::config::{Limits, LimitsExt};
7use crate::modules::QCModule;
8use crate::report::charts::quality_boxplot::{render_quality_boxplot, QualityBoxPlotData};
9use crate::report::charts::scaled_chart_width;
10use crate::sequence::Sequence;
11use crate::utils::base_group::BaseGroup;
12use crate::utils::format::java_format_double;
13use crate::utils::phred;
14use crate::utils::quality_count::{self, QualityCount};
15
16pub struct PerBaseQualityScores {
17    quality_counts: Vec<QualityCount>,
18    nogroup: bool,
19    expgroup: bool,
20    // Set by QCModule::set_phred_encoding; see the trait docs.
21    known_encoding: Option<phred::PhredEncoding>,
22    limits: Limits,
23}
24
25impl PerBaseQualityScores {
26    pub fn new(limits: &Limits, nogroup: bool, expgroup: bool) -> Self {
27        PerBaseQualityScores {
28            quality_counts: Vec::new(),
29            nogroup,
30            expgroup,
31            known_encoding: None,
32            limits: limits.clone(),
33        }
34    }
35
36    fn calculate(&self) -> CalculatedData {
37        let (min_char, _max_char) = quality_count::calculate_offsets(&self.quality_counts);
38        // If no quality data, default to the Sanger offset.
39        let offset = phred::resolve(self.known_encoding, min_char as u16)
40            .map(|e| e.offset)
41            .unwrap_or(phred::PhredEncoding::SANGER.offset);
42
43        let groups =
44            BaseGroup::make_base_groups(self.quality_counts.len(), self.nogroup, self.expgroup);
45
46        let mut means = vec![0.0f64; groups.len()];
47        let mut medians = vec![0.0f64; groups.len()];
48        let mut lower_quartile = vec![0.0f64; groups.len()];
49        let mut upper_quartile = vec![0.0f64; groups.len()];
50        let mut lowest = vec![0.0f64; groups.len()];
51        let mut highest = vec![0.0f64; groups.len()];
52        let mut x_labels = Vec::with_capacity(groups.len());
53
54        for (i, group) in groups.iter().enumerate() {
55            x_labels.push(group.label());
56            // Java uses 1-based lowerCount/upperCount; our BaseGroup
57            // stores 0-based lower_count/upper_count.
58            let min_base = group.lower_count;
59            let max_base = group.upper_count;
60            lowest[i] = self.get_percentile(min_base, max_base, offset, 10);
61            highest[i] = self.get_percentile(min_base, max_base, offset, 90);
62            means[i] = self.get_mean(min_base, max_base, offset);
63            medians[i] = self.get_percentile(min_base, max_base, offset, 50);
64            lower_quartile[i] = self.get_percentile(min_base, max_base, offset, 25);
65            upper_quartile[i] = self.get_percentile(min_base, max_base, offset, 75);
66        }
67
68        CalculatedData {
69            means,
70            medians,
71            lower_quartile,
72            upper_quartile,
73            lowest,
74            highest,
75            x_labels,
76        }
77    }
78
79    /// Replicates `getPercentile(int minbp, int maxbp, int offset, int percentile)`.
80    /// Only includes positions with >100 total counts.
81    /// minbp and maxbp are 0-based inclusive indices into quality_counts.
82    fn get_percentile(&self, min_base: usize, max_base: usize, offset: u8, percentile: u8) -> f64 {
83        let mut count = 0;
84        let mut total = 0.0;
85
86        // Java loop is `for (int i=minbp-1;i<maxbp;i++)` where minbp
87        // is 1-based. Our min_base is 0-based, so we iterate min_base..=max_base.
88        for i in min_base..=max_base {
89            // Only include positions with >100 counts for percentile calculation
90            if self.quality_counts[i].get_total_count() > 100 {
91                count += 1;
92                total += self.quality_counts[i].get_percentile(offset, percentile);
93            }
94        }
95
96        if count > 0 {
97            total / count as f64
98        } else {
99            f64::NAN
100        }
101    }
102
103    /// Replicates `getMean(int minbp, int maxbp, int offset)`.
104    /// Only includes positions with >0 total counts.
105    fn get_mean(&self, min_base: usize, max_base: usize, offset: u8) -> f64 {
106        let mut count = 0;
107        let mut total = 0.0;
108
109        for i in min_base..=max_base {
110            // getMean includes positions with >0 counts (not >100 like percentile)
111            if self.quality_counts[i].get_total_count() > 0 {
112                count += 1;
113                total += self.quality_counts[i].get_mean(offset);
114            }
115        }
116
117        if count > 0 {
118            total / count as f64
119        } else {
120            // Java returns 0 when count is 0 for mean (not NaN)
121            0.0
122        }
123    }
124}
125
126impl PerBaseQualityScores {
127    /// Generate the SVG chart for this module.
128    fn build_chart_svg(&self) -> String {
129        let data = self.calculate();
130        let (min_char, max_char) = quality_count::calculate_offsets(&self.quality_counts);
131        // If no quality data, default to Sanger.
132        let encoding = phred::resolve(self.known_encoding, min_char as u16)
133            .unwrap_or(phred::PhredEncoding::SANGER);
134        let (offset, encoding_name) = (encoding.offset, encoding.name);
135
136        // The chart title includes the encoding scheme name
137        let title = format!(
138            "Quality scores across all bases ({} encoding)",
139            encoding_name
140        );
141
142        // Java computes: high = maxChar - offset; if (high < 35) high = 35;
143        // This determines the Y-axis range from the encoding, not from the data.
144        // Java passes high directly as maxY (no rounding).
145        let mut max_y = (max_char as i32 - offset as i32).max(0) as f64;
146        if max_y < 35.0 {
147            max_y = 35.0;
148        }
149        let y_interval = 2.0;
150        let min_y = 0.0;
151
152        render_quality_boxplot(&QualityBoxPlotData {
153            width: scaled_chart_width(data.x_labels.len()),
154            means: data.means,
155            medians: data.medians,
156            lower_quartile: data.lower_quartile,
157            upper_quartile: data.upper_quartile,
158            lowest: data.lowest,
159            highest: data.highest,
160            min_y,
161            max_y,
162            y_interval,
163            x_labels: data.x_labels,
164            title,
165        })
166    }
167}
168
169impl QCModule for PerBaseQualityScores {
170    fn cost_hint(&self) -> u32 {
171        10
172    }
173
174    fn process_sequence(&mut self, sequence: &Sequence) {
175        let qual = &sequence.quality;
176
177        // Grow the quality_counts array if needed
178        if self.quality_counts.len() < qual.len() {
179            self.quality_counts
180                .resize_with(qual.len(), QualityCount::new);
181        }
182
183        for (i, &q) in qual.iter().enumerate() {
184            self.quality_counts[i].add_value(q);
185        }
186    }
187
188    fn set_phred_encoding(&mut self, encoding: phred::PhredEncoding) {
189        self.known_encoding = Some(encoding);
190    }
191
192    fn name(&self) -> &str {
193        "Per base sequence quality"
194    }
195
196    fn description(&self) -> &str {
197        "Shows the Quality scores of all bases at a given position in a sequencing run"
198    }
199
200    fn reset(&mut self) {
201        self.quality_counts.clear();
202    }
203
204    fn raises_error(&self) -> bool {
205        let data = self.calculate();
206        let lq_error = self.limits.threshold("quality_base_lower\terror", 5.0);
207        let median_error = self.limits.threshold("quality_base_median\terror", 20.0);
208
209        for i in 0..data.lower_quartile.len() {
210            if data.lower_quartile[i].is_nan() {
211                // Skip groups without enough data
212                continue;
213            }
214            if data.lower_quartile[i] < lq_error || data.medians[i] < median_error {
215                return true;
216            }
217        }
218        false
219    }
220
221    fn raises_warning(&self) -> bool {
222        let data = self.calculate();
223        let lq_warn = self.limits.threshold("quality_base_lower\twarn", 10.0);
224        let median_warn = self.limits.threshold("quality_base_median\twarn", 25.0);
225
226        for i in 0..data.lower_quartile.len() {
227            if data.lower_quartile[i].is_nan() {
228                continue;
229            }
230            if data.lower_quartile[i] < lq_warn || data.medians[i] < median_warn {
231                return true;
232            }
233        }
234        false
235    }
236
237    fn ignore_filtered_sequences(&self) -> bool {
238        true
239    }
240
241    fn ignore_in_report(&self) -> bool {
242        // Ignore if configured to ignore or no quality data
243        self.limits.is_ignored("quality_base") || self.quality_counts.is_empty()
244    }
245
246    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
247        let data = self.calculate();
248
249        // Header matches Java's makeReport output exactly
250        writeln!(
251            writer,
252            "#Base\tMean\tMedian\tLower Quartile\tUpper Quartile\t10th Percentile\t90th Percentile"
253        )?;
254
255        for i in 0..data.means.len() {
256            // Java uses StringBuffer.append(double) which calls
257            // Double.toString(double) for each value
258            writeln!(
259                writer,
260                "{}\t{}\t{}\t{}\t{}\t{}\t{}",
261                data.x_labels[i],
262                java_format_double(data.means[i]),
263                java_format_double(data.medians[i]),
264                java_format_double(data.lower_quartile[i]),
265                java_format_double(data.upper_quartile[i]),
266                java_format_double(data.lowest[i]),
267                java_format_double(data.highest[i]),
268            )?;
269        }
270
271        Ok(())
272    }
273
274    // Image filename matches Java's "per_base_quality.png" in Images/
275    fn chart_image_name(&self) -> Option<&str> {
276        Some("per_base_quality")
277    }
278    fn chart_alt_text(&self) -> Option<&str> {
279        Some("Per base quality graph")
280    }
281    fn generate_chart_svg(&self) -> Option<String> {
282        Some(self.build_chart_svg())
283    }
284}
285
286/// Internal struct holding calculated per-base quality data.
287struct CalculatedData {
288    means: Vec<f64>,
289    medians: Vec<f64>,
290    lower_quartile: Vec<f64>,
291    upper_quartile: Vec<f64>,
292    lowest: Vec<f64>,
293    highest: Vec<f64>,
294    x_labels: Vec<String>,
295}