Skip to main content

symcurve/
output.rs

1//! Writing curvature scores out, in the format implied by the output path.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::fs::File;
6use std::io::{self, BufWriter, Write};
7use std::path::Path;
8use std::sync::mpsc;
9use std::sync::{Arc, Mutex};
10use std::thread;
11
12use bigtools::BigWigWrite;
13use bigtools::Value;
14
15use crate::curve::calls::NucleosomeCall;
16use bigtools::beddata::BedParserStreamingIterator;
17
18/// The output formats this tool can write.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OutputFormat {
21    /// Indexed binary bigWig, via bigtools.
22    BigWig,
23    /// Plain-text bedGraph: `chrom<TAB>start<TAB>end<TAB>value`, 0-based half-open.
24    BedGraph,
25    /// Plain-text GFF of nucleosome calls, one feature per call.
26    Gff,
27}
28
29impl OutputFormat {
30    /// Pick a format from the output path's extension.
31    ///
32    /// Chosen from the extension rather than a flag so that the output path alone says
33    /// what the file is, and an unrecognized extension is refused rather than guessed at:
34    /// silently writing text into a file named `.bw` would produce something no genome
35    /// browser can read.
36    pub fn from_path(path: &Path) -> Result<Self, UnknownFormat> {
37        let ext = path
38            .extension()
39            .and_then(|e| e.to_str())
40            .unwrap_or_default()
41            .to_ascii_lowercase();
42        match ext.as_str() {
43            "bw" | "bigwig" => Ok(OutputFormat::BigWig),
44            "bedgraph" | "bg" => Ok(OutputFormat::BedGraph),
45            "gff" | "gff2" | "gff3" => Ok(OutputFormat::Gff),
46            _ => Err(UnknownFormat {
47                path: path.display().to_string(),
48            }),
49        }
50    }
51}
52
53/// The output path's extension did not name a format this tool can write.
54#[derive(Debug)]
55pub struct UnknownFormat {
56    path: String,
57}
58
59impl fmt::Display for UnknownFormat {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(
62            f,
63            "cannot tell the output format from {:?}: expected one of .bw, .bigWig, .bedGraph, .bg",
64            self.path
65        )
66    }
67}
68
69impl std::error::Error for UnknownFormat {}
70
71/// A score at a position, as the writers want it.
72///
73/// Positions arrive 1-based and inclusive from the curve code and are converted here to
74/// the 0-based half-open convention both output formats use.
75fn interval(name: &str, position: usize, score: f64) -> (String, Value) {
76    let start = (position - 1) as u32;
77    (
78        name.to_string(),
79        Value {
80            start,
81            end: start + 1,
82            value: score as f32,
83        },
84    )
85}
86
87/// Write a bedGraph, pulling scores from `produce` as they are needed.
88///
89/// Nothing is retained: each score is formatted and written as it arrives.
90pub fn write_bedgraph_streaming<P>(
91    path: &Path,
92    produce: P,
93) -> Result<(), Box<dyn std::error::Error>>
94where
95    P: FnOnce(&mut dyn FnMut(&str, usize, f64) -> io::Result<()>) -> io::Result<()>,
96{
97    let mut out = BufWriter::new(File::create(path)?);
98    produce(&mut |name, position, score| {
99        let (chrom, value) = interval(name, position, score);
100        writeln!(
101            out,
102            "{}\t{}\t{}\t{}",
103            chrom, value.start, value.end, value.value
104        )
105    })?;
106    out.flush()?;
107    Ok(())
108}
109
110/// How many values travel through the channel at once.
111///
112/// Sending a hundred million values one at a time costs more in synchronization than in
113/// work, so they move in batches. Memory stays bounded because both the batch size and
114/// the number of batches in flight are fixed.
115const SEND_BATCH: usize = 8192;
116
117/// Write a bigWig, pulling scores from `produce` as they are needed.
118///
119/// bigtools takes an iterator, but the scoring loop pushes, so the producer runs on its
120/// own thread and feeds a bounded channel. The bound is what makes this streaming rather
121/// than merely deferred: when the writer falls behind, the producer blocks instead of
122/// queueing the genome up in memory.
123pub fn write_bigwig_streaming<P>(
124    path: &Path,
125    chrom_sizes: Vec<(String, u32)>,
126    queue_depth: usize,
127    produce: P,
128) -> Result<(), Box<dyn std::error::Error>>
129where
130    P: FnOnce(&mut dyn FnMut(&str, usize, f64) -> io::Result<()>) -> io::Result<()>
131        + Send
132        + 'static,
133{
134    let sizes: HashMap<String, u32> = chrom_sizes.into_iter().collect();
135    let writer = BigWigWrite::create_file(path, sizes)?;
136
137    let batches_in_flight = queue_depth.div_ceil(SEND_BATCH).max(1);
138    let (tx, rx) = mpsc::sync_channel::<Vec<(String, Value)>>(batches_in_flight);
139    // The producer's error is reported after the write finishes: a send failure only
140    // says the consumer went away, not why, and a read error must not look like a
141    // successful but truncated file.
142    let failure = Arc::new(Mutex::new(None::<String>));
143    let producer_failure = Arc::clone(&failure);
144
145    let producer = thread::spawn(move || {
146        let mut batch: Vec<(String, Value)> = Vec::with_capacity(SEND_BATCH);
147        let result = (|| {
148            produce(&mut |name, position, score| {
149                batch.push(interval(name, position, score));
150                if batch.len() == SEND_BATCH {
151                    let full = std::mem::replace(&mut batch, Vec::with_capacity(SEND_BATCH));
152                    // A closed channel means the writer stopped; report it so a partial
153                    // file is never mistaken for a complete one.
154                    tx.send(full)
155                        .map_err(|_| io::Error::other("bigWig writer stopped accepting values"))?;
156                }
157                Ok(())
158            })?;
159            if !batch.is_empty() {
160                tx.send(std::mem::take(&mut batch))
161                    .map_err(|_| io::Error::other("bigWig writer stopped accepting values"))?;
162            }
163            Ok::<(), io::Error>(())
164        })();
165        if let Err(err) = result {
166            *producer_failure.lock().unwrap() = Some(err.to_string());
167        }
168    });
169
170    let data = BedParserStreamingIterator::wrap_infallible_iter(rx.into_iter().flatten(), false);
171    let runtime = tokio::runtime::Builder::new_multi_thread().build()?;
172    let write_result = writer.write(data, runtime);
173
174    producer.join().map_err(|_| "scoring thread panicked")?;
175    if let Some(err) = failure.lock().unwrap().take() {
176        return Err(err.into());
177    }
178    write_result?;
179    Ok(())
180}
181
182/// Write nucleosome calls as GFF, pulling records from `produce` as they are needed.
183///
184/// One feature per call. The attribute column carries the called sequence, as the
185/// reference implementation does.
186///
187/// Coordinates follow the reference rather than the GFF specification. It prints
188/// `dyad - half_width` directly, which is a zero-based index into the record, where GFF
189/// expects one-based inclusive coordinates. Everything it emits is therefore one base to
190/// the left of where a browser will read it. Reproduced so that positions can be compared
191/// against the reference; see the Algorithm Issues page.
192pub fn write_gff_streaming<P>(
193    path: &Path,
194    feature: &str,
195    produce: P,
196) -> Result<(), Box<dyn std::error::Error>>
197where
198    P: FnOnce(
199        &mut dyn FnMut(&str, &[u8], &[NucleosomeCall], usize) -> io::Result<()>,
200    ) -> io::Result<()>,
201{
202    let mut out = BufWriter::new(File::create(path)?);
203    produce(&mut |name, bases, calls, half_width| {
204        for call in calls {
205            let start = call.dyad - half_width;
206            let end = call.dyad + half_width;
207            let sequence = std::str::from_utf8(&bases[start..=end]).unwrap_or("");
208            writeln!(
209                out,
210                "{name}\tevidence\t{feature}\t{start}\t{end}\t{}\t+\t.\t{sequence}",
211                call.reported_score()
212            )?;
213        }
214        Ok(())
215    })?;
216    out.flush()?;
217    Ok(())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::path::PathBuf;
224
225    /// A producer emitting a fixed set of scores, standing in for a scoring run.
226    fn produce(emit: &mut dyn FnMut(&str, usize, f64) -> io::Result<()>) -> io::Result<()> {
227        emit("chr1", 22, 1.5)?;
228        emit("chr1", 23, 2.5)?;
229        emit("chr1", 24, 3.5)?;
230        emit("chr2", 31, 4.0)?;
231        Ok(())
232    }
233
234    fn sizes() -> Vec<(String, u32)> {
235        vec![("chr1".to_string(), 100), ("chr2".to_string(), 60)]
236    }
237
238    fn tmp(name: &str) -> PathBuf {
239        let mut p = std::env::temp_dir();
240        p.push(format!("symcurve-out-{}-{}", std::process::id(), name));
241        p
242    }
243
244    #[test]
245    fn test_format_from_extension() {
246        let cases = [
247            ("out.bw", Some(OutputFormat::BigWig)),
248            ("out.bigWig", Some(OutputFormat::BigWig)),
249            ("OUT.BW", Some(OutputFormat::BigWig)),
250            ("out.bedGraph", Some(OutputFormat::BedGraph)),
251            ("out.bg", Some(OutputFormat::BedGraph)),
252            ("out.txt", None),
253            ("out", None),
254        ];
255        for (name, expected) in cases {
256            assert_eq!(
257                OutputFormat::from_path(Path::new(name)).ok(),
258                expected,
259                "for {name}"
260            );
261        }
262    }
263
264    #[test]
265    fn test_unknown_extension_message_lists_the_options() {
266        let err = OutputFormat::from_path(Path::new("out.txt")).unwrap_err();
267        let msg = err.to_string();
268        assert!(msg.contains("out.txt"), "{msg}");
269        assert!(msg.contains(".bw") && msg.contains(".bedGraph"), "{msg}");
270    }
271
272    #[test]
273    fn test_interval_is_zero_based_half_open() {
274        // Position 22 is 1-based, so the interval covers [21, 22).
275        let (chrom, value) = interval("chr1", 22, 1.5);
276        assert_eq!(chrom, "chr1");
277        assert_eq!((value.start, value.end), (21, 22));
278        assert!((value.value - 1.5).abs() < 1e-6);
279    }
280
281    #[test]
282    fn test_bedgraph_contents() {
283        let path = tmp("out.bedGraph");
284        write_bedgraph_streaming(&path, produce).unwrap();
285        let text = std::fs::read_to_string(&path).unwrap();
286        let lines: Vec<&str> = text.lines().collect();
287        assert_eq!(lines.len(), 4);
288        assert_eq!(lines[0], "chr1\t21\t22\t1.5");
289        assert_eq!(lines[3], "chr2\t30\t31\t4");
290        std::fs::remove_file(&path).ok();
291    }
292
293    #[test]
294    fn test_bigwig_round_trips() {
295        // Write a bigWig and read it back, rather than checking the magic bytes: a file
296        // with the right header but wrong contents would still be useless.
297        let path = tmp("out.bw");
298        write_bigwig_streaming(&path, sizes(), 8, produce).unwrap();
299
300        let mut read = bigtools::BigWigRead::open_file(&path).unwrap();
301        let mut chroms: Vec<_> = read
302            .chroms()
303            .iter()
304            .map(|c| (c.name.clone(), c.length))
305            .collect();
306        chroms.sort();
307        assert_eq!(
308            chroms,
309            vec![("chr1".to_string(), 100u32), ("chr2".to_string(), 60u32)]
310        );
311
312        let values: Vec<_> = read.values("chr1", 21, 24).unwrap();
313        assert_eq!(values.len(), 3);
314        assert!((values[0] - 1.5).abs() < 1e-6, "{values:?}");
315        assert!((values[2] - 3.5).abs() < 1e-6, "{values:?}");
316        std::fs::remove_file(&path).ok();
317    }
318
319    #[test]
320    fn test_bigwig_survives_a_queue_depth_of_one() {
321        // A depth of 1 makes the producer block on nearly every value, which is the
322        // backpressure path; it must produce the same file, not deadlock.
323        let path = tmp("depth1.bw");
324        write_bigwig_streaming(&path, sizes(), 1, produce).unwrap();
325        let mut read = bigtools::BigWigRead::open_file(&path).unwrap();
326        let values: Vec<_> = read.values("chr1", 21, 24).unwrap();
327        assert_eq!(values.len(), 3);
328        std::fs::remove_file(&path).ok();
329    }
330
331    #[test]
332    fn test_producer_error_is_reported_not_silently_truncated() {
333        // A read failure partway through must not look like a short but successful file.
334        let path = tmp("fail.bw");
335        let failing = |emit: &mut dyn FnMut(&str, usize, f64) -> io::Result<()>| {
336            emit("chr1", 22, 1.5)?;
337            Err(io::Error::other("synthetic read failure"))
338        };
339        let err = write_bigwig_streaming(&path, sizes(), 8, failing).unwrap_err();
340        assert!(err.to_string().contains("synthetic read failure"), "{err}");
341        std::fs::remove_file(&path).ok();
342    }
343
344    #[test]
345    fn test_bigwig_streaming_flushes_full_batches() {
346        // More values than one send batch, so the mid-run flush runs and not only the
347        // final drain.
348        let n = 2 * SEND_BATCH + 7;
349        let path = tmp("batches.bw");
350        let many = move |emit: &mut dyn FnMut(&str, usize, f64) -> io::Result<()>| {
351            for i in 0..n {
352                emit("chr1", i + 1, i as f64)?;
353            }
354            Ok(())
355        };
356        write_bigwig_streaming(&path, vec![("chr1".to_string(), n as u32)], 8, many).unwrap();
357
358        let mut read = bigtools::BigWigRead::open_file(&path).unwrap();
359        let values: Vec<_> = read.values("chr1", 0, n as u32).unwrap();
360        assert_eq!(values.len(), n);
361        assert_eq!(values[0], 0.0);
362        assert_eq!(values[n - 1], (n - 1) as f32);
363        std::fs::remove_file(&path).ok();
364    }
365}