1use 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#[derive(Debug, Clone, Copy)]
28pub struct ScanConfig {
29 pub params: CurveParams,
30 pub sym: SymParams,
31 pub stage: Stage,
32 pub chunk_scores: usize,
34 pub batch: usize,
36 pub window_bases: usize,
38}
39
40impl ScanConfig {
41 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 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
58pub type ChromSize = (String, u32);
60
61pub 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#[derive(Debug, Default, Clone, Copy)]
87pub struct RunStats {
88 pub records: usize,
89 pub pieces: usize,
90 pub scores: usize,
91 pub longest_record: usize,
93}
94
95#[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 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 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
162pub 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
188pub 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; while own_start <= length {
224 let own_end = (own_start + window - 1).min(length);
225 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(®ion)?;
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
254pub 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 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 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
316pub 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
333pub 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 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 fn write_fasta_and_index(tag: &str) -> PathBuf {
386 let unit = "CCAACATTTTGACTTTTTGGGAGGGCACTAGCACCTATCTACCCTGAATC";
387 let mut text = String::new();
388 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 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 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 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 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 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 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}