Skip to main content

symcurve/
stream.rs

1//! Streaming a FASTA file through the curvature calculation.
2//!
3//! Scores are handed to a callback as they are produced and then dropped, so the amount
4//! held at once is a batch of chunks rather than a genome. What is not bounded here is
5//! the sequence itself: without an index the FASTA reader yields a whole record at a
6//! time, so the largest record is a floor on the footprint.
7
8use std::fs::File;
9use std::io::{self, BufReader};
10use std::path::{Path, PathBuf};
11
12use noodles_core::{Position, Region};
13use noodles_fasta::fai;
14
15use rayon::prelude::*;
16
17use crate::curve::calls::{CallParams, NucleosomeCall, call_nucleosomes, greedy_non_overlapping};
18use crate::curve::scan::{
19    CurveParams, Stage, SymParams, chunks, score_bases, score_symmetry_bases, sym_chunks,
20};
21use crate::fasta::{RecordPiece, split_seq_by_gaps};
22
23/// Everything a scoring run needs beyond the input file itself.
24///
25/// Grouped rather than passed one at a time: these travel together through every entry
26/// point here, and a function taking eight positional values is easy to call wrongly.
27#[derive(Debug, Clone, Copy)]
28pub struct ScanConfig {
29    pub params: CurveParams,
30    pub sym: SymParams,
31    pub stage: Stage,
32    /// Scores produced per chunk, which is how the memory budget is applied.
33    pub chunk_scores: usize,
34    /// Chunks scored at once, normally the worker count.
35    pub batch: usize,
36    /// Bases held at once when reading through an index.
37    pub window_bases: usize,
38}
39
40impl ScanConfig {
41    /// How many bases at each end of a piece receive no score, for the selected stage.
42    pub fn lead_in(&self) -> usize {
43        match self.stage {
44            Stage::Curvature => self.params.lead_in(),
45            Stage::Symmetry => self.sym.lead_in(&self.params),
46        }
47    }
48
49    /// The gap between consecutive output positions.
50    pub fn stride(&self) -> usize {
51        match self.stage {
52            Stage::Curvature => 1,
53            Stage::Symmetry => self.sym.step.max(1),
54        }
55    }
56}
57
58/// A chromosome name and its length, as needed for a bigWig header.
59pub type ChromSize = (String, u32);
60
61/// Read just the names and lengths of each record.
62///
63/// bigWig stores chromosome sizes in its header, so they must all be known before any
64/// value is written. Sequences are read and dropped rather than retained, which costs a
65/// second pass over the file but keeps only one record in memory at a time.
66pub fn chrom_sizes(input: &Path) -> io::Result<Vec<ChromSize>> {
67    let mut reader = open(input)?;
68    let mut sizes = Vec::new();
69    for result in reader.records() {
70        let record = result?;
71        sizes.push((
72            String::from_utf8_lossy(record.name()).into_owned(),
73            record.sequence().len() as u32,
74        ));
75    }
76    Ok(sizes)
77}
78
79fn open(input: &Path) -> io::Result<noodles_fasta::io::Reader<BufReader<File>>> {
80    Ok(noodles_fasta::io::Reader::new(BufReader::new(File::open(
81        input,
82    )?)))
83}
84
85/// How the scoring run went, for reporting.
86#[derive(Debug, Default, Clone, Copy)]
87pub struct RunStats {
88    pub records: usize,
89    pub pieces: usize,
90    pub scores: usize,
91    /// Bases in the largest record, which is the unavoidable part of the footprint.
92    pub longest_record: usize,
93}
94
95/// Score a run of already gap-split pieces, emitting each score with its absolute
96/// position.
97///
98/// `base_offset` is how far the scored slice sits into the record: zero when the whole
99/// record was read, and the window's start when reading through an index. `owned`
100/// optionally restricts what is emitted to a 1-based inclusive absolute range, which is
101/// how overlapping windows avoid emitting the same position twice.
102#[allow(clippy::too_many_arguments)]
103fn emit_pieces<F>(
104    name: &str,
105    pieces: &[RecordPiece],
106    base_offset: usize,
107    config: &ScanConfig,
108    owned: Option<(usize, usize)>,
109    emit: &mut F,
110) -> io::Result<usize>
111where
112    F: FnMut(&str, usize, f64) -> io::Result<()>,
113{
114    let mut emitted = 0usize;
115    for piece in pieces {
116        let bases = piece.bases();
117        let piece_start = usize::from(piece.start);
118        let (lead, stride) = (config.lead_in(), config.stride());
119        let plan = match config.stage {
120            Stage::Curvature => chunks(bases.len(), &config.params, config.chunk_scores),
121            Stage::Symmetry => sym_chunks(
122                bases.len(),
123                &config.params,
124                &config.sym,
125                config.chunk_scores,
126            ),
127        };
128
129        // Score a batch of chunks at a time: enough to keep every thread busy, few
130        // enough that only that many chunks of scores exist at once.
131        for group in plan.chunks(config.batch.max(1)) {
132            let scored: Vec<Vec<f64>> = group
133                .par_iter()
134                .map(|chunk| {
135                    let slice = &bases[chunk.in_start..chunk.in_end];
136                    match config.stage {
137                        Stage::Curvature => score_bases(slice, &config.params),
138                        Stage::Symmetry => score_symmetry_bases(slice, &config.params, &config.sym),
139                    }
140                })
141                .collect();
142            for (chunk, values) in group.iter().zip(&scored) {
143                // out_start is an offset into the piece's scores; the first score sits
144                // lead bases into the piece, and successive scores are `stride` apart.
145                let first = base_offset + piece_start + lead + chunk.out_start * stride;
146                for (i, &value) in values.iter().enumerate() {
147                    let position = first + i * stride;
148                    if let Some((lo, hi)) = owned
149                        && (position < lo || position > hi)
150                    {
151                        continue;
152                    }
153                    emit(name, position, value)?;
154                    emitted += 1;
155                }
156            }
157        }
158    }
159    Ok(emitted)
160}
161
162/// Score every record in `input`, passing each score to `emit` as it is produced.
163///
164/// `emit` receives the record name, the 1-based position the score belongs to, and the
165/// score. It is called in position order within a record and in file order across
166/// records, which is the order a bigWig writer requires.
167pub fn for_each_score<F>(input: &Path, config: &ScanConfig, mut emit: F) -> io::Result<RunStats>
168where
169    F: FnMut(&str, usize, f64) -> io::Result<()>,
170{
171    let mut reader = open(input)?;
172    let mut stats = RunStats::default();
173
174    for result in reader.records() {
175        let record = result?;
176        let name = String::from_utf8_lossy(record.name()).into_owned();
177        let length = record.sequence().len();
178        stats.records += 1;
179        stats.longest_record = stats.longest_record.max(length);
180
181        let pieces = split_seq_by_gaps(record);
182        stats.pieces += pieces.len();
183        stats.scores += emit_pieces(&name, &pieces, 0, config, None, &mut emit)?;
184    }
185    Ok(stats)
186}
187
188/// Score every record, reading each one a window at a time through a FASTA index.
189///
190/// Without an index the reader hands over a whole record, so the largest record is a
191/// floor on memory. Querying windows lowers that floor to the window size.
192///
193/// Windows overlap by `lead_in` bases and each emits only the range it owns. The overlap
194/// is what makes a windowed run agree with a whole-record one: a piece straddling a
195/// boundary is truncated within the window and so loses `lead_in` scores at that
196/// artificial edge, but those positions belong to the neighbouring window, which sees
197/// them with full context.
198pub fn for_each_score_indexed<F>(
199    input: &Path,
200    index: &fai::Index,
201    config: &ScanConfig,
202    mut emit: F,
203) -> io::Result<RunStats>
204where
205    F: FnMut(&str, usize, f64) -> io::Result<()>,
206{
207    let inner = BufReader::new(File::open(input)?);
208    let mut reader = noodles_fasta::io::IndexedReader::new(inner, index.clone());
209    let lead = config.lead_in();
210    let window = config.window_bases.max(2 * lead + 1);
211    let mut stats = RunStats::default();
212
213    for record in index.as_ref() {
214        let name = String::from_utf8_lossy(record.name()).into_owned();
215        let length = record.length() as usize;
216        stats.records += 1;
217        stats.longest_record = stats.longest_record.max(length);
218        if length == 0 {
219            continue;
220        }
221
222        let mut own_start = 1usize; // 1-based, inclusive
223        while own_start <= length {
224            let own_end = (own_start + window - 1).min(length);
225            // Read the owned range plus context on each side, clamped to the record.
226            let read_start = own_start.saturating_sub(lead).max(1);
227            let read_end = (own_end + lead).min(length);
228
229            let region = Region::new(
230                name.as_str(),
231                Position::try_from(read_start).map_err(io::Error::other)?
232                    ..=Position::try_from(read_end).map_err(io::Error::other)?,
233            );
234            let slice = reader.query(&region)?;
235            let pieces = split_seq_by_gaps(slice);
236            if own_start == 1 {
237                stats.pieces += pieces.len();
238            }
239            stats.scores += emit_pieces(
240                &name,
241                &pieces,
242                read_start - 1,
243                config,
244                Some((own_start, own_end)),
245                &mut emit,
246            )?;
247
248            own_start = own_end + 1;
249        }
250    }
251    Ok(stats)
252}
253
254/// Score every record and hand its nucleosome calls to `on_record`, with the sequence.
255///
256/// Calls need two things the per-score path does not provide: the record's bases, for the
257/// sequence the reference puts in the GFF attribute column, and every call for a record at
258/// once, because the greedy selection ranks them against each other. Both are per record,
259/// so this reads whole records rather than windows even when an index is available.
260///
261/// Only dyads that actually score are retained, which is a small fraction of positions, so
262/// what is held is the calls for one record rather than its scores.
263pub fn for_each_record_calls<F>(
264    input: &Path,
265    config: &ScanConfig,
266    call_params: &CallParams,
267    greedy: bool,
268    mut on_record: F,
269) -> io::Result<RunStats>
270where
271    F: FnMut(&str, &[u8], &[NucleosomeCall], usize) -> io::Result<()>,
272{
273    let mut reader = open(input)?;
274    let mut stats = RunStats::default();
275
276    for result in reader.records() {
277        let record = result?;
278        let name = String::from_utf8_lossy(record.name()).into_owned();
279        let length = record.sequence().len();
280        stats.records += 1;
281        stats.longest_record = stats.longest_record.max(length);
282
283        // Keep the sequence alive for the attribute column while the pieces borrow it.
284        let sequence: Vec<u8> = record.sequence().as_ref().to_vec();
285        let pieces = split_seq_by_gaps(record);
286        stats.pieces += pieces.len();
287
288        let mut scored: Vec<(usize, f64)> = Vec::new();
289        emit_pieces(
290            &name,
291            &pieces,
292            0,
293            config,
294            None,
295            &mut |_, position, score| {
296                if score > 0.0 {
297                    // The reference indexes its arrays from zero; positions here are 1-based.
298                    scored.push((position - 1, score));
299                }
300                Ok(())
301            },
302        )?;
303
304        let calls = call_nucleosomes(&scored, length, call_params);
305        let calls = if greedy {
306            greedy_non_overlapping(&calls, call_params)
307        } else {
308            calls
309        };
310        stats.scores += calls.len();
311        on_record(&name, &sequence, &calls, call_params.half_width)?;
312    }
313    Ok(stats)
314}
315
316/// Read `<input>.fai` if it is there.
317///
318/// A missing index is not an error: it only means falling back to whole-record reads.
319/// A malformed one is an error, since silently ignoring it would quietly give up the
320/// lower memory the user was expecting.
321pub fn load_index(input: &Path) -> io::Result<Option<fai::Index>> {
322    let mut path = input.as_os_str().to_owned();
323    path.push(".fai");
324    let path = PathBuf::from(path);
325    if !path.exists() {
326        return Ok(None);
327    }
328    fai::fs::read(&path)
329        .map(Some)
330        .map_err(|e| io::Error::other(format!("cannot read {}: {e}", path.display())))
331}
332
333/// Chromosome names and lengths straight from an index, with no sequence read at all.
334pub fn chrom_sizes_from_index(index: &fai::Index) -> Vec<ChromSize> {
335    index
336        .as_ref()
337        .iter()
338        .map(|r| {
339            (
340                String::from_utf8_lossy(r.name()).into_owned(),
341                r.length() as u32,
342            )
343        })
344        .collect()
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::curve::matrix::RollType;
351    use noodles_fasta::io::Indexer;
352
353    fn params() -> CurveParams {
354        CurveParams {
355            roll_type: RollType::Simple,
356            step_b: 5,
357            step_c: 15,
358            curve_scale: 0.33335,
359        }
360    }
361
362    /// A small symmetry window, so test inputs need not be thousands of bases long.
363    fn sym() -> SymParams {
364        SymParams { win: 20, step: 1 }
365    }
366
367    fn config(stage: Stage, chunk_scores: usize, window_bases: usize) -> ScanConfig {
368        ScanConfig {
369            params: params(),
370            sym: sym(),
371            stage,
372            chunk_scores,
373            batch: 4,
374            window_bases,
375        }
376    }
377
378    fn tmp(name: &str) -> PathBuf {
379        let mut p = std::env::temp_dir();
380        p.push(format!("symcurve-stream-{}-{}", std::process::id(), name));
381        p
382    }
383
384    /// Write a FASTA with several records, gaps and soft-masked runs, plus its index.
385    fn write_fasta_and_index(tag: &str) -> PathBuf {
386        let unit = "CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
387        let mut text = String::new();
388        // Deliberately uneven: gaps and masked runs at awkward offsets so pieces
389        // straddle window boundaries rather than lining up with them.
390        for (name, spec) in [
391            (
392                "chrA",
393                vec![
394                    (unit.repeat(31), "NNNNN"),
395                    (unit.repeat(17), "RR"),
396                    (unit.repeat(23), ""),
397                ],
398            ),
399            (
400                "chrB",
401                vec![(unit.repeat(9).to_lowercase(), "NN"), (unit.repeat(41), "")],
402            ),
403            ("chrC", vec![(unit.repeat(5), "")]),
404        ] {
405            text.push_str(&format!(">{name}\n"));
406            let mut seq = String::new();
407            for (body, gap) in spec {
408                seq.push_str(&body);
409                seq.push_str(gap);
410            }
411            for line in seq.as_bytes().chunks(60) {
412                text.push_str(std::str::from_utf8(line).unwrap());
413                text.push('\n');
414            }
415        }
416        let path = tmp(&format!("{tag}.fa"));
417        std::fs::write(&path, text).unwrap();
418
419        // Build the .fai alongside it.
420        let mut indexer = Indexer::new(BufReader::new(File::open(&path).unwrap()));
421        let mut records = Vec::new();
422        while let Some(record) = indexer.index_record().unwrap() {
423            records.push(record);
424        }
425        let index = fai::Index::from(records);
426        let mut fai_path = path.as_os_str().to_owned();
427        fai_path.push(".fai");
428        fai::fs::write(PathBuf::from(fai_path), &index).unwrap();
429        path
430    }
431
432    fn collect(
433        run: impl FnOnce(&mut dyn FnMut(&str, usize, f64) -> io::Result<()>) -> io::Result<RunStats>,
434    ) -> (Vec<(String, usize, f64)>, RunStats) {
435        let mut out = Vec::new();
436        let stats = run(&mut |name, position, score| {
437            out.push((name.to_string(), position, score));
438            Ok(())
439        })
440        .unwrap();
441        (out, stats)
442    }
443
444    #[test]
445    fn test_indexed_and_whole_record_reads_agree() {
446        // The windowed path is only worth having if it computes the same thing. Windows
447        // are chosen small and awkward so that pieces and gaps straddle their edges.
448        let path = write_fasta_and_index("agree");
449        let index = load_index(&path).unwrap().expect("index should be found");
450
451        let (plain, plain_stats) =
452            collect(|emit| for_each_score(&path, &config(Stage::Curvature, 1000, 0), emit));
453        assert!(plain.len() > 5000, "test input too small to be meaningful");
454
455        for window in [2 * params().lead_in() + 1, 97, 1000, 7919, 1 << 20] {
456            let (windowed, windowed_stats) = collect(|emit| {
457                for_each_score_indexed(&path, &index, &config(Stage::Curvature, 1000, window), emit)
458            });
459            assert_eq!(
460                windowed.len(),
461                plain.len(),
462                "score count differs at window {window}"
463            );
464            assert_eq!(windowed_stats.records, plain_stats.records);
465            for (a, b) in plain.iter().zip(&windowed) {
466                assert_eq!(a.0, b.0, "chromosome differs at window {window}");
467                assert_eq!(a.1, b.1, "position differs at window {window}");
468                // Not bit-identical: a window accumulates twist over a different run.
469                let rel = (a.2 - b.2).abs() / a.2.abs().max(1e-12);
470                assert!(rel < 1e-6, "value {} vs {} at window {window}", a.2, b.2);
471            }
472        }
473        std::fs::remove_file(&path).ok();
474    }
475
476    #[test]
477    fn test_positions_are_emitted_in_order_and_without_duplicates() {
478        let path = write_fasta_and_index("order");
479        let index = load_index(&path).unwrap().unwrap();
480        let (out, _) = collect(|emit| {
481            for_each_score_indexed(&path, &index, &config(Stage::Curvature, 500, 331), emit)
482        });
483        let mut by_chrom: Vec<(&str, usize)> =
484            out.iter().map(|(c, p, _)| (c.as_str(), *p)).collect();
485        let before = by_chrom.len();
486        by_chrom.dedup();
487        assert_eq!(by_chrom.len(), before, "a position was emitted twice");
488        // Within each chromosome positions must strictly increase.
489        for pair in out.windows(2) {
490            if pair[0].0 == pair[1].0 {
491                assert!(
492                    pair[0].1 < pair[1].1,
493                    "out of order: {:?} then {:?}",
494                    pair[0],
495                    pair[1]
496                );
497            }
498        }
499        std::fs::remove_file(&path).ok();
500    }
501
502    #[test]
503    fn test_chrom_sizes_from_index_match_reading_the_file() {
504        let path = write_fasta_and_index("sizes");
505        let index = load_index(&path).unwrap().unwrap();
506        assert_eq!(chrom_sizes_from_index(&index), chrom_sizes(&path).unwrap());
507        std::fs::remove_file(&path).ok();
508    }
509
510    #[test]
511    fn test_missing_index_is_not_an_error() {
512        let path = tmp("no-index.fa");
513        std::fs::write(&path, ">c\nACGT\n").unwrap();
514        assert!(load_index(&path).unwrap().is_none());
515        std::fs::remove_file(&path).ok();
516    }
517
518    #[test]
519    fn test_malformed_index_is_an_error() {
520        // Silently ignoring a broken index would quietly give up the lower memory the
521        // user asked for.
522        let path = tmp("bad-index.fa");
523        std::fs::write(&path, ">c\nACGT\n").unwrap();
524        let mut fai = path.as_os_str().to_owned();
525        fai.push(".fai");
526        let fai = PathBuf::from(fai);
527        std::fs::write(&fai, "this is not an index\n").unwrap();
528        assert!(load_index(&path).is_err());
529        std::fs::remove_file(&path).ok();
530        std::fs::remove_file(&fai).ok();
531    }
532
533    #[test]
534    fn test_zero_length_index_records_are_skipped() {
535        let path = write_fasta_and_index("zerolen");
536        let index = load_index(&path).unwrap().unwrap();
537        let config = config(Stage::Curvature, 1000, 700);
538        let count = |index: &fai::Index| {
539            let n = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
540            let seen = std::sync::Arc::clone(&n);
541            let stats = for_each_score_indexed(&path, index, &config, move |_, _, _| {
542                seen.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
543                Ok(())
544            })
545            .unwrap();
546            (stats, n.load(std::sync::atomic::Ordering::Relaxed))
547        };
548        let (plain, plain_n) = count(&index);
549
550        // An empty record must be counted but never queried, since there is no range.
551        let mut records: Vec<fai::Record> = index.as_ref().to_vec();
552        let one = std::num::NonZero::new(1).unwrap();
553        records.push(fai::Record::new("empty", 0, 0, one, one));
554        let (padded, n) = count(&fai::Index::from(records));
555        assert_eq!(padded.records, plain.records + 1);
556        assert_eq!(padded.scores, plain.scores);
557        assert_eq!(n, plain_n);
558
559        std::fs::remove_file(&path).ok();
560        let mut fai_path = path.as_os_str().to_owned();
561        fai_path.push(".fai");
562        std::fs::remove_file(PathBuf::from(fai_path)).ok();
563    }
564}