Skip to main content

fastqc_rust/modules/
per_tile_quality.rs

1// Per Tile Sequence Quality module
2// Corresponds to Modules/PerTileQualityScores.java
3
4use std::collections::HashMap;
5use std::io;
6
7use crate::config::{Limits, LimitsExt};
8use crate::modules::QCModule;
9use crate::report::charts::tile_graph::{render_tile_graph, TileGraphData};
10use crate::sequence::Sequence;
11use crate::utils::base_group::BaseGroup;
12use crate::utils::format::java_format_double;
13use crate::utils::quality_count::QualityCount;
14use crate::utils::{phred, quality_count};
15
16pub struct PerTileQualityScores {
17    per_tile_quality_counts: HashMap<i32, Vec<QualityCount>>,
18    current_length: usize,
19    total_count: u64,
20    // splitPosition tracks which colon-separated field contains the tile number.
21    // -1 means not yet determined.
22    split_position: i32,
23    ignore_in_report: bool,
24    nogroup: bool,
25    expgroup: bool,
26    // Set by QCModule::set_phred_encoding; see the trait docs.
27    known_encoding: Option<phred::PhredEncoding>,
28    limits: Limits,
29}
30
31impl PerTileQualityScores {
32    pub fn new(limits: &Limits, nogroup: bool, expgroup: bool) -> Self {
33        PerTileQualityScores {
34            per_tile_quality_counts: HashMap::new(),
35            current_length: 0,
36            total_count: 0,
37            split_position: -1,
38            ignore_in_report: false,
39            nogroup,
40            expgroup,
41            known_encoding: None,
42            limits: limits.clone(),
43        }
44    }
45
46    fn calculate(&self) -> Option<TileCalculatedData> {
47        if self.per_tile_quality_counts.is_empty() {
48            return None;
49        }
50
51        // Collect all QualityCount slices across tiles to find global min/max chars
52        let all_counts: Vec<&QualityCount> = self
53            .per_tile_quality_counts
54            .values()
55            .flat_map(|v| v.iter())
56            .collect();
57        let (min_char, _max_char) = quality_count::calculate_offsets(all_counts);
58        // If no quality data, default to the Sanger offset.
59        let offset = phred::resolve(self.known_encoding, min_char as u16)
60            .map(|e| e.offset)
61            .unwrap_or(phred::PhredEncoding::SANGER.offset);
62
63        let groups = BaseGroup::make_base_groups(self.current_length, self.nogroup, self.expgroup);
64
65        let mut tile_numbers: Vec<i32> = self.per_tile_quality_counts.keys().copied().collect();
66        tile_numbers.sort();
67
68        let mut means = vec![vec![0.0f64; groups.len()]; tile_numbers.len()];
69        let mut x_labels = Vec::with_capacity(groups.len());
70
71        for (t, &tile) in tile_numbers.iter().enumerate() {
72            for (i, group) in groups.iter().enumerate() {
73                if t == 0 {
74                    x_labels.push(group.label());
75                }
76                let min_base = group.lower_count;
77                let max_base = group.upper_count;
78                means[t][i] = self.get_mean(tile, min_base, max_base, offset);
79            }
80        }
81
82        // Normalise by subtracting column averages to show deviations
83        let mut average_qualities_per_group = vec![0.0f64; groups.len()];
84        for tile_means in means.iter().take(tile_numbers.len()) {
85            for (avg, &m) in average_qualities_per_group
86                .iter_mut()
87                .zip(tile_means.iter())
88            {
89                *avg += m;
90            }
91        }
92        for avg in &mut average_qualities_per_group {
93            *avg /= tile_numbers.len() as f64;
94        }
95
96        let mut max_deviation: f64 = 0.0;
97        // subtract per-group averages from each tile's means
98        for (i, &avg) in average_qualities_per_group.iter().enumerate() {
99            for tile_means in means.iter_mut().take(tile_numbers.len()) {
100                tile_means[i] -= avg;
101                if tile_means[i].abs() > max_deviation {
102                    max_deviation = tile_means[i].abs();
103                }
104            }
105        }
106
107        Some(TileCalculatedData {
108            tiles: tile_numbers,
109            means,
110            x_labels,
111            max_deviation,
112        })
113    }
114
115    /// Replicates `getMean(int tile, int minbp, int maxbp, int offset)`.
116    fn get_mean(&self, tile: i32, min_base: usize, max_base: usize, offset: u8) -> f64 {
117        let quality_counts = match self.per_tile_quality_counts.get(&tile) {
118            Some(qc) => qc,
119            None => return 0.0,
120        };
121
122        let mut count = 0;
123        let mut total = 0.0;
124
125        for qc in &quality_counts[min_base..=max_base] {
126            if qc.get_total_count() > 0 {
127                count += 1;
128                total += qc.get_mean(offset);
129            }
130        }
131
132        if count > 0 {
133            total / count as f64
134        } else {
135            0.0
136        }
137    }
138}
139
140impl PerTileQualityScores {
141    fn build_chart_svg(&self) -> Option<String> {
142        let data = self.calculate()?;
143
144        // Color scale max is the error threshold from config
145        let color_scale_max = self.limits.threshold("tile\terror", 5.0);
146
147        Some(render_tile_graph(&TileGraphData {
148            x_labels: data.x_labels,
149            tiles: data.tiles,
150            tile_base_means: data.means,
151            color_scale_max,
152        }))
153    }
154}
155
156impl QCModule for PerTileQualityScores {
157    fn cost_hint(&self) -> u32 {
158        1
159    }
160
161    fn process_sequence(&mut self, sequence: &Sequence) {
162        // Check ignore config on first sequence
163        if self.total_count == 0 && self.limits.is_ignored("tile") {
164            self.ignore_in_report = true;
165        }
166
167        if self.ignore_in_report {
168            return;
169        }
170
171        // Skip zero-length quality strings
172        if sequence.quality.is_empty() {
173            return;
174        }
175
176        self.total_count += 1;
177
178        // Sample all for first 10k reads, then every 10th
179        if self.total_count > 10000 && !self.total_count.is_multiple_of(10) {
180            return;
181        }
182
183        // Parse tile ID from read header.
184        // Use nth() on the split iterator to avoid allocating a Vec per sequence.
185        if self.split_position < 0 {
186            let field_count = sequence.id.split(':').count();
187            if field_count >= 7 {
188                // 1.8+ format, tile at position 4
189                self.split_position = 4;
190            } else if field_count >= 5 {
191                // Older format, tile at position 2
192                self.split_position = 2;
193            } else {
194                // Can't get a tile from this header
195                self.ignore_in_report = true;
196                return;
197            }
198        }
199
200        let tile = match sequence
201            .id
202            .split(':')
203            .nth(self.split_position as usize)
204            .and_then(|f| f.parse::<i32>().ok())
205        {
206            Some(t) => t,
207            None => {
208                self.ignore_in_report = true;
209                return;
210            }
211        };
212
213        let qual = &sequence.quality;
214
215        // Grow all existing tile arrays if quality string is longer
216        if self.current_length < qual.len() {
217            for qc_vec in self.per_tile_quality_counts.values_mut() {
218                qc_vec.resize_with(qual.len(), QualityCount::new);
219            }
220            self.current_length = qual.len();
221        }
222
223        // Add new tile if not seen, with check for too many tiles
224        if !self.per_tile_quality_counts.contains_key(&tile) {
225            if self.per_tile_quality_counts.len() > 2500 {
226                // Too many tiles, give up
227                crate::progress::log_line("Too many tiles (>2500) so giving up trying to do per-tile qualities since we're probably parsing the file wrongly");
228                self.ignore_in_report = true;
229                self.per_tile_quality_counts.clear();
230                return;
231            }
232
233            let mut quality_counts = Vec::new();
234            quality_counts.resize_with(self.current_length, QualityCount::new);
235            self.per_tile_quality_counts.insert(tile, quality_counts);
236        }
237
238        let quality_counts = self.per_tile_quality_counts.get_mut(&tile).unwrap();
239
240        for (i, &q) in qual.iter().enumerate() {
241            quality_counts[i].add_value(q);
242        }
243    }
244
245    fn set_phred_encoding(&mut self, encoding: phred::PhredEncoding) {
246        self.known_encoding = Some(encoding);
247    }
248
249    fn name(&self) -> &str {
250        "Per tile sequence quality"
251    }
252
253    fn description(&self) -> &str {
254        "Shows the per tile Quality scores of all bases at a given position in a sequencing run"
255    }
256
257    fn reset(&mut self) {
258        self.total_count = 0;
259        self.per_tile_quality_counts.clear();
260        self.current_length = 0;
261        self.split_position = -1;
262        self.ignore_in_report = false;
263    }
264
265    fn raises_error(&self) -> bool {
266        let threshold = self.limits.threshold("tile\terror", 5.0);
267        self.calculate()
268            .is_some_and(|data| data.max_deviation > threshold)
269    }
270
271    fn raises_warning(&self) -> bool {
272        let threshold = self.limits.threshold("tile\twarn", 2.0);
273        self.calculate()
274            .is_some_and(|data| data.max_deviation > threshold)
275    }
276
277    fn ignore_filtered_sequences(&self) -> bool {
278        true
279    }
280
281    fn ignore_in_report(&self) -> bool {
282        // Ignore if flagged, configured to ignore, or no data
283        self.ignore_in_report || self.limits.is_ignored("tile") || self.current_length == 0
284    }
285
286    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
287        let data = match self.calculate() {
288            Some(d) => d,
289            None => return Ok(()),
290        };
291
292        // Header and format match Java's makeReport
293        writeln!(writer, "#Tile\tBase\tMean")?;
294
295        for (t, &tile) in data.tiles.iter().enumerate() {
296            for i in 0..data.means[t].len() {
297                writeln!(
298                    writer,
299                    "{}\t{}\t{}",
300                    tile,
301                    data.x_labels[i],
302                    java_format_double(data.means[t][i]),
303                )?;
304            }
305        }
306
307        Ok(())
308    }
309
310    // Image filename matches Java's "per_tile_quality.png" in Images/
311    fn chart_image_name(&self) -> Option<&str> {
312        Some("per_tile_quality")
313    }
314    fn chart_alt_text(&self) -> Option<&str> {
315        Some("Per tile sequence quality")
316    }
317    fn generate_chart_svg(&self) -> Option<String> {
318        self.build_chart_svg()
319    }
320}
321
322struct TileCalculatedData {
323    tiles: Vec<i32>,
324    means: Vec<Vec<f64>>,
325    x_labels: Vec<String>,
326    max_deviation: f64,
327}