1use crate::fasta::FastaReader;
12use anyhow::{bail, Context, Result};
13use clap::Parser;
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::env;
16use std::f64::consts::TAU;
17use std::fs::{create_dir_all, remove_file, File};
18use std::io::{BufRead, BufReader, BufWriter, Write};
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22const SECOND_ISOFORM_PROBABILITY: f64 = 0.5;
24const SWITCHING_HIGH_USAGE: f64 = 0.85;
26const SWITCHING_LOW_USAGE: f64 = 0.15;
28const NON_SWITCHING_MAJOR_USAGE: f64 = 0.90;
30
31const SHORT_CIRC_FULL_LENGTH_MAX_LEN: usize = 300;
35
36const SHORT_CIRC_FULL_LENGTH_BIAS_FRACTION: f64 = 0.70;
39
40const OUTWARD_TRUTH_MIN_PAIR_OFFSET: usize = 19;
48
49#[derive(Parser, Debug, Clone)]
55#[command(
56 author,
57 version,
58 about = "Generate CIRI simulator FASTQ and truth tables",
59 long_about = None
60)]
61pub struct SimulateArgs {
62 #[arg(short = 'r', long = "ref")]
64 pub ref_fasta: String,
65
66 #[arg(short = 'a', long = "anno")]
68 pub gtf: String,
69
70 #[arg(short = 'o', long = "out", num_args = 1..)]
72 pub out_prefixes: Vec<String>,
73
74 #[arg(long = "chrom")]
76 pub chrom: Option<String>,
77
78 #[arg(long = "circ-count", default_value_t = 100)]
80 pub circ_count: usize,
81
82 #[arg(long = "switching-event", default_value_t = 0)]
84 pub switching_event: usize,
85
86 #[arg(long = "circ-coverage", default_value_t = 10.0)]
88 pub circ_coverage: f64,
89
90 #[arg(long = "linear-coverage", default_value_t = 0.1)]
92 pub linear_coverage: f64,
93
94 #[arg(long = "scale", default_value_t = 0.5)]
96 pub scale: f64,
97
98 #[arg(long = "read-len", default_value_t = 150)]
100 pub read_len: usize,
101
102 #[arg(long = "insert-len", default_value_t = 260)]
104 pub insert_len: usize,
105
106 #[arg(long = "insert-sd", default_value_t = 40.0)]
108 pub insert_sd: f64,
109
110 #[arg(long = "insert-len-minor", default_value_t = 420)]
112 pub insert_len_minor: usize,
113
114 #[arg(long = "insert-sd-minor", default_value_t = 60.0)]
116 pub insert_sd_minor: f64,
117
118 #[arg(long = "minor-insert-fraction", default_value_t = 0.10)]
120 pub minor_insert_fraction: f64,
121
122 #[arg(long = "error-rate", default_value_t = 0.002)]
124 pub error_rate: f64,
125
126 #[arg(long = "exon-exclusive-rate", default_value_t = 0.25)]
128 pub exon_exclusive_rate: f64,
129
130 #[arg(long = "seed", default_value_t = 5)]
132 pub seed: u64,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SimulationSummary {
143 pub sample_count: usize,
145 pub circ_count: usize,
147 pub isoform_count: usize,
149 pub total_read_pairs: usize,
151 pub circ_read_pairs: usize,
153 pub linear_read_pairs: usize,
155 pub bsj_reads: usize,
157 pub bsj_read_pairs: usize,
159}
160
161impl std::fmt::Display for SimulationSummary {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 if self.sample_count <= 1 {
164 write!(
165 f,
166 "Simulated {} circRNAs, {} circular isoforms\nTotal {} read pairs, {} circRNA read pairs, {} linear read pairs\nBSJ feature: {} reads / {} read pairs",
167 self.circ_count,
168 self.isoform_count,
169 self.total_read_pairs,
170 self.circ_read_pairs,
171 self.linear_read_pairs,
172 self.bsj_reads,
173 self.bsj_read_pairs
174 )
175 } else {
176 write!(
177 f,
178 "Simulated {} samples, {} circRNAs, {} circular isoforms\nTotal {} read pairs, {} circRNA read pairs, {} linear read pairs\nBSJ feature: {} reads / {} read pairs",
179 self.sample_count,
180 self.circ_count,
181 self.isoform_count,
182 self.total_read_pairs,
183 self.circ_read_pairs,
184 self.linear_read_pairs,
185 self.bsj_reads,
186 self.bsj_read_pairs
187 )
188 }
189 }
190}
191
192#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
197struct ExonKey {
198 chrom: String,
199 start: usize,
200 end: usize,
201 strand: char,
202}
203
204#[derive(Clone, Debug)]
206struct Exon {
207 start: usize,
208 end: usize,
209 exclusive: bool,
210}
211
212impl Exon {
213 fn len(&self) -> usize {
215 self.end.saturating_sub(self.start).saturating_add(1)
216 }
217}
218
219#[derive(Clone, Debug)]
221struct Transcript {
222 chrom: String,
223 gene_id: String,
224 transcript_id: String,
225 strand: char,
226 exons: Vec<Exon>,
227}
228
229#[derive(Clone, Debug)]
234struct GtfRecord {
235 line: String,
236 feature: String,
237 gene_id: Option<String>,
238 transcript_id: Option<String>,
239 exon_key: Option<ExonKey>,
240}
241
242#[derive(Clone, Debug)]
244struct AnnotationModel {
245 records: Vec<GtfRecord>,
246 transcripts: Vec<Transcript>,
247 linear_transcripts: Vec<Transcript>,
248 exclusive_exons: BTreeSet<ExonKey>,
249 surviving_genes: HashSet<String>,
250 surviving_transcripts: HashSet<String>,
251}
252
253#[derive(Clone, Debug)]
255struct SimCirc {
256 circ_id: String,
257 chrom: String,
258 start: usize,
259 end: usize,
260 strand: char,
261 gene_id: String,
262 transcript_id: String,
263 isoforms: Vec<SimIsoform>,
264 sampled_circ_coverage: f64,
265 read_pair_count: usize,
266 bsj_read_pair_count: usize,
267 bsj_read_count: usize,
268}
269
270#[derive(Clone, Debug)]
272struct SimIsoform {
273 isoform_id: String,
274 exon_chain: Vec<Exon>,
275 seq: String,
276 read_pair_count: usize,
277 bsj_read_pair_count: usize,
278}
279
280#[derive(Clone, Debug)]
286struct SampleCircUsage {
287 weights: Vec<f64>,
288}
289
290#[derive(Clone, Debug)]
292struct SampleUsageProfile {
293 prefix: String,
294 by_circ: Vec<SampleCircUsage>,
295}
296
297#[derive(Clone, Debug)]
299struct SourceBase {
300 coord: usize,
301 exon_idx: usize,
302}
303
304#[derive(Clone, Debug)]
306struct MateSegments {
307 text: String,
308 is_bsj: bool,
309}
310
311#[derive(Clone, Debug)]
313struct SegmentToken {
314 start: usize,
315 end: usize,
316 partition: usize,
317}
318
319#[derive(Clone, Debug)]
321struct Lcg64 {
322 state: u64,
323}
324
325impl Lcg64 {
326 fn new(seed: u64) -> Self {
328 Self {
329 state: seed ^ 0x9E37_79B9_7F4A_7C15,
330 }
331 }
332
333 fn next_u64(&mut self) -> u64 {
335 self.state = self
336 .state
337 .wrapping_mul(6364136223846793005)
338 .wrapping_add(1442695040888963407);
339 self.state
340 }
341
342 fn gen_range(&mut self, upper: usize) -> usize {
344 if upper <= 1 {
345 0
346 } else {
347 (self.next_u64() as usize) % upper
348 }
349 }
350
351 fn gen_f64(&mut self) -> f64 {
353 const SCALE: f64 = (1u64 << 53) as f64;
354 ((self.next_u64() >> 11) as f64) / SCALE
355 }
356
357 fn gen_standard_normal(&mut self) -> f64 {
359 let u1 = self.gen_f64().clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
360 let u2 = self.gen_f64();
361 (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos()
362 }
363}
364
365fn prefixed_path(prefix: &str, suffix: &str) -> PathBuf {
367 PathBuf::from(format!("{}{}", prefix, suffix))
368}
369
370fn attr_value(attrs: &str, key: &str) -> Option<String> {
372 for item in attrs.split(';') {
373 let trimmed = item.trim();
374 if !trimmed.starts_with(key) {
375 continue;
376 }
377 return trimmed.split('"').nth(1).map(str::to_string);
378 }
379 None
380}
381
382fn read_annotation_model(
389 gtf: &str,
390 chrom_filter: Option<&str>,
391 exon_exclusive_rate: f64,
392 rng: &mut Lcg64,
393) -> Result<AnnotationModel> {
394 let file = File::open(gtf).with_context(|| format!("cannot open GTF: {gtf}"))?;
395 let reader = BufReader::new(file);
396 let mut records = Vec::new();
397 let mut transcript_exons: BTreeMap<(String, String, String), (char, Vec<(ExonKey, Exon)>)> =
398 BTreeMap::new();
399 let mut unique_exons = BTreeSet::new();
400
401 for line_res in reader.lines() {
402 let line = line_res?;
403 if line.starts_with('#') || line.trim().is_empty() {
404 continue;
405 }
406 let fields: Vec<&str> = line.split('\t').collect();
407 if fields.len() < 9 {
408 continue;
409 }
410 if let Some(chrom) = chrom_filter {
411 if fields[0] != chrom {
412 continue;
413 }
414 }
415 let feature = fields[2].to_string();
416 if !matches!(feature.as_str(), "gene" | "transcript" | "exon") {
417 continue;
418 }
419 let chrom = fields[0].to_string();
420 let start = fields[3].parse::<usize>()?;
421 let end = fields[4].parse::<usize>()?;
422 let strand = fields[6].chars().next().unwrap_or('+');
423 let gene_id = attr_value(fields[8], "gene_id");
424 let transcript_id = attr_value(fields[8], "transcript_id");
425 let exon_key = (feature == "exon").then(|| ExonKey {
426 chrom: chrom.clone(),
427 start,
428 end,
429 strand,
430 });
431
432 if let Some(key) = &exon_key {
433 unique_exons.insert(key.clone());
434 let gene = gene_id.clone().unwrap_or_else(|| "NA".to_string());
435 let tx = transcript_id
436 .clone()
437 .unwrap_or_else(|| format!("{gene}:tx"));
438 transcript_exons
439 .entry((chrom.clone(), gene, tx))
440 .or_insert_with(|| (strand, Vec::new()))
441 .1
442 .push((
443 key.clone(),
444 Exon {
445 start,
446 end,
447 exclusive: false,
448 },
449 ));
450 }
451
452 records.push(GtfRecord {
453 line,
454 feature,
455 gene_id,
456 transcript_id,
457 exon_key,
458 });
459 }
460
461 let mut exclusive_exons = BTreeSet::new();
462 for exon in &unique_exons {
463 if rng.gen_f64() <= exon_exclusive_rate {
464 exclusive_exons.insert(exon.clone());
465 }
466 }
467
468 let mut transcripts = Vec::new();
469 let mut linear_transcripts = Vec::new();
470 let mut surviving_genes = HashSet::new();
471 let mut surviving_transcripts = HashSet::new();
472
473 for ((chrom, gene_id, transcript_id), (strand, exon_items)) in transcript_exons {
474 let mut full_exons = Vec::new();
475 let mut linear_exons = Vec::new();
476 for (key, mut exon) in exon_items {
477 exon.exclusive = exclusive_exons.contains(&key);
478 full_exons.push(exon.clone());
479 if !exon.exclusive {
480 linear_exons.push(exon);
481 }
482 }
483 full_exons.sort_by_key(|exon| exon.start);
484 linear_exons.sort_by_key(|exon| exon.start);
485 if !full_exons.is_empty() {
486 transcripts.push(Transcript {
487 chrom: chrom.clone(),
488 gene_id: gene_id.clone(),
489 transcript_id: transcript_id.clone(),
490 strand,
491 exons: full_exons,
492 });
493 }
494 if !linear_exons.is_empty() {
495 surviving_genes.insert(gene_id.clone());
496 surviving_transcripts.insert(transcript_id.clone());
497 linear_transcripts.push(Transcript {
498 chrom,
499 gene_id,
500 transcript_id,
501 strand,
502 exons: linear_exons,
503 });
504 }
505 }
506
507 Ok(AnnotationModel {
508 records,
509 transcripts,
510 linear_transcripts,
511 exclusive_exons,
512 surviving_genes,
513 surviving_transcripts,
514 })
515}
516
517fn write_linear_annotation(model: &AnnotationModel, prefix: &str) -> Result<()> {
523 let mut writer = BufWriter::new(File::create(prefixed_path(prefix, ".annotation.gtf"))?);
524 for record in &model.records {
525 match record.feature.as_str() {
526 "gene" => {
527 let Some(gene_id) = &record.gene_id else {
528 continue;
529 };
530 if model.surviving_genes.contains(gene_id) {
531 writeln!(writer, "{}", record.line)?;
532 }
533 }
534 "transcript" => {
535 let Some(transcript_id) = &record.transcript_id else {
536 continue;
537 };
538 if model.surviving_transcripts.contains(transcript_id) {
539 writeln!(writer, "{}", record.line)?;
540 }
541 }
542 "exon" => {
543 let Some(exon_key) = &record.exon_key else {
544 continue;
545 };
546 let Some(transcript_id) = &record.transcript_id else {
547 continue;
548 };
549 if !model.exclusive_exons.contains(exon_key)
550 && model.surviving_transcripts.contains(transcript_id)
551 {
552 writeln!(writer, "{}", record.line)?;
553 }
554 }
555 _ => {}
556 }
557 }
558 Ok(())
559}
560
561fn revcomp(seq: &str) -> String {
563 seq.as_bytes()
564 .iter()
565 .rev()
566 .map(|base| match base.to_ascii_uppercase() {
567 b'A' => 'T',
568 b'T' => 'A',
569 b'C' => 'G',
570 b'G' => 'C',
571 _ => 'N',
572 })
573 .collect()
574}
575
576fn genomic_slice<'a>(
578 reference: &'a HashMap<String, String>,
579 chrom: &str,
580 exon: &Exon,
581) -> Result<&'a str> {
582 let chr_seq = reference
583 .get(chrom)
584 .with_context(|| format!("reference FASTA does not contain chromosome {chrom}"))?;
585 if exon.start == 0 || exon.end > chr_seq.len() || exon.start > exon.end {
586 bail!("invalid exon interval {chrom}:{}-{}", exon.start, exon.end);
587 }
588 Ok(&chr_seq[exon.start - 1..exon.end])
589}
590
591fn isoform_sequence(
597 reference: &HashMap<String, String>,
598 chrom: &str,
599 strand: char,
600 exons: &[Exon],
601) -> Result<String> {
602 let mut seq = String::new();
603 if strand == '-' {
604 for exon in exons.iter().rev() {
605 seq.push_str(&revcomp(genomic_slice(reference, chrom, exon)?));
606 }
607 } else {
608 for exon in exons {
609 seq.push_str(genomic_slice(reference, chrom, exon)?);
610 }
611 }
612 Ok(seq)
613}
614
615fn source_map_for_exons(exons: &[Exon], strand: char) -> Vec<SourceBase> {
620 let mut map = Vec::new();
621 if strand == '-' {
622 for (exon_idx, exon) in exons.iter().enumerate().rev() {
623 for coord in (exon.start..=exon.end).rev() {
624 map.push(SourceBase { coord, exon_idx });
625 }
626 }
627 } else {
628 for (exon_idx, exon) in exons.iter().enumerate() {
629 for coord in exon.start..=exon.end {
630 map.push(SourceBase { coord, exon_idx });
631 }
632 }
633 }
634 map
635}
636
637fn exon_chain_string(exons: &[Exon], strand: char) -> String {
639 exons
640 .iter()
641 .map(|exon| {
642 let suffix = if exon.exclusive { "*" } else { "" };
643 format!("{}-{}:{}{}", exon.start, exon.end, strand, suffix)
644 })
645 .collect::<Vec<_>>()
646 .join(",")
647}
648
649fn circular_slice(seq: &str, start: usize, len: usize) -> String {
651 let bytes = seq.as_bytes();
652 let mut out = String::with_capacity(len);
653 for offset in 0..len {
654 out.push(bytes[(start + offset) % bytes.len()] as char);
655 }
656 out
657}
658
659fn linear_slice(seq: &str, start: usize, len: usize) -> Option<&str> {
661 seq.get(start..start + len)
662}
663
664fn crosses_boundary(start: usize, len: usize, seq_len: usize) -> bool {
666 start + len > seq_len
667}
668
669fn format_segments(
679 source_map: &[SourceBase],
680 strand: char,
681 start: usize,
682 len: usize,
683 circular: bool,
684) -> MateSegments {
685 let seq_len = source_map.len();
686 let positions: Vec<usize> = (0..len)
687 .map(|offset| {
688 if circular {
689 (start + offset) % seq_len
690 } else {
691 start + offset
692 }
693 })
694 .collect();
695 let mut segments = Vec::new();
696 let mut current: Option<(usize, usize, usize, usize)> = None;
697 let mut is_bsj = false;
698 let mut partition = 0usize;
699 let flush_current =
700 |segments: &mut Vec<SegmentToken>, current: &mut Option<(usize, usize, usize, usize)>| {
701 if let Some((min_coord, max_coord, _exon_idx, partition)) = current.take() {
702 segments.push(SegmentToken {
703 start: min_coord,
704 end: max_coord,
705 partition,
706 });
707 }
708 };
709
710 for (idx, pos) in positions.iter().copied().enumerate() {
711 if idx > 0 && circular {
712 let prev = positions[idx - 1];
713 if (prev == seq_len - 1 && pos == 0) || (prev == 0 && pos == seq_len - 1) {
714 flush_current(&mut segments, &mut current);
715 partition += 1;
716 is_bsj = true;
717 }
718 }
719
720 let base = &source_map[pos];
721 match current {
722 Some((ref mut min_coord, ref mut max_coord, exon_idx, existing_partition))
723 if exon_idx == base.exon_idx
724 && existing_partition == partition
725 && (base.coord.abs_diff(*min_coord) == 1
726 || base.coord.abs_diff(*max_coord) == 1) =>
727 {
728 *min_coord = (*min_coord).min(base.coord);
729 *max_coord = (*max_coord).max(base.coord);
730 }
731 Some(_) => {
732 flush_current(&mut segments, &mut current);
733 current = Some((base.coord, base.coord, base.exon_idx, partition));
734 }
735 None => current = Some((base.coord, base.coord, base.exon_idx, partition)),
736 }
737 }
738 flush_current(&mut segments, &mut current);
739
740 let mut out = Vec::with_capacity(segments.len() + usize::from(is_bsj));
741 let mut previous_partition = None;
742 for segment in segments {
743 if previous_partition.is_some_and(|partition| partition != segment.partition) {
744 out.push("<bsj>".to_string());
745 }
746 out.push(format!("{}-{}:{strand}", segment.start, segment.end));
747 previous_partition = Some(segment.partition);
748 }
749 let text = out.join("|");
750
751 MateSegments { text, is_bsj }
752}
753
754fn first_read_chain_segment_span(
761 source_map: &[SourceBase],
762 start: usize,
763 len: usize,
764 circular: bool,
765) -> Option<(usize, usize)> {
766 let seq_len = source_map.len();
767 let mut iter = (0..len).map(|offset| {
768 if circular {
769 (start + offset) % seq_len
770 } else {
771 start + offset
772 }
773 });
774 let first_pos = iter.next()?;
775 let first = &source_map[first_pos];
776 let mut min_coord = first.coord;
777 let mut max_coord = first.coord;
778 let exon_idx = first.exon_idx;
779 let mut prev_pos = first_pos;
780
781 for pos in iter {
782 if circular
783 && ((prev_pos == seq_len - 1 && pos == 0) || (prev_pos == 0 && pos == seq_len - 1))
784 {
785 break;
786 }
787 let base = &source_map[pos];
788 if base.exon_idx != exon_idx
789 || (base.coord.abs_diff(min_coord) != 1 && base.coord.abs_diff(max_coord) != 1)
790 {
791 break;
792 }
793 min_coord = min_coord.min(base.coord);
794 max_coord = max_coord.max(base.coord);
795 prev_pos = pos;
796 }
797
798 Some((min_coord, max_coord))
799}
800
801fn is_outward_facing_truth(
811 source_map: &[SourceBase],
812 strand: char,
813 r1_start: usize,
814 r2_start: usize,
815 read_len: usize,
816) -> bool {
817 let Some(r1_span) = first_read_chain_segment_span(source_map, r1_start, read_len, true) else {
818 return false;
819 };
820 let Some(r2_span) = first_read_chain_segment_span(source_map, r2_start, read_len, true) else {
821 return false;
822 };
823 let (reverse_span, forward_span) = if strand == '-' {
824 (r1_span, r2_span)
825 } else {
826 (r2_span, r1_span)
827 };
828 let start_offset = forward_span.0.saturating_sub(reverse_span.0);
829 let end_offset = forward_span.1.saturating_sub(reverse_span.1);
830 start_offset >= OUTWARD_TRUTH_MIN_PAIR_OFFSET && end_offset >= OUTWARD_TRUTH_MIN_PAIR_OFFSET
831}
832
833fn sample_coverage(mean: f64, scale: f64, rng: &mut Lcg64) -> f64 {
835 if mean <= 0.0 {
836 return 0.0;
837 }
838 if scale <= 0.0 {
839 return mean;
840 }
841 (mean + rng.gen_standard_normal() * mean * scale).max(0.0)
842}
843
844fn circular_pair_count(circ_len: usize, coverage: f64, read_len: usize) -> usize {
849 ((circ_len as f64 * coverage) / (read_len as f64 * 2.0))
850 .round()
851 .max(1.0) as usize
852}
853
854fn linear_pair_target(
856 transcripts: &[Transcript],
857 args: &SimulateArgs,
858 sampled_linear_coverage: f64,
859) -> usize {
860 let total_bases: usize = transcripts
861 .iter()
862 .flat_map(|tx| tx.exons.iter())
863 .map(Exon::len)
864 .sum();
865 ((total_bases as f64 * sampled_linear_coverage) / (args.read_len as f64 * 2.0))
866 .round()
867 .max(0.0) as usize
868}
869
870fn sample_insert_len(args: &SimulateArgs, rng: &mut Lcg64) -> usize {
875 let use_minor = rng.gen_f64() < args.minor_insert_fraction;
876 let (mean, sd) = if use_minor {
877 (args.insert_len_minor as f64, args.insert_sd_minor)
878 } else {
879 (args.insert_len as f64, args.insert_sd)
880 };
881 let sampled = mean + rng.gen_standard_normal() * sd;
882 sampled.round().max(args.read_len as f64) as usize
883}
884
885fn sample_circular_insert_len(seq_len: usize, args: &SimulateArgs, rng: &mut Lcg64) -> usize {
887 if seq_len > SHORT_CIRC_FULL_LENGTH_MAX_LEN
888 || rng.gen_f64() >= SHORT_CIRC_FULL_LENGTH_BIAS_FRACTION
889 {
890 return sample_insert_len(args, rng);
891 }
892
893 let mean = seq_len as f64;
894 let sd = ((seq_len as f64) * 0.06).max(8.0);
895 let lower = args.read_len as f64;
896 let upper = (seq_len + args.read_len / 3).max(args.read_len) as f64;
897 let sampled = mean + rng.gen_standard_normal() * sd;
898 sampled.round().clamp(lower, upper) as usize
899}
900
901fn mutate_read(seq: &str, error_rate: f64, rng: &mut Lcg64) -> String {
903 if error_rate <= 0.0 {
904 return seq.to_string();
905 }
906 let mut out = String::with_capacity(seq.len());
907 for base in seq.bytes() {
908 let upper = base.to_ascii_uppercase();
909 if !matches!(upper, b'A' | b'C' | b'G' | b'T') || rng.gen_f64() >= error_rate {
910 out.push(upper as char);
911 continue;
912 }
913 let choices = match upper {
914 b'A' => [b'C', b'G', b'T'],
915 b'C' => [b'A', b'G', b'T'],
916 b'G' => [b'A', b'C', b'T'],
917 b'T' => [b'A', b'C', b'G'],
918 _ => unreachable!(),
919 };
920 out.push(choices[rng.gen_range(choices.len())] as char);
921 }
922 out
923}
924
925fn write_fastq(
927 writer: &mut BufWriter<File>,
928 read_id: &str,
929 seq: &str,
930 qual: &str,
931 metadata: &str,
932) -> Result<()> {
933 writeln!(writer, "@{read_id} {metadata}")?;
934 writeln!(writer, "{seq}")?;
935 writeln!(writer, "+")?;
936 writeln!(writer, "{qual}")?;
937 Ok(())
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941enum FastqCompressor {
942 Pigz,
943 Gzip,
944}
945
946impl FastqCompressor {
947 fn program(self) -> &'static str {
949 match self {
950 Self::Pigz => "pigz",
951 Self::Gzip => "gzip",
952 }
953 }
954}
955
956fn select_fastq_compressor() -> Result<FastqCompressor> {
958 let compressor = select_fastq_compressor_from_availability(
959 executable_exists("pigz"),
960 executable_exists("gzip"),
961 )?;
962 if compressor == FastqCompressor::Gzip {
963 eprintln!("pigz not found; falling back to gzip for simulator FASTQ compression");
964 }
965 Ok(compressor)
966}
967
968fn executable_exists(program: &str) -> bool {
970 if program.is_empty() {
971 return false;
972 }
973 let Some(paths) = env::var_os("PATH") else {
974 return false;
975 };
976 env::split_paths(&paths).any(|dir| is_executable_file(&dir.join(program)))
977}
978
979#[cfg(unix)]
980fn is_executable_file(path: &Path) -> bool {
981 use std::os::unix::fs::PermissionsExt;
982
983 path.is_file()
984 && path
985 .metadata()
986 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
987 .unwrap_or(false)
988}
989
990#[cfg(not(unix))]
991fn is_executable_file(path: &Path) -> bool {
992 path.is_file()
993}
994
995fn select_fastq_compressor_from_availability(
996 has_pigz: bool,
997 has_gzip: bool,
998) -> Result<FastqCompressor> {
999 if has_pigz {
1000 Ok(FastqCompressor::Pigz)
1001 } else if has_gzip {
1002 Ok(FastqCompressor::Gzip)
1003 } else {
1004 bail!("failed to compress simulator FASTQ: neither pigz nor gzip was found in PATH")
1005 }
1006}
1007
1008fn compress_fastq_to_gz(
1010 input_path: &Path,
1011 output_path: &Path,
1012 compressor: FastqCompressor,
1013) -> Result<()> {
1014 run_fastq_compressor(compressor.program(), input_path, output_path)?;
1015 remove_file(input_path)
1016 .with_context(|| format!("remove temporary FASTQ {}", input_path.display()))?;
1017 Ok(())
1018}
1019
1020fn run_fastq_compressor(program: &str, input_path: &Path, output_path: &Path) -> Result<()> {
1022 let output = File::create(output_path)
1023 .with_context(|| format!("create compressed FASTQ {}", output_path.display()))?;
1024 let status = match Command::new(program)
1025 .arg("-n")
1026 .arg("-c")
1027 .arg(input_path)
1028 .stdout(Stdio::from(output))
1029 .stderr(Stdio::null())
1030 .status()
1031 {
1032 Ok(status) => status,
1033 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1034 let _ = remove_file(output_path);
1035 bail!("{program} disappeared before FASTQ compression could run");
1036 }
1037 Err(err) => {
1038 let _ = remove_file(output_path);
1039 return Err(err).with_context(|| format!("run {program}"));
1040 }
1041 };
1042
1043 if status.success() {
1044 Ok(())
1045 } else {
1046 let _ = remove_file(output_path);
1047 bail!(
1048 "{program} exited with {status} while compressing {}",
1049 input_path.display()
1050 )
1051 }
1052}
1053
1054fn build_circs(
1056 transcripts: &[Transcript],
1057 reference: &HashMap<String, String>,
1058 args: &SimulateArgs,
1059 rng: &mut Lcg64,
1060) -> Result<Vec<SimCirc>> {
1061 let min_len = args.read_len.max(80);
1062 let mut candidates: Vec<usize> = transcripts
1063 .iter()
1064 .enumerate()
1065 .filter_map(|(idx, tx)| (tx.exons.len() >= 2).then_some(idx))
1066 .collect();
1067 for i in (1..candidates.len()).rev() {
1068 let j = rng.gen_range(i + 1);
1069 candidates.swap(i, j);
1070 }
1071
1072 let mut circs = Vec::new();
1073 let mut switching_candidate_count = 0usize;
1074 for tx_idx in candidates {
1075 if circs.len() >= args.circ_count {
1076 break;
1077 }
1078 let tx = &transcripts[tx_idx];
1079 let max_window = tx.exons.len().min(8);
1080 let min_window = 2;
1081 if max_window < min_window {
1082 continue;
1083 }
1084 let window_len = min_window + rng.gen_range(max_window - min_window + 1);
1085 let window_start = rng.gen_range(tx.exons.len() - window_len + 1);
1086 let mut exon_window = tx.exons[window_start..window_start + window_len].to_vec();
1087 exon_window.sort_by_key(|exon| exon.start);
1088
1089 let seq = isoform_sequence(reference, &tx.chrom, tx.strand, &exon_window)?;
1090 if seq.len() < min_len {
1091 continue;
1092 }
1093 let sampled_circ_coverage = sample_coverage(args.circ_coverage, args.scale, rng);
1094 let read_pair_count = circular_pair_count(seq.len(), sampled_circ_coverage, args.read_len);
1095 let start = exon_window.iter().map(|exon| exon.start).min().unwrap();
1096 let end = exon_window.iter().map(|exon| exon.end).max().unwrap();
1097 let circ_id = format!("{}:{}|{}", tx.chrom, start, end);
1098 let mut isoforms = vec![SimIsoform {
1099 isoform_id: "isoform1".to_string(),
1100 exon_chain: exon_window.clone(),
1101 seq,
1102 read_pair_count: 0,
1103 bsj_read_pair_count: 0,
1104 }];
1105
1106 let force_switching_candidate =
1107 args.out_prefixes.len() > 1 && switching_candidate_count < args.switching_event;
1108 if exon_window.len() >= 3
1109 && read_pair_count >= 2
1110 && (force_switching_candidate || rng.gen_f64() < SECOND_ISOFORM_PROBABILITY)
1111 {
1112 let skip_idx = 1 + rng.gen_range(exon_window.len() - 2);
1113 let skipped: Vec<Exon> = exon_window
1114 .iter()
1115 .enumerate()
1116 .filter_map(|(idx, exon)| (idx != skip_idx).then_some(exon.clone()))
1117 .collect();
1118 let skipped_seq = isoform_sequence(reference, &tx.chrom, tx.strand, &skipped)?;
1119 if skipped_seq.len() >= min_len {
1120 isoforms.push(SimIsoform {
1121 isoform_id: "isoform2".to_string(),
1122 exon_chain: skipped,
1123 seq: skipped_seq,
1124 read_pair_count: 0,
1125 bsj_read_pair_count: 0,
1126 });
1127 switching_candidate_count += 1;
1128 }
1129 }
1130
1131 circs.push(SimCirc {
1132 circ_id,
1133 chrom: tx.chrom.clone(),
1134 start,
1135 end,
1136 strand: tx.strand,
1137 gene_id: tx.gene_id.clone(),
1138 transcript_id: tx.transcript_id.clone(),
1139 isoforms,
1140 sampled_circ_coverage,
1141 read_pair_count,
1142 bsj_read_pair_count: 0,
1143 bsj_read_count: 0,
1144 });
1145 }
1146 Ok(circs)
1147}
1148
1149fn choose_isoform_for_pair(
1156 circ: &SimCirc,
1157 usage: Option<&SampleCircUsage>,
1158 pair_idx: usize,
1159 rng: &mut Lcg64,
1160) -> usize {
1161 let Some(usage) = usage else {
1162 return if pair_idx < circ.isoforms.len() {
1163 pair_idx
1164 } else {
1165 rng.gen_range(circ.isoforms.len())
1166 };
1167 };
1168 let active: Vec<usize> = usage
1169 .weights
1170 .iter()
1171 .enumerate()
1172 .filter_map(|(idx, &weight)| (weight > 0.0).then_some(idx))
1173 .collect();
1174 if let Some(&idx) = active.get(pair_idx) {
1175 return idx;
1176 }
1177 let total: f64 = usage.weights.iter().sum();
1178 if total <= 0.0 {
1179 return 0;
1180 }
1181 let mut draw = rng.gen_f64() * total;
1182 for (idx, &weight) in usage.weights.iter().enumerate() {
1183 if weight <= 0.0 {
1184 continue;
1185 }
1186 if draw < weight {
1187 return idx;
1188 }
1189 draw -= weight;
1190 }
1191 usage.weights.len().saturating_sub(1)
1192}
1193
1194fn write_circular_reads(
1196 circs: &mut [SimCirc],
1197 usage_profile: Option<&SampleUsageProfile>,
1198 args: &SimulateArgs,
1199 rng: &mut Lcg64,
1200 r1_writer: &mut BufWriter<File>,
1201 r2_writer: &mut BufWriter<File>,
1202 read_truth: &mut BufWriter<File>,
1203 next_read_index: &mut usize,
1204) -> Result<()> {
1205 let qual = "I".repeat(args.read_len);
1206 for (circ_idx, circ) in circs.iter_mut().enumerate() {
1207 let circ_usage = usage_profile.and_then(|profile| profile.by_circ.get(circ_idx));
1208 for pair_idx in 0..circ.read_pair_count {
1209 let iso_idx = choose_isoform_for_pair(circ, circ_usage, pair_idx, rng);
1210 let iso = &mut circ.isoforms[iso_idx];
1211 let seq_len = iso.seq.len();
1212 let source_map = source_map_for_exons(&iso.exon_chain, circ.strand);
1213 let insert_len = sample_circular_insert_len(seq_len, args, rng);
1214 let r1_start = rng.gen_range(seq_len);
1215 let r2_start = (r1_start + insert_len.saturating_sub(args.read_len)) % seq_len;
1216 let r1_template = circular_slice(&iso.seq, r1_start, args.read_len);
1217 let r2_template = circular_slice(&iso.seq, r2_start, args.read_len);
1218 let r1_seq = mutate_read(&r1_template, args.error_rate, rng);
1219 let r2_seq = mutate_read(&revcomp(&r2_template), args.error_rate, rng);
1220 let r1_segments =
1221 format_segments(&source_map, circ.strand, r1_start, args.read_len, true);
1222 let r2_segments =
1223 format_segments(&source_map, circ.strand, r2_start, args.read_len, true);
1224 let is_bsj = r1_segments.is_bsj || r2_segments.is_bsj;
1225 let pair_crosses_boundary = crosses_boundary(r1_start, insert_len, seq_len);
1226 let is_outward = !is_bsj
1231 && pair_crosses_boundary
1232 && is_outward_facing_truth(
1233 &source_map,
1234 circ.strand,
1235 r1_start,
1236 r2_start,
1237 args.read_len,
1238 );
1239 let is_circular = is_bsj || is_outward;
1240 let truth_type = if is_bsj {
1241 "bsj"
1242 } else if is_outward {
1243 "outward"
1244 } else {
1245 "forward"
1246 };
1247 let read_id = format!("sim:{}", *next_read_index);
1248 let metadata = format!(
1249 "read_kind=circ circ_id={} isoform_id={} insert_len={}",
1250 circ.circ_id, iso.isoform_id, insert_len
1251 );
1252 write_fastq(
1253 r1_writer,
1254 &format!("{read_id}/1"),
1255 &r1_seq,
1256 &qual,
1257 &metadata,
1258 )?;
1259 write_fastq(
1260 r2_writer,
1261 &format!("{read_id}/2"),
1262 &r2_seq,
1263 &qual,
1264 &metadata,
1265 )?;
1266 writeln!(
1267 read_truth,
1268 "{read_id}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{truth_type}",
1269 circ.circ_id,
1270 circ.chrom,
1271 circ.start,
1272 circ.end,
1273 circ.strand,
1274 iso.isoform_id,
1275 usize::from(is_circular),
1276 usize::from(is_bsj),
1277 r1_segments.text,
1278 usize::from(r1_segments.is_bsj),
1279 r2_segments.text,
1280 usize::from(r2_segments.is_bsj)
1281 )?;
1282 iso.read_pair_count += 1;
1283 if is_bsj {
1284 iso.bsj_read_pair_count += 1;
1285 circ.bsj_read_pair_count += 1;
1286 circ.bsj_read_count +=
1287 usize::from(r1_segments.is_bsj) + usize::from(r2_segments.is_bsj);
1288 }
1289 *next_read_index += 1;
1290 }
1291 }
1292 Ok(())
1293}
1294
1295fn write_linear_reads(
1297 transcripts: &[Transcript],
1298 reference: &HashMap<String, String>,
1299 args: &SimulateArgs,
1300 sampled_linear_coverage: f64,
1301 rng: &mut Lcg64,
1302 r1_writer: &mut BufWriter<File>,
1303 r2_writer: &mut BufWriter<File>,
1304 read_truth: &mut BufWriter<File>,
1305 next_read_index: &mut usize,
1306) -> Result<()> {
1307 let target_pairs = linear_pair_target(transcripts, args, sampled_linear_coverage);
1308 if target_pairs == 0 {
1309 return Ok(());
1310 }
1311 let usable: Vec<&Transcript> = transcripts
1312 .iter()
1313 .filter(|tx| tx.exons.len() >= 2)
1314 .collect();
1315 if usable.is_empty() {
1316 bail!("no usable transcripts for linear read simulation");
1317 }
1318 let qual = "I".repeat(args.read_len);
1319 let mut emitted = 0usize;
1320 let mut attempts = 0usize;
1321 while emitted < target_pairs && attempts < target_pairs * 30 {
1322 attempts += 1;
1323 let tx = usable[rng.gen_range(usable.len())];
1324 let tx_seq = isoform_sequence(reference, &tx.chrom, tx.strand, &tx.exons)?;
1325 let source_map = source_map_for_exons(&tx.exons, tx.strand);
1326 let insert_len = sample_insert_len(args, rng);
1327 if tx_seq.len() <= insert_len || insert_len < args.read_len {
1328 continue;
1329 }
1330 let start = rng.gen_range(tx_seq.len() - insert_len + 1);
1331 let r2_start = start + insert_len - args.read_len;
1332 let Some(r1_template) = linear_slice(&tx_seq, start, args.read_len) else {
1333 continue;
1334 };
1335 let Some(r2_template) = linear_slice(&tx_seq, r2_start, args.read_len) else {
1336 continue;
1337 };
1338 let r1_seq = mutate_read(r1_template, args.error_rate, rng);
1339 let r2_seq = mutate_read(&revcomp(r2_template), args.error_rate, rng);
1340 let r1_segments = format_segments(&source_map, tx.strand, start, args.read_len, false);
1341 let r2_segments = format_segments(&source_map, tx.strand, r2_start, args.read_len, false);
1342 let read_id = format!("sim:{}", *next_read_index);
1343 let metadata = format!(
1344 "read_kind=linear gene_id={} transcript_id={} insert_len={}",
1345 tx.gene_id, tx.transcript_id, insert_len
1346 );
1347 write_fastq(
1348 r1_writer,
1349 &format!("{read_id}/1"),
1350 &r1_seq,
1351 &qual,
1352 &metadata,
1353 )?;
1354 write_fastq(
1355 r2_writer,
1356 &format!("{read_id}/2"),
1357 &r2_seq,
1358 &qual,
1359 &metadata,
1360 )?;
1361 writeln!(
1362 read_truth,
1363 "{read_id}\tNA\t{}\tNA\tNA\t{}\tNA\t0\t0\t{}\t0\t{}\t0\tforward",
1364 tx.chrom, tx.strand, r1_segments.text, r2_segments.text
1365 )?;
1366 *next_read_index += 1;
1367 emitted += 1;
1368 }
1369 if emitted < target_pairs {
1370 bail!(
1371 "only emitted {emitted} linear read pairs after {attempts} attempts; try reducing --insert-len or --linear-coverage"
1372 );
1373 }
1374 Ok(())
1375}
1376
1377fn write_isoform_truth(circs: &[SimCirc], prefix: &str) -> Result<()> {
1379 let mut writer = BufWriter::new(File::create(prefixed_path(prefix, ".isoforms.tsv"))?);
1380 writeln!(
1381 writer,
1382 "circ_id\tchrom\tstart\tend\tstrand\tgene_id\ttranscript_id\tcoverage\tread_cnt\tbsj_read_cnt\tisoform_cnt\tisoform_exons\tisoform_len\tisoform_read_cnt\tisoform_bsj_read_cnt"
1383 )?;
1384 for circ in circs {
1385 let isoform_exons = circ
1386 .isoforms
1387 .iter()
1388 .map(|iso| {
1389 format!(
1390 "{}={}",
1391 iso.isoform_id,
1392 exon_chain_string(&iso.exon_chain, circ.strand)
1393 )
1394 })
1395 .collect::<Vec<_>>()
1396 .join(";");
1397 let isoform_len = circ
1398 .isoforms
1399 .iter()
1400 .map(|iso| format!("{}={}", iso.isoform_id, iso.seq.len()))
1401 .collect::<Vec<_>>()
1402 .join(";");
1403 let isoform_read_cnt = circ
1404 .isoforms
1405 .iter()
1406 .map(|iso| format!("{}={}", iso.isoform_id, iso.read_pair_count))
1407 .collect::<Vec<_>>()
1408 .join(";");
1409 let isoform_bsj_read_cnt = circ
1410 .isoforms
1411 .iter()
1412 .map(|iso| format!("{}={}", iso.isoform_id, iso.bsj_read_pair_count))
1413 .collect::<Vec<_>>()
1414 .join(";");
1415 writeln!(
1416 writer,
1417 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1418 circ.circ_id,
1419 circ.chrom,
1420 circ.start,
1421 circ.end,
1422 circ.strand,
1423 circ.gene_id,
1424 circ.transcript_id,
1425 circ.sampled_circ_coverage,
1426 circ.read_pair_count,
1427 circ.bsj_read_pair_count,
1428 circ.isoforms.len(),
1429 isoform_exons,
1430 isoform_len,
1431 isoform_read_cnt,
1432 isoform_bsj_read_cnt
1433 )?;
1434 }
1435 Ok(())
1436}
1437
1438fn build_sample_usage_profiles(
1444 circs: &[SimCirc],
1445 prefixes: &[String],
1446 switching_event: usize,
1447) -> Vec<SampleUsageProfile> {
1448 let switching_ids: BTreeSet<String> = circs
1449 .iter()
1450 .filter(|circ| circ.isoforms.len() >= 2)
1451 .take(switching_event)
1452 .map(|circ| circ.circ_id.clone())
1453 .collect();
1454
1455 prefixes
1456 .iter()
1457 .enumerate()
1458 .map(|(sample_idx, prefix)| {
1459 let by_circ = circs
1460 .iter()
1461 .map(|circ| SampleCircUsage {
1462 weights: sample_usage_weights(
1463 circ,
1464 switching_ids.contains(&circ.circ_id),
1465 sample_idx,
1466 ),
1467 })
1468 .collect();
1469 SampleUsageProfile {
1470 prefix: prefix.clone(),
1471 by_circ,
1472 }
1473 })
1474 .collect()
1475}
1476
1477fn sample_usage_weights(circ: &SimCirc, switching: bool, sample_idx: usize) -> Vec<f64> {
1479 if circ.isoforms.len() <= 1 {
1480 return vec![1.0];
1481 }
1482 let mut weights = vec![0.0; circ.isoforms.len()];
1483 if switching {
1484 let (first, second) = if sample_idx == 0 {
1485 (SWITCHING_HIGH_USAGE, SWITCHING_LOW_USAGE)
1486 } else {
1487 (SWITCHING_LOW_USAGE, SWITCHING_HIGH_USAGE)
1488 };
1489 weights[0] = first;
1490 weights[1] = second;
1491 return weights;
1492 }
1493 weights[0] = NON_SWITCHING_MAJOR_USAGE;
1494 let minor = (1.0 - NON_SWITCHING_MAJOR_USAGE) / (circ.isoforms.len() - 1) as f64;
1495 for weight in weights.iter_mut().skip(1) {
1496 *weight = minor;
1497 }
1498 weights
1499}
1500
1501fn write_sample_usage_truths(
1507 sample_circs: &[Vec<SimCirc>],
1508 profiles: &[SampleUsageProfile],
1509) -> Result<()> {
1510 for (sample, profile) in sample_circs.iter().zip(profiles) {
1511 let mut writer =
1512 BufWriter::new(File::create(prefixed_path(&profile.prefix, ".usage.tsv"))?);
1513 writeln!(
1514 writer,
1515 "circ_id\tchrom\tstart\tend\tstrand\tisoform_id\tusage\tread_cnt\tbsj_read_cnt"
1516 )?;
1517 for circ in sample {
1518 let total = circ.read_pair_count.max(1) as f64;
1519 for iso in &circ.isoforms {
1520 let usage = iso.read_pair_count as f64 / total;
1521 writeln!(
1522 writer,
1523 "{}\t{}\t{}\t{}\t{}\t{}\t{:.6}\t{}\t{}",
1524 circ.circ_id,
1525 circ.chrom,
1526 circ.start,
1527 circ.end,
1528 circ.strand,
1529 iso.isoform_id,
1530 usage,
1531 iso.read_pair_count,
1532 iso.bsj_read_pair_count
1533 )?;
1534 }
1535 }
1536 writer.flush()?;
1537 }
1538 Ok(())
1539}
1540
1541fn validate_args(args: &SimulateArgs) -> Result<()> {
1543 if args.out_prefixes.is_empty() {
1544 bail!("at least one -o/--out prefix is required");
1545 }
1546 if args.out_prefixes.len() > 2 {
1547 bail!("multi-sample simulator currently supports one or two -o/--out prefixes");
1548 }
1549 if args.switching_event > 0 && args.out_prefixes.len() < 2 {
1550 bail!("--switching-event requires two -o/--out prefixes");
1551 }
1552 let mut seen_prefixes = HashSet::new();
1553 for prefix in &args.out_prefixes {
1554 if prefix.is_empty() {
1555 bail!("output prefixes must not be empty");
1556 }
1557 if !seen_prefixes.insert(prefix) {
1558 bail!("duplicate output prefix: {prefix}");
1559 }
1560 }
1561 if args.circ_count == 0 {
1562 bail!("--circ-count must be > 0");
1563 }
1564 if args.read_len == 0 {
1565 bail!("--read-len must be > 0");
1566 }
1567 if args.insert_len < args.read_len {
1568 bail!("--insert-len must be >= --read-len");
1569 }
1570 if args.insert_len_minor < args.read_len {
1571 bail!("--insert-len-minor must be >= --read-len");
1572 }
1573 if args.insert_sd < 0.0 || args.insert_sd_minor < 0.0 {
1574 bail!("insert standard deviations must be >= 0");
1575 }
1576 if !(0.0..=1.0).contains(&args.minor_insert_fraction) {
1577 bail!("--minor-insert-fraction must be in [0, 1]");
1578 }
1579 if args.circ_coverage < 0.0 || args.linear_coverage < 0.0 {
1580 bail!("coverage values must be >= 0");
1581 }
1582 if args.scale < 0.0 {
1583 bail!("--scale must be >= 0");
1584 }
1585 if !(0.0..=1.0).contains(&args.error_rate) {
1586 bail!("--error-rate must be in [0, 1]");
1587 }
1588 if !(0.0..=1.0).contains(&args.exon_exclusive_rate) {
1589 bail!("--exon-exclusive-rate must be in [0, 1]");
1590 }
1591 Ok(())
1592}
1593
1594pub fn run(args: SimulateArgs) -> Result<SimulationSummary> {
1600 validate_args(&args)?;
1601 let fastq_compressor = select_fastq_compressor()?;
1602 for prefix in &args.out_prefixes {
1603 if let Some(parent) = Path::new(prefix).parent() {
1604 if !parent.as_os_str().is_empty() {
1605 create_dir_all(parent)?;
1606 }
1607 }
1608 }
1609
1610 let mut rng = Lcg64::new(args.seed);
1611 let mut fasta = FastaReader::new();
1612 fasta.read_fasta(&args.ref_fasta)?;
1613 let annotation = read_annotation_model(
1614 &args.gtf,
1615 args.chrom.as_deref(),
1616 args.exon_exclusive_rate,
1617 &mut rng,
1618 )?;
1619 if annotation.transcripts.is_empty() {
1620 bail!("no exon-bearing transcripts found for the requested filter");
1621 }
1622 if annotation.linear_transcripts.is_empty() {
1623 bail!("no usable linear transcripts remain after circRNA-exclusive exon filtering");
1624 }
1625
1626 let circs = build_circs(
1627 &annotation.transcripts,
1628 &fasta.chr_tcga_map,
1629 &args,
1630 &mut rng,
1631 )?;
1632 if circs.is_empty() {
1633 bail!("no circRNAs could be built; try lowering --read-len or --circ-count");
1634 }
1635 let usage_profiles = if args.out_prefixes.len() > 1 {
1636 build_sample_usage_profiles(&circs, &args.out_prefixes, args.switching_event)
1637 } else {
1638 Vec::new()
1639 };
1640
1641 let mut sample_circs = Vec::with_capacity(args.out_prefixes.len());
1642 let mut total_read_pairs = 0usize;
1643 let mut circ_read_pairs = 0usize;
1644 let mut linear_read_pairs = 0usize;
1645 let mut bsj_read_pairs = 0usize;
1646 let mut bsj_reads = 0usize;
1647 for (sample_idx, prefix) in args.out_prefixes.iter().enumerate() {
1648 write_linear_annotation(&annotation, prefix)?;
1649 let mut circs_for_sample = circs.clone();
1650 let profile = usage_profiles.get(sample_idx);
1651 let r1_tmp_path = prefixed_path(prefix, "_1.fq.tmp");
1652 let r2_tmp_path = prefixed_path(prefix, "_2.fq.tmp");
1653 let r1_gz_path = prefixed_path(prefix, "_1.fq.gz");
1654 let r2_gz_path = prefixed_path(prefix, "_2.fq.gz");
1655 let mut r1_writer = BufWriter::new(File::create(&r1_tmp_path)?);
1656 let mut r2_writer = BufWriter::new(File::create(&r2_tmp_path)?);
1657 let mut read_truth = BufWriter::new(File::create(prefixed_path(prefix, ".reads.tsv"))?);
1658 writeln!(
1659 read_truth,
1660 "read_id\tcirc_id\tchrom\tstart\tend\tstrand\tisoform_id\tis_circular\tis_bsj\tr1_segments\tr1_is_bsj\tr2_segments\tr2_is_bsj\ttype"
1661 )?;
1662
1663 let mut next_read_index = 1usize;
1664 write_circular_reads(
1665 &mut circs_for_sample,
1666 profile,
1667 &args,
1668 &mut rng,
1669 &mut r1_writer,
1670 &mut r2_writer,
1671 &mut read_truth,
1672 &mut next_read_index,
1673 )?;
1674 let sampled_linear_coverage = sample_coverage(args.linear_coverage, args.scale, &mut rng);
1675 write_linear_reads(
1676 &annotation.linear_transcripts,
1677 &fasta.chr_tcga_map,
1678 &args,
1679 sampled_linear_coverage,
1680 &mut rng,
1681 &mut r1_writer,
1682 &mut r2_writer,
1683 &mut read_truth,
1684 &mut next_read_index,
1685 )?;
1686 r1_writer.flush()?;
1687 r2_writer.flush()?;
1688 read_truth.flush()?;
1689 drop(r1_writer);
1690 drop(r2_writer);
1691 drop(read_truth);
1692 compress_fastq_to_gz(&r1_tmp_path, &r1_gz_path, fastq_compressor)?;
1693 compress_fastq_to_gz(&r2_tmp_path, &r2_gz_path, fastq_compressor)?;
1694 write_isoform_truth(&circs_for_sample, prefix)?;
1695
1696 let sample_total_read_pairs = next_read_index - 1;
1697 let sample_circ_read_pairs: usize = circs_for_sample
1698 .iter()
1699 .map(|circ| circ.read_pair_count)
1700 .sum();
1701 total_read_pairs += sample_total_read_pairs;
1702 circ_read_pairs += sample_circ_read_pairs;
1703 linear_read_pairs += sample_total_read_pairs.saturating_sub(sample_circ_read_pairs);
1704 bsj_read_pairs += circs_for_sample
1705 .iter()
1706 .map(|circ| circ.bsj_read_pair_count)
1707 .sum::<usize>();
1708 bsj_reads += circs_for_sample
1709 .iter()
1710 .map(|circ| circ.bsj_read_count)
1711 .sum::<usize>();
1712 sample_circs.push(circs_for_sample);
1713 }
1714 if args.out_prefixes.len() > 1 {
1715 write_sample_usage_truths(&sample_circs, &usage_profiles)?;
1716 }
1717
1718 let isoform_count: usize = circs.iter().map(|circ| circ.isoforms.len()).sum();
1719
1720 Ok(SimulationSummary {
1721 sample_count: args.out_prefixes.len(),
1722 circ_count: circs.len(),
1723 isoform_count,
1724 total_read_pairs,
1725 circ_read_pairs,
1726 linear_read_pairs,
1727 bsj_reads,
1728 bsj_read_pairs,
1729 })
1730}
1731
1732#[cfg(test)]
1733mod tests;