Skip to main content

fastqc_rust/modules/
gc_content.rs

1// Per Sequence GC Content module
2// Corresponds to Modules/PerSequenceGCContent.java
3
4use std::io;
5
6use crate::config::{Limits, LimitsExt};
7use crate::modules::QCModule;
8use crate::report::charts::line_graph::{render_line_graph, LineGraphData};
9use crate::report::charts::CHART_WIDTH;
10use crate::sequence::Sequence;
11use crate::utils::base_counts::count_acgt;
12use crate::utils::format::java_format_double;
13
14/// Mirrors GCModel/GCModelValue.java - a single percentage bin and its increment weight.
15struct GCModelValue {
16    percentage: usize,
17    increment: f64,
18}
19
20/// Mirrors GCModel/GCModel.java - maps a GC base count to weighted percentage bins.
21/// For a given read length, each possible GC count (0..=length) maps to one or more percentage
22/// bins with fractional increments so that counts at bin boundaries are shared.
23struct GCModel {
24    models: Vec<Vec<GCModelValue>>,
25}
26
27impl GCModel {
28    fn new(read_length: usize) -> Self {
29        // Two-pass algorithm from GCModel.java
30        // First pass: count how many GC-count positions claim each percentage bin
31        let mut claiming_counts = vec![0usize; 101];
32
33        for pos in 0..=read_length {
34            let low_count = if pos == 0 { 0.0 } else { pos as f64 - 0.5 };
35            let high_count = (pos as f64 + 0.5).min(read_length as f64);
36            // clamp is also applied for lowCount < 0 (only matters for pos==0)
37            let low_count = low_count.max(0.0);
38
39            let low_percentage = ((low_count * 100.0) / read_length as f64).round() as usize;
40            let high_percentage = ((high_count * 100.0) / read_length as f64).round() as usize;
41
42            for cc in &mut claiming_counts[low_percentage..=high_percentage] {
43                *cc += 1;
44            }
45        }
46
47        // Second pass: build the model with weighted increments
48        let mut models = Vec::with_capacity(read_length + 1);
49
50        for pos in 0..=read_length {
51            let low_count = if pos == 0 { 0.0 } else { pos as f64 - 0.5 };
52            let high_count = (pos as f64 + 0.5).min(read_length as f64);
53            let low_count = low_count.max(0.0);
54
55            let low_percentage = ((low_count * 100.0) / read_length as f64).round() as usize;
56            let high_percentage = ((high_count * 100.0) / read_length as f64).round() as usize;
57
58            let mut values = Vec::with_capacity((high_percentage - low_percentage) + 1);
59            // need both index p (for percentage field) and claiming_counts[p]
60            for (p, &cc) in (low_percentage..=high_percentage)
61                .zip(&claiming_counts[low_percentage..=high_percentage])
62            {
63                values.push(GCModelValue {
64                    percentage: p,
65                    increment: 1.0 / cc as f64,
66                });
67            }
68            models.push(values);
69        }
70
71        GCModel { models }
72    }
73
74    fn get_model_values(&self, gc_count: usize) -> &[GCModelValue] {
75        &self.models[gc_count]
76    }
77}
78
79pub struct PerSequenceGCContent {
80    gc_distribution: [f64; 101],
81    // Cache GCModels by read length to avoid recomputing
82    cached_models: Vec<Option<GCModel>>,
83    limits: Limits,
84    // Lazily computed results
85    deviation_percent: Option<f64>,
86    /// Theoretical normal distribution for the chart, computed alongside deviation_percent.
87    theoretical_distribution: Option<[f64; 101]>,
88}
89
90impl PerSequenceGCContent {
91    pub fn new(limits: &Limits) -> Self {
92        PerSequenceGCContent {
93            gc_distribution: [0.0; 101],
94            // Initial cache size 200, grows as needed
95            cached_models: Vec::new(),
96            limits: limits.clone(),
97            deviation_percent: None,
98            theoretical_distribution: None,
99        }
100    }
101
102    /// Truncate sequence to reduce number of distinct GCModel lengths.
103    /// Sequences >1000bp are truncated to a multiple of 1000, >100bp to a multiple of 100.
104    fn truncate_length(len: usize) -> usize {
105        if len > 1000 {
106            (len / 1000) * 1000
107        } else if len > 100 {
108            (len / 100) * 100
109        } else {
110            len
111        }
112    }
113
114    /// Calculate the theoretical normal distribution and deviation percentage.
115    fn calculate_distribution(&mut self) {
116        if self.deviation_percent.is_some() {
117            return;
118        }
119
120        let mut total_count: f64 = 0.0;
121        let mut first_mode: usize = 0;
122        let mut mode_count: f64 = 0.0;
123
124        for i in 0..101 {
125            total_count += self.gc_distribution[i];
126            if self.gc_distribution[i] > mode_count {
127                mode_count = self.gc_distribution[i];
128                first_mode = i;
129            }
130        }
131
132        // Average over adjacent points that stay above 90% of the modal value
133        // (the comment says 95% but the code checks gcDistribution[firstMode] - gcDistribution[firstMode]/10,
134        // which is 90% of the mode value)
135        let mut mode: f64 = 0.0;
136        let mut mode_duplicates: usize = 0;
137        let mut fell_off_top = true;
138
139        for i in first_mode..101 {
140            if self.gc_distribution[i]
141                > self.gc_distribution[first_mode] - (self.gc_distribution[first_mode] / 10.0)
142            {
143                mode += i as f64;
144                mode_duplicates += 1;
145            } else {
146                fell_off_top = false;
147                break;
148            }
149        }
150
151        let mut fell_off_bottom = true;
152        if first_mode > 0 {
153            for i in (0..first_mode).rev() {
154                if self.gc_distribution[i]
155                    > self.gc_distribution[first_mode] - (self.gc_distribution[first_mode] / 10.0)
156                {
157                    mode += i as f64;
158                    mode_duplicates += 1;
159                } else {
160                    fell_off_bottom = false;
161                    break;
162                }
163            }
164        }
165
166        // If distribution is so skewed that 90% of the mode falls off the
167        // 0-100% scale, keep first_mode as center
168        if fell_off_bottom || fell_off_top {
169            mode = first_mode as f64;
170        } else {
171            mode /= mode_duplicates as f64;
172        }
173
174        // Calculate standard deviation
175        let mut stdev: f64 = 0.0;
176        for i in 0..101 {
177            stdev += (i as f64 - mode).powi(2) * self.gc_distribution[i];
178        }
179        // Divides by totalCount-1 (Bessel's correction)
180        stdev /= total_count - 1.0;
181        stdev = stdev.sqrt();
182
183        // Calculate theoretical distribution using the normal PDF
184        // NormalDistribution.getZScoreForValue() is actually a PDF, not a z-score
185        let mut deviation_percent: f64 = 0.0;
186        let mut theoretical = [0.0f64; 101];
187
188        // Calculate theoretical[i] and compare with gc_distribution[i]
189        for (i, (theo, &observed)) in theoretical
190            .iter_mut()
191            .zip(self.gc_distribution.iter())
192            .enumerate()
193        {
194            let lhs = 1.0 / (2.0 * std::f64::consts::PI * stdev * stdev).sqrt();
195            let rhs =
196                std::f64::consts::E.powf(-(((i as f64) - mode).powi(2)) / (2.0 * stdev * stdev));
197            *theo = lhs * rhs * total_count;
198
199            deviation_percent += (*theo - observed).abs();
200        }
201
202        deviation_percent /= total_count;
203        deviation_percent *= 100.0;
204
205        self.theoretical_distribution = Some(theoretical);
206        self.deviation_percent = Some(deviation_percent);
207    }
208
209    fn get_deviation_percent(&self) -> f64 {
210        self.deviation_percent.unwrap_or(0.0)
211    }
212}
213
214impl PerSequenceGCContent {
215    fn build_chart_svg(&self) -> String {
216        let gc_dist: Vec<f64> = self.gc_distribution.to_vec();
217        let theoretical = self.theoretical_distribution.unwrap_or([0.0; 101]).to_vec();
218
219        // max is the maximum of either distribution
220        let max_val = gc_dist
221            .iter()
222            .chain(theoretical.iter())
223            .cloned()
224            .fold(0.0_f64, f64::max);
225
226        // Java passes raw max to LineGraph (no ceil rounding)
227        let max_y = max_val;
228
229        let x_categories: Vec<String> = (0..101).map(|i| format!("{}", i)).collect();
230
231        // Two series: GC distribution and theoretical distribution
232        render_line_graph(&LineGraphData {
233            width: CHART_WIDTH,
234            data: vec![gc_dist, theoretical],
235            min_y: 0.0,
236            max_y,
237            x_label: "Mean GC content (%)".to_string(),
238            series_names: vec![
239                "GC count per read".to_string(),
240                "Theoretical Distribution".to_string(),
241            ],
242            x_categories,
243            title: "GC distribution over all sequences".to_string(),
244        })
245    }
246}
247
248impl QCModule for PerSequenceGCContent {
249    fn cost_hint(&self) -> u32 {
250        2
251    }
252
253    fn process_sequence(&mut self, sequence: &Sequence) {
254        // Invalidate cached calculation when new data arrives
255        self.deviation_percent = None;
256        self.theoretical_distribution = None;
257
258        let seq = &sequence.sequence;
259        let truncated_len = Self::truncate_length(seq.len());
260        if truncated_len == 0 {
261            return;
262        }
263
264        // Count G and C in the truncated portion only
265        let [_, c, g, _] = count_acgt(&seq[..truncated_len]);
266        let gc_count = (c + g) as usize;
267
268        // Ensure cache is large enough
269        if truncated_len >= self.cached_models.len() {
270            self.cached_models.resize_with(truncated_len + 1, || None);
271        }
272
273        // Create model if not cached
274        if self.cached_models[truncated_len].is_none() {
275            self.cached_models[truncated_len] = Some(GCModel::new(truncated_len));
276        }
277
278        let model = self.cached_models[truncated_len].as_ref().unwrap();
279        let values = model.get_model_values(gc_count);
280
281        for v in values {
282            self.gc_distribution[v.percentage] += v.increment;
283        }
284    }
285
286    fn name(&self) -> &str {
287        "Per sequence GC content"
288    }
289
290    fn description(&self) -> &str {
291        "Shows the distribution of GC contents for whole sequences"
292    }
293
294    fn reset(&mut self) {
295        self.gc_distribution = [0.0; 101];
296        self.deviation_percent = None;
297        self.theoretical_distribution = None;
298    }
299
300    fn finalize(&mut self) {
301        self.calculate_distribution();
302    }
303
304    fn raises_error(&self) -> bool {
305        self.get_deviation_percent() > self.limits.threshold("gc_sequence\terror", 30.0)
306    }
307
308    fn raises_warning(&self) -> bool {
309        self.get_deviation_percent() > self.limits.threshold("gc_sequence\twarn", 15.0)
310    }
311
312    fn ignore_filtered_sequences(&self) -> bool {
313        true
314    }
315
316    fn ignore_in_report(&self) -> bool {
317        self.limits.is_ignored("gc_sequence")
318    }
319
320    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
321        // Header line with #GC Content\tCount
322        writeln!(writer, "#GC Content\tCount")?;
323        // Always output all 101 rows (0-100)
324        for i in 0..101 {
325            writeln!(
326                writer,
327                "{}\t{}",
328                i,
329                java_format_double(self.gc_distribution[i])
330            )?;
331        }
332        Ok(())
333    }
334
335    // Image filename matches Java's "per_sequence_gc_content.png" in Images/
336    fn chart_image_name(&self) -> Option<&str> {
337        Some("per_sequence_gc_content")
338    }
339    fn chart_alt_text(&self) -> Option<&str> {
340        Some("Per sequence GC content graph")
341    }
342    fn generate_chart_svg(&self) -> Option<String> {
343        Some(self.build_chart_svg())
344    }
345}