Skip to main content

fastqc_rust/modules/
sequence_length_distribution.rs

1// Sequence Length Distribution module
2// Corresponds to Modules/SequenceLengthDistribution.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::scaled_chart_width;
10use crate::sequence::Sequence;
11use crate::utils::format::java_format_double;
12
13pub struct SequenceLengthDistribution {
14    /// length_counts[i] = number of sequences with length i
15    length_counts: Vec<u64>,
16    limits: Limits,
17    nogroup: bool,
18    // Lazily computed results
19    computed: Option<ComputedDistribution>,
20}
21
22struct ComputedDistribution {
23    graph_counts: Vec<f64>,
24    x_categories: Vec<String>,
25}
26
27impl SequenceLengthDistribution {
28    pub fn new(limits: &Limits, nogroup: bool) -> Self {
29        SequenceLengthDistribution {
30            length_counts: Vec::new(),
31            limits: limits.clone(),
32            nogroup,
33            computed: None,
34        }
35    }
36
37    /// Find interval and starting point for binning sequence lengths.
38    /// Replicates getSizeDistribution() from SequenceLengthDistribution.java.
39    fn get_size_distribution(min: usize, max: usize, nogroup: bool) -> (usize, usize) {
40        // If nogroup is set, don't bin
41        if nogroup {
42            return (min, 1);
43        }
44
45        // Find the smallest interval from [1,2,5]*base that gives <=50 bins
46        let mut base: usize = 1;
47
48        // The Java code starts with base=1 and has `while (base > (max-min)) base /= 10`
49        // which is a no-op when base starts at 1 (since 1 is always <= max-min for any valid range).
50        // We skip this loop as it has no effect in practice.
51
52        let divisions = [1, 2, 5];
53        let interval;
54
55        'outer: loop {
56            for &d in &divisions {
57                let tester = base * d;
58                if (max - min) / tester <= 50 {
59                    interval = tester;
60                    break 'outer;
61                }
62            }
63            base *= 10;
64        }
65
66        // Calculate starting value aligned to interval boundary
67        let basic_division = min / interval;
68        let starting = basic_division * interval;
69
70        (starting, interval)
71    }
72
73    fn calculate_distribution(&mut self) {
74        if self.computed.is_some() {
75            return;
76        }
77
78        let mut max_len: usize = 0;
79        let mut min_len: Option<usize> = None;
80
81        // Find min and max lengths
82        for i in 0..self.length_counts.len() {
83            if self.length_counts[i] > 0 {
84                if min_len.is_none() {
85                    min_len = Some(i);
86                }
87                max_len = i;
88            }
89        }
90
91        // Default min to 0 if no sequences
92        let mut min_len = min_len.unwrap_or(0);
93
94        // Add one extra category on either side
95        min_len = min_len.saturating_sub(1);
96        max_len += 1;
97
98        let (starting, interval) = Self::get_size_distribution(min_len, max_len, self.nogroup);
99
100        // Count how many categories we need
101        let mut categories = 0;
102        let mut current_value = starting;
103        while current_value <= max_len {
104            categories += 1;
105            current_value += interval;
106        }
107
108        let mut graph_counts = vec![0.0f64; categories];
109        let mut x_categories = Vec::with_capacity(categories);
110
111        // i needed to compute bin boundaries
112        for (i, gc) in graph_counts.iter_mut().enumerate() {
113            let min_value = starting + (interval * i);
114            let mut max_value = (starting + (interval * (i + 1))) - 1;
115
116            // Clamp max_value to maxLen
117            if max_value > max_len {
118                max_value = max_len;
119            }
120
121            // Sum counts in this bin
122            for bp in min_value..=max_value {
123                if bp < self.length_counts.len() {
124                    *gc += self.length_counts[bp] as f64;
125                }
126            }
127
128            // Label format depends on interval
129            if interval == 1 {
130                x_categories.push(format!("{}", min_value));
131            } else {
132                x_categories.push(format!("{}-{}", min_value, max_value));
133            }
134        }
135
136        self.computed = Some(ComputedDistribution {
137            graph_counts,
138            x_categories,
139        });
140    }
141
142    fn ensure_calculated(&self) -> &ComputedDistribution {
143        // SAFETY: finalize() must be called before any reporting method.
144        // If a caller skips finalize(), we provide a static default to avoid panicking.
145        static DEFAULT: ComputedDistribution = ComputedDistribution {
146            graph_counts: Vec::new(),
147            x_categories: Vec::new(),
148        };
149        self.computed.as_ref().unwrap_or(&DEFAULT)
150    }
151}
152
153impl SequenceLengthDistribution {
154    fn build_chart_svg(&self) -> String {
155        let computed = self.ensure_calculated();
156        let max_val = computed
157            .graph_counts
158            .iter()
159            .cloned()
160            .fold(0.0_f64, f64::max);
161        // Java passes raw max to LineGraph (no ceil rounding)
162        let max_y = max_val;
163
164        // Matches Java constructor call
165        render_line_graph(&LineGraphData {
166            width: scaled_chart_width(computed.graph_counts.len()),
167            data: vec![computed.graph_counts.clone()],
168            min_y: 0.0,
169            max_y,
170            x_label: "Sequence Length (bp)".to_string(),
171            series_names: vec!["Sequence Length".to_string()],
172            x_categories: computed.x_categories.clone(),
173            title: "Distribution of sequence lengths over all sequences".to_string(),
174        })
175    }
176}
177
178impl QCModule for SequenceLengthDistribution {
179    fn process_sequence(&mut self, sequence: &Sequence) {
180        self.computed = None;
181        let seq_len = sequence.sequence.len();
182
183        // Array is extended to seqLen+2 to match Java's `seqLen+2 > lengthCounts.length`
184        if seq_len + 2 > self.length_counts.len() {
185            self.length_counts.resize(seq_len + 2, 0);
186        }
187
188        self.length_counts[seq_len] += 1;
189    }
190
191    fn name(&self) -> &str {
192        "Sequence Length Distribution"
193    }
194
195    fn description(&self) -> &str {
196        "Shows the distribution of sequence length over all sequences"
197    }
198
199    fn reset(&mut self) {
200        self.length_counts.clear();
201        self.computed = None;
202    }
203
204    fn finalize(&mut self) {
205        self.calculate_distribution();
206    }
207
208    fn raises_error(&self) -> bool {
209        // If error threshold is 0, the test is disabled
210        let threshold = self.limits.threshold("sequence_length\terror", 1.0);
211        if threshold == 0.0 {
212            return false;
213        }
214
215        // Error if there are sequences of length 0
216        if !self.length_counts.is_empty() && self.length_counts[0] > 0 {
217            return true;
218        }
219        false
220    }
221
222    fn raises_warning(&self) -> bool {
223        // If warn threshold is 0, the test is disabled
224        let threshold = self.limits.threshold("sequence_length\twarn", 1.0);
225        if threshold == 0.0 {
226            return false;
227        }
228
229        // Warn if there are sequences of different lengths
230        let mut seen_length = false;
231        for &count in &self.length_counts {
232            if count > 0 {
233                if seen_length {
234                    return true;
235                }
236                seen_length = true;
237            }
238        }
239        false
240    }
241
242    fn ignore_filtered_sequences(&self) -> bool {
243        true
244    }
245
246    fn ignore_in_report(&self) -> bool {
247        self.limits.is_ignored("sequence_length")
248    }
249
250    fn write_text_report(&self, writer: &mut dyn io::Write) -> io::Result<()> {
251        let computed = self.ensure_calculated();
252
253        writeln!(writer, "#Length\tCount")?;
254        for i in 0..computed.x_categories.len() {
255            // Skip empty padding bins at the start and end
256            if (i == 0 || i == computed.x_categories.len() - 1) && computed.graph_counts[i] == 0.0 {
257                continue;
258            }
259            writeln!(
260                writer,
261                "{}\t{}",
262                computed.x_categories[i],
263                java_format_double(computed.graph_counts[i])
264            )?;
265        }
266        Ok(())
267    }
268
269    // Image filename matches Java's "sequence_length_distribution.png" in Images/
270    fn chart_image_name(&self) -> Option<&str> {
271        Some("sequence_length_distribution")
272    }
273    fn chart_alt_text(&self) -> Option<&str> {
274        Some("Sequence length distribution")
275    }
276    fn generate_chart_svg(&self) -> Option<String> {
277        Some(self.build_chart_svg())
278    }
279}