1use std::fs::File;
2use std::io::BufWriter;
3
4use anyhow::{Result, bail};
5use clap::Parser;
6use pooled_writer::PoolBuilder;
7use pooled_writer::bgzf::BgzfCompressor;
8use rand::rngs::SmallRng;
9use rand::{Rng, SeedableRng};
10use rand_distr::{Distribution, Normal};
11
12use super::command::{Command, output_path};
13use super::common::{BedOptions, OutputPrefixOptions, ReferenceOptions, SeedOptions, VcfOptions};
14use crate::bed::{PaddedIntervalSampler, TargetRegions};
15use crate::error_model::illumina::IlluminaErrorModel;
16use crate::fasta::Fasta;
17use crate::fragment::extract_fragment;
18use crate::haplotype::build_haplotypes;
19use crate::output::fastq::FastqWriter;
20use crate::output::golden_bam::{GoldenBamMetadata, GoldenBamWriter};
21use crate::read::generate_read_pair;
22use crate::seed::{derive_seed, resolve_seed};
23use crate::sequence_dict::SequenceDictionary;
24use crate::version::VERSION;
25
26const DEFAULT_SAMPLE_NAME: &str = "holodeck-simulation";
29
30const DEFAULT_ADAPTER_R1: &str = "AGATCGGAAGAGCACACGTCTGAACTCCAGTCA";
32
33const DEFAULT_ADAPTER_R2: &str = "AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT";
35
36const DEFAULT_CONVERSION_RATE: f64 = 0.999;
39
40const DEFAULT_FAILURE_RATE: f64 = 0.01;
43
44#[derive(Parser, Debug)]
51#[command(after_long_help = "EXAMPLES:\n \
52 holodeck simulate -r ref.fa -o out --coverage 30\n \
53 holodeck simulate -r ref.fa -v vars.vcf -o out --coverage 30 --golden-bam\n \
54 holodeck simulate -r ref.fa -v vars.vcf -b targets.bed -o out --coverage 100")]
55#[allow(clippy::struct_excessive_bools)] pub struct Simulate {
57 #[command(flatten)]
58 pub reference: ReferenceOptions,
59
60 #[command(flatten)]
61 pub vcf: VcfOptions,
62
63 #[command(flatten)]
64 pub bed: BedOptions,
65
66 #[command(flatten)]
67 pub output: OutputPrefixOptions,
68
69 #[command(flatten)]
70 pub seed: SeedOptions,
71
72 #[arg(short = 'c', long, default_value_t = 30.0, value_name = "FLOAT")]
74 pub coverage: f64,
75
76 #[arg(short = 'l', long, default_value_t = 150, value_name = "INT")]
78 pub read_length: usize,
79
80 #[arg(short = 'd', long, default_value_t = 300, value_name = "INT")]
82 pub fragment_mean: usize,
83
84 #[arg(short = 's', long, default_value_t = 50, value_name = "INT")]
86 pub fragment_stddev: usize,
87
88 #[arg(long, default_value_t = 20, value_name = "INT")]
92 pub min_fragment_length: usize,
93
94 #[arg(long)]
96 pub single_end: bool,
97
98 #[arg(long, default_value = DEFAULT_ADAPTER_R1, value_name = "SEQ")]
101 pub adapter_r1: String,
102
103 #[arg(long, default_value = DEFAULT_ADAPTER_R2, value_name = "SEQ")]
106 pub adapter_r2: String,
107
108 #[arg(long, default_value_t = 0.001, value_name = "FLOAT")]
110 pub min_error_rate: f64,
111
112 #[arg(long, default_value_t = 0.01, value_name = "FLOAT")]
114 pub max_error_rate: f64,
115
116 #[arg(long, default_value_t = 0.02, value_name = "FLOAT")]
124 pub max_n_frac: f64,
125
126 #[arg(long, value_enum, value_name = "MODE")]
134 pub methylation_mode: Option<crate::meth::MethylationMode>,
135
136 #[arg(long, value_name = "FLOAT")]
144 pub methylation_conversion_rate: Option<f64>,
145
146 #[arg(long, value_name = "FLOAT")]
158 pub methylation_failure_rate: Option<f64>,
159
160 #[arg(long, value_name = "PATH")]
170 pub cpg_truth_bedgraph: Option<std::path::PathBuf>,
171
172 #[arg(long)]
174 pub golden_bam: bool,
175
176 #[arg(long)]
178 pub golden_vcf: bool,
179
180 #[arg(long)]
183 pub simple_names: bool,
184
185 #[arg(long, default_value_t = 1, value_name = "INT")]
189 pub compression: u8,
190
191 #[arg(short = 't', long, default_value_t = 4, value_name = "INT")]
193 pub threads: usize,
194}
195
196impl Command for Simulate {
197 fn execute(&self) -> Result<()> {
198 let resolved_sample = self.validate()?;
199 self.run_simulation(resolved_sample.as_deref())
200 }
201}
202
203impl Simulate {
204 fn validate(&self) -> Result<Option<String>> {
208 if !self.coverage.is_finite() || self.coverage <= 0.0 {
209 bail!("--coverage must be a finite positive number");
210 }
211 if self.read_length == 0 {
212 bail!("--read-length must be > 0");
213 }
214 if self.min_fragment_length == 0 {
215 bail!("--min-fragment-length must be at least 1");
216 }
217 if self.min_error_rate < 0.0 || self.max_error_rate < 0.0 {
218 bail!("Error rates must be >= 0");
219 }
220 if self.min_error_rate > self.max_error_rate {
221 bail!("--min-error-rate must be <= --max-error-rate");
222 }
223 if !self.max_n_frac.is_finite() || !(0.0..=1.0).contains(&self.max_n_frac) {
224 bail!("--max-n-frac must be in [0.0, 1.0]");
225 }
226 if let Some(rate) = self.methylation_conversion_rate
227 && (!rate.is_finite() || !(0.0..=1.0).contains(&rate))
228 {
229 bail!("--methylation-conversion-rate must be in [0.0, 1.0]");
230 }
231 if self.methylation_mode.is_none() && self.methylation_conversion_rate.is_some() {
235 bail!("--methylation-conversion-rate requires --methylation-mode");
236 }
237 if let Some(rate) = self.methylation_failure_rate
238 && (!rate.is_finite() || !(0.0..=1.0).contains(&rate))
239 {
240 bail!("--methylation-failure-rate must be in [0.0, 1.0]");
241 }
242 if self.methylation_mode.is_none() && self.methylation_failure_rate.is_some() {
243 bail!("--methylation-failure-rate requires --methylation-mode");
244 }
245 if self.cpg_truth_bedgraph.is_some() && self.methylation_mode.is_none() {
246 bail!("--cpg-truth-bedgraph requires --methylation-mode");
247 }
248
249 if let Some(path) = &self.cpg_truth_bedgraph
253 && let Some(parent) = path.parent()
254 && !parent.as_os_str().is_empty()
255 && !parent.exists()
256 {
257 bail!("CpG truth bedGraph directory does not exist: {}", parent.display());
258 }
259
260 let vcf_has_methylation = if let Some(vcf_path) = &self.vcf.vcf {
271 crate::vcf::methylation::vcf_has_mt_mb_records(vcf_path)
272 .map_err(|e| anyhow::anyhow!("failed to inspect VCF for MT/MB records: {e}"))?
273 } else {
274 false
275 };
276 match (vcf_has_methylation, self.methylation_mode.is_some()) {
277 (true, true) | (false, false) => {} (true, false) => {
279 log::warn!(
280 "VCF contains methylation truth (MT/MB) but --methylation-mode is not set; \
281 methylation chemistry will not be applied"
282 );
283 }
284 (false, true) => {
285 bail!(
286 "--methylation-mode requires a methylation-annotated VCF (MT/MB FORMAT \
287 fields); run `holodeck methylate` first"
288 );
289 }
290 }
291 if self.compression > 12 {
292 bail!("--compression must be between 0 and 12");
293 }
294
295 if self.vcf.sample.is_some() && self.vcf.vcf.is_none() {
297 bail!("--sample requires --vcf");
298 }
299
300 let resolved_sample = if let Some(vcf_path) = &self.vcf.vcf {
305 Some(crate::vcf::validate_vcf_sample(vcf_path, self.vcf.sample.as_deref())?)
306 } else {
307 None
308 };
309
310 if let Some(parent) = self.output.output.parent()
312 && !parent.as_os_str().is_empty()
313 && !parent.exists()
314 {
315 bail!("Output directory does not exist: {}", parent.display());
316 }
317
318 Ok(resolved_sample)
319 }
320
321 #[allow(clippy::too_many_lines)] fn run_simulation(&self, resolved_vcf_sample: Option<&str>) -> Result<()> {
327 let seed = self.compute_seed();
328 let mut rng = SmallRng::seed_from_u64(seed);
329 log::info!("Using random seed: {seed}");
330
331 let mut fasta = Fasta::from_path(&self.reference.reference)?;
332 let dict = fasta.dict().clone();
333 log::info!(
334 "Loaded reference with {} contigs, total {} bp",
335 dict.len(),
336 dict.total_length()
337 );
338
339 let targets = self.load_targets(&dict)?;
340 let effective_size = targets
341 .as_ref()
342 .map_or(dict.total_length(), |t| t.effective_territory(self.fragment_mean));
343 if effective_size == 0 {
344 bail!("Effective genome size is 0; nothing to simulate");
345 }
346
347 let total_reads = self.compute_total_reads(effective_size);
348 log::info!("Will generate {total_reads} read pairs for {:.1}x coverage", self.coverage);
349
350 let error_model =
351 IlluminaErrorModel::new(self.read_length, self.min_error_rate, self.max_error_rate);
352 let frag_dist = Normal::new(self.fragment_mean as f64, self.fragment_stddev as f64)
353 .map_err(|e| anyhow::anyhow!("Invalid fragment distribution parameters: {e}"))?;
354
355 let adapter_r1 = self.adapter_r1.to_ascii_uppercase();
359 let adapter_r2 = self.adapter_r2.to_ascii_uppercase();
360
361 let compression = self.compression;
362 let use_pool = self.threads > 1;
363
364 let mut pool_builder: Option<PoolBuilder<BufWriter<File>, BgzfCompressor>> = if use_pool {
367 let pb = PoolBuilder::new()
368 .threads(self.threads)
369 .compression_level(compression)
370 .map_err(|e| anyhow::anyhow!("failed to set compression level: {e}"))?;
371 log::info!("Using {} threads for BGZF compression", self.threads);
372 Some(pb)
373 } else {
374 None
375 };
376
377 let mut r1_writer = self.create_fastq_writer(".r1.fastq.gz", &mut pool_builder)?;
378 let mut r2_writer = if self.single_end {
379 None
380 } else {
381 Some(self.create_fastq_writer(".r2.fastq.gz", &mut pool_builder)?)
382 };
383
384 let mut golden_bam_writer = if self.golden_bam {
385 let bam_path = output_path(&self.output.output, ".golden.bam");
386 log::info!("Writing golden BAM to: {}", bam_path.display());
387 let meta = Self::golden_bam_metadata(resolved_vcf_sample);
388 if let Some(pb) = &mut pool_builder {
389 let file = File::create(&bam_path)?;
390 let pooled = pb.exchange(BufWriter::new(file));
391 Some(GoldenBamWriter::from_writer(Box::new(pooled), &dict, &meta)?)
392 } else {
393 Some(GoldenBamWriter::new(&bam_path, &dict, compression, &meta)?)
394 }
395 } else {
396 None
397 };
398
399 let mut pool = pool_builder
401 .map(PoolBuilder::build)
402 .transpose()
403 .map_err(|e| anyhow::anyhow!("failed to build compression pool: {e}"))?;
404
405 if self.golden_vcf {
406 log::warn!("--golden-vcf is not yet implemented; skipping");
407 }
408
409 let mut cpg_truth = self
413 .cpg_truth_bedgraph
414 .as_ref()
415 .map(|_| crate::output::cpg_truth::CpgTruthTally::new());
416
417 let mut read_num: u64 = 0;
418 let contig_names: Vec<String> = dict.names().into_iter().map(String::from).collect();
419
420 let methylation_records = if self.methylation_mode.is_some()
426 && let Some(vcf_path) = &self.vcf.vcf
427 {
428 Some(crate::vcf::methylation::parse_methylation_vcf(vcf_path)?)
429 } else {
430 None
431 };
432
433 let parsed_variants = if let Some(vcf_path) = &self.vcf.vcf {
442 Some(crate::vcf::parse_variants_by_contig(vcf_path, resolved_vcf_sample, &dict)?)
443 } else {
444 None
445 };
446 let sample_ploidy = parsed_variants.as_ref().map_or(2, |p| p.sample_ploidy);
447
448 for contig_name in &contig_names {
449 let variants_for_contig: &[crate::vcf::genotype::VariantRecord] = parsed_variants
450 .as_ref()
451 .and_then(|p| p.by_contig.get(contig_name))
452 .map_or(&[], Vec::as_slice);
453 read_num += self.simulate_contig(
454 contig_name,
455 &dict,
456 &mut fasta,
457 targets.as_ref(),
458 total_reads,
459 &error_model,
460 &frag_dist,
461 adapter_r1.as_bytes(),
462 adapter_r2.as_bytes(),
463 &mut r1_writer,
464 &mut r2_writer,
465 &mut golden_bam_writer,
466 variants_for_contig,
467 sample_ploidy,
468 cpg_truth.as_mut(),
469 methylation_records.as_ref(),
470 read_num,
471 seed,
472 &mut rng,
473 )?;
474 }
475
476 r1_writer.close();
480 if let Some(w) = r2_writer {
481 w.close();
482 }
483 if let Some(w) = golden_bam_writer {
484 w.close();
485 }
486 if let Some(ref mut p) = pool {
487 p.stop_pool().map_err(|e| anyhow::anyhow!("failed to stop compression pool: {e}"))?;
488 }
489
490 if let (Some(path), Some(tally)) = (&self.cpg_truth_bedgraph, &cpg_truth) {
491 log::info!("Writing CpG truth bedGraph to: {}", path.display());
492 tally.write_bedgraph(&dict, path)?;
493 }
494
495 log::info!("Generated {read_num} total read pairs");
496 Ok(())
497 }
498
499 fn create_fastq_writer(
502 &self,
503 suffix: &str,
504 pool_builder: &mut Option<PoolBuilder<BufWriter<File>, BgzfCompressor>>,
505 ) -> Result<FastqWriter> {
506 let path = output_path(&self.output.output, suffix);
507 if let Some(pb) = pool_builder {
508 let file = File::create(&path)?;
509 let pooled = pb.exchange(BufWriter::new(file));
510 Ok(FastqWriter::from_writer(pooled))
511 } else {
512 FastqWriter::new(&path, self.compression)
513 }
514 }
515
516 fn golden_bam_metadata(resolved_vcf_sample: Option<&str>) -> GoldenBamMetadata {
523 let command_line = std::env::args_os()
524 .map(|arg| arg.to_string_lossy().into_owned())
525 .collect::<Vec<_>>()
526 .join(" ");
527 let sample =
528 resolved_vcf_sample.map_or_else(|| DEFAULT_SAMPLE_NAME.to_string(), str::to_string);
529 GoldenBamMetadata { command_line, version: VERSION.clone(), sample }
530 }
531
532 fn compute_seed(&self) -> u64 {
541 let mut seed_desc = format!(
542 "{}:{}:{}:{}:{}:{}:{}",
543 self.reference.reference.display(),
544 self.coverage,
545 self.read_length,
546 self.fragment_mean,
547 self.fragment_stddev,
548 self.min_error_rate,
549 self.max_error_rate,
550 );
551 if let Some(mode) = self.methylation_mode {
552 seed_desc.push(':');
553 seed_desc.push_str(mode.as_seed_str());
554 seed_desc.push(':');
555 seed_desc.push_str(
556 &self.methylation_conversion_rate.unwrap_or(DEFAULT_CONVERSION_RATE).to_string(),
557 );
558 seed_desc.push(':');
559 seed_desc.push_str(
560 &self.methylation_failure_rate.unwrap_or(DEFAULT_FAILURE_RATE).to_string(),
561 );
562 }
563 resolve_seed(self.seed.seed, &seed_desc)
564 }
565
566 fn load_targets(&self, dict: &SequenceDictionary) -> Result<Option<TargetRegions>> {
568 match &self.bed.targets {
569 Some(bed_path) => {
570 let t = TargetRegions::from_path(bed_path, dict)?;
571 log::info!("Loaded {} bp of target territory", t.total_territory());
572 Ok(Some(t))
573 }
574 None => Ok(None),
575 }
576 }
577
578 fn compute_total_reads(&self, effective_size: u64) -> u64 {
580 let bases_per_read =
581 if self.single_end { self.read_length as u64 } else { self.read_length as u64 * 2 };
582 #[expect(clippy::cast_possible_truncation, reason = "read count fits u64")]
583 #[expect(clippy::cast_sign_loss, reason = "coverage is positive")]
584 let n = ((self.coverage * effective_size as f64) / bases_per_read as f64).round() as u64;
585 n
586 }
587
588 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
594 fn simulate_contig(
595 &self,
596 contig_name: &str,
597 dict: &SequenceDictionary,
598 fasta: &mut Fasta,
599 targets: Option<&TargetRegions>,
600 total_reads: u64,
601 error_model: &IlluminaErrorModel,
602 frag_dist: &Normal<f64>,
603 adapter_r1: &[u8],
604 adapter_r2: &[u8],
605 r1_writer: &mut FastqWriter,
606 r2_writer: &mut Option<FastqWriter>,
607 golden_bam: &mut Option<GoldenBamWriter>,
608 variants: &[crate::vcf::genotype::VariantRecord],
609 sample_ploidy: usize,
610 cpg_truth: Option<&mut crate::output::cpg_truth::CpgTruthTally>,
611 methylation_records: Option<&crate::vcf::methylation::MethylationVcfRecords>,
612 start_read_num: u64,
613 main_seed: u64,
614 rng: &mut SmallRng,
615 ) -> Result<u64> {
616 let contig_meta = dict.get_by_name(contig_name).unwrap();
617 let contig_len = contig_meta.length() as u64;
618 let contig_idx = contig_meta.index();
619
620 let contig_effective_size = targets
625 .map_or(contig_len, |t| t.contig_effective_territory(contig_idx, self.fragment_mean));
626 let effective_total =
627 targets.map_or(dict.total_length(), |t| t.effective_territory(self.fragment_mean));
628
629 if contig_effective_size == 0 || effective_total == 0 {
630 return Ok(0);
631 }
632
633 #[expect(clippy::cast_possible_truncation, reason = "read count fits u64")]
634 #[expect(clippy::cast_sign_loss, reason = "fraction is positive")]
635 let contig_reads = (total_reads as f64 * contig_effective_size as f64
636 / effective_total as f64)
637 .round() as u64;
638
639 if contig_reads == 0 {
640 return Ok(0);
641 }
642
643 log::info!("Simulating {contig_reads} reads for contig {contig_name} ({contig_len} bp)");
644
645 #[expect(clippy::cast_possible_truncation, reason = "pad fits u32")]
651 let sampler = targets.map(|tgt| {
652 let pad = (self.fragment_mean + 4 * self.fragment_stddev) as u32;
653 PaddedIntervalSampler::new(tgt.contig_intervals(contig_idx), pad, contig_len as u32)
654 });
655
656 let contig_seed = derive_seed(main_seed, contig_name);
659 let mut ref_rng = SmallRng::seed_from_u64(contig_seed);
660 let reference = fasta.load_contig(contig_name, &mut ref_rng)?;
661
662 if !variants.is_empty() {
665 log::info!(" Loaded {} variants for {contig_name}", variants.len());
666 }
667
668 let haplotypes = build_haplotypes(variants, sample_ploidy, rng);
673
674 let methylation = if let Some(mode) = self.methylation_mode {
683 let records = methylation_records.ok_or_else(|| {
684 anyhow::anyhow!(
685 "internal invariant: --methylation-mode set but methylation \
686 records were not loaded (should be caught by validate())"
687 )
688 })?;
689 let cm = crate::vcf::methylation::load_contig_methylation_from_records(
690 records,
691 contig_name,
692 &reference,
693 variants,
694 sample_ploidy,
695 )?
696 .ok_or_else(|| {
697 anyhow::anyhow!(
698 "internal invariant: --methylation-mode set but VCF lacks MT/MB \
699 (should be caught by validate())"
700 )
701 })?;
702 anyhow::ensure!(
707 cm.len() == haplotypes.len(),
708 "internal invariant: methylation table count ({}) disagrees with \
709 haplotype count ({}) on contig {contig_name}",
710 cm.len(),
711 haplotypes.len(),
712 );
713 Some((cm, mode))
714 } else {
715 None
716 };
717 let methylation_config =
718 methylation.as_ref().map(|(cm, mode)| crate::meth::MethylationConfig {
719 contig_methylation: cm,
720 mode: *mode,
721 conversion_rate: self
722 .methylation_conversion_rate
723 .unwrap_or(DEFAULT_CONVERSION_RATE),
724 failure_rate: self.methylation_failure_rate.unwrap_or(DEFAULT_FAILURE_RATE),
725 });
726
727 let ref_cpgs: Vec<u32> = if cpg_truth.is_some() {
731 crate::meth::find_reference_cpgs(&reference)
732 } else {
733 Vec::new()
734 };
735 let mut cpg_truth = cpg_truth;
736
737 let mut generated: u64 = 0;
738 let mut attempts: u64 = 0;
739 let max_attempts = contig_reads * 100;
740
741 while generated < contig_reads && attempts < max_attempts {
742 attempts += 1;
743
744 #[expect(clippy::cast_possible_truncation, reason = "fragment length fits usize")]
748 #[expect(clippy::cast_sign_loss, reason = "clamped to positive")]
749 let frag_len = frag_dist
750 .sample(rng)
751 .round()
752 .clamp(self.min_fragment_length as f64, contig_len as f64)
753 as usize;
754
755 if frag_len == 0 {
756 continue;
757 }
758
759 #[expect(clippy::cast_possible_truncation, reason = "position fits u32")]
764 let ref_start = if let Some(samp) = &sampler {
765 let s = samp.sample_start(rng).unwrap();
766 s.min((contig_len - frag_len as u64) as u32)
768 } else {
769 let max_start = contig_len - frag_len as u64;
770 if max_start > 0 { rng.random_range(0..=max_start) as u32 } else { 0 }
771 };
772
773 #[expect(clippy::cast_possible_truncation, reason = "frag end fits u32")]
777 let frag_end = ref_start + frag_len as u32;
778 if let Some(tgt) = targets
779 && !tgt.overlaps(contig_idx, ref_start, frag_end)
780 {
781 continue;
782 }
783
784 let hap_idx = rng.random_range(0..haplotypes.len());
785 let is_forward: bool = rng.random();
786 let fragment =
787 extract_fragment(&haplotypes[hap_idx], &reference, ref_start, frag_len, is_forward);
788
789 let read_num = start_read_num + generated + 1;
790 let Some(mut pair) = generate_read_pair(
791 &fragment,
792 contig_name,
793 read_num,
794 self.read_length,
795 !self.single_end,
796 adapter_r1,
797 adapter_r2,
798 self.max_n_frac,
799 error_model,
800 self.simple_names,
801 methylation_config.as_ref(),
802 self.golden_bam,
806 rng,
807 ) else {
808 continue;
810 };
811
812 if self.golden_bam
817 && let (Some(annotation), Some((cm, mode))) =
818 (pair.methylation.as_mut(), methylation.as_ref())
819 {
820 crate::methylation_tags::populate_pair_call_tags(
821 &pair.read1,
822 pair.read2.as_ref(),
823 &pair.r1_truth,
824 pair.r2_truth.as_ref(),
825 &pair.r1_cigar,
826 pair.r2_cigar.as_ref(),
827 &reference,
828 cm.table_for(hap_idx),
829 &haplotypes[hap_idx],
830 *mode,
831 annotation,
832 );
833 }
834
835 r1_writer.write_read(&pair.read1)?;
836 if let Some(w) = r2_writer
837 && let Some(r2) = &pair.read2
838 {
839 w.write_read(r2)?;
840 }
841 if let Some(bam_w) = golden_bam {
842 bam_w.write_pair(&pair)?;
843 }
844
845 if let (Some(tally), Some((cm, _))) = (cpg_truth.as_deref_mut(), methylation.as_ref()) {
850 let frag_len = fragment.bases.len();
851 let genomic = frag_len.min(self.read_length);
852 let right_start = frag_len.saturating_sub(genomic);
853 let r1_neg = !fragment.is_forward;
854 let r1_positions: &[u32] = if r1_neg {
855 &fragment.ref_positions[right_start..frag_len]
856 } else {
857 &fragment.ref_positions[..genomic]
858 };
859 tally.record_mate(
860 contig_idx,
861 r1_positions,
862 &ref_cpgs,
863 cm,
864 &haplotypes[hap_idx],
865 hap_idx,
866 fragment.is_forward,
867 );
868 if pair.read2.is_some() {
869 let r2_neg = fragment.is_forward;
870 let r2_positions: &[u32] = if r2_neg {
871 &fragment.ref_positions[right_start..frag_len]
872 } else {
873 &fragment.ref_positions[..genomic]
874 };
875 tally.record_mate(
876 contig_idx,
877 r2_positions,
878 &ref_cpgs,
879 cm,
880 &haplotypes[hap_idx],
881 hap_idx,
882 fragment.is_forward,
883 );
884 }
885 }
886
887 generated += 1;
888 }
889
890 if generated < contig_reads {
891 log::warn!(
892 "Only generated {generated}/{contig_reads} reads for {contig_name} \
893 after {max_attempts} attempts"
894 );
895 }
896
897 Ok(generated)
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::commands::common::{
905 BedOptions, OutputPrefixOptions, ReferenceOptions, SeedOptions, VcfOptions,
906 };
907 use std::path::PathBuf;
908
909 fn make_default_simulate() -> Simulate {
912 Simulate {
913 reference: ReferenceOptions { reference: PathBuf::from("/tmp/ref.fa") },
914 vcf: VcfOptions { vcf: None, sample: None },
915 bed: BedOptions { targets: None },
916 output: OutputPrefixOptions { output: PathBuf::from("out") },
917 seed: SeedOptions { seed: None },
918 coverage: 30.0,
919 read_length: 150,
920 fragment_mean: 300,
921 fragment_stddev: 50,
922 min_fragment_length: 20,
923 single_end: false,
924 adapter_r1: DEFAULT_ADAPTER_R1.to_string(),
925 adapter_r2: DEFAULT_ADAPTER_R2.to_string(),
926 min_error_rate: 0.001,
927 max_error_rate: 0.01,
928 max_n_frac: 0.02,
929 methylation_mode: None,
930 methylation_conversion_rate: None,
931 methylation_failure_rate: None,
932 cpg_truth_bedgraph: None,
933 golden_bam: false,
934 golden_vcf: false,
935 simple_names: false,
936 compression: 1,
937 threads: 4,
938 }
939 }
940
941 #[test]
947 fn test_compute_seed_no_methylation_matches_legacy() {
948 let sim = make_default_simulate();
949 let expected = resolve_seed(None, "/tmp/ref.fa:30:150:300:50:0.001:0.01");
950 assert_eq!(sim.compute_seed(), expected);
951 }
952
953 #[test]
957 fn test_compute_seed_methylation_differs_from_legacy() {
958 let mut sim = make_default_simulate();
959 let baseline = sim.compute_seed();
960 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
961 assert_ne!(sim.compute_seed(), baseline);
962 }
963
964 #[test]
967 fn test_compute_seed_explicit_seed_unaffected_by_methylation() {
968 let mut sim = make_default_simulate();
969 sim.seed.seed = Some(42);
970 assert_eq!(sim.compute_seed(), 42);
971 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
972 assert_eq!(sim.compute_seed(), 42);
973 }
974
975 #[test]
979 fn test_compute_seed_em_seq_and_taps_differ() {
980 let mut sim_em = make_default_simulate();
981 sim_em.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
982 let mut sim_taps = make_default_simulate();
983 sim_taps.methylation_mode = Some(crate::meth::MethylationMode::Taps);
984 assert_ne!(sim_em.compute_seed(), sim_taps.compute_seed());
985 }
986
987 #[test]
991 fn test_compute_seed_differs_by_failure_rate() {
992 let mut sim_a = make_default_simulate();
993 sim_a.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
994 sim_a.methylation_failure_rate = Some(0.0);
995 let mut sim_b = make_default_simulate();
996 sim_b.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
997 sim_b.methylation_failure_rate = Some(0.5);
998 assert_ne!(sim_a.compute_seed(), sim_b.compute_seed());
999 }
1000
1001 #[test]
1004 fn test_methylation_conversion_rate_without_mode_rejected() {
1005 let mut sim = make_default_simulate();
1006 sim.methylation_mode = None;
1007 sim.methylation_conversion_rate = Some(0.5);
1008 let err =
1009 sim.validate().expect_err("validate must reject orphaned methylation conversion rate");
1010 let msg = format!("{err}");
1011 assert!(
1012 msg.contains("--methylation-conversion-rate requires --methylation-mode"),
1013 "error must mention required mode flag, got: {msg}"
1014 );
1015 }
1016
1017 #[test]
1020 fn test_default_rates_without_mode_accepted() {
1021 let sim = make_default_simulate();
1022 assert!(sim.validate().is_ok(), "defaults without mode must validate cleanly");
1023 }
1024
1025 #[test]
1029 fn test_methylation_conversion_rate_explicit_one_without_mode_rejected() {
1030 let mut sim = make_default_simulate();
1031 sim.methylation_mode = None;
1032 sim.methylation_conversion_rate = Some(1.0);
1033 let err = sim.validate().expect_err(
1034 "validate must reject explicit --methylation-conversion-rate 1.0 without mode",
1035 );
1036 let msg = format!("{err}");
1037 assert!(
1038 msg.contains("--methylation-conversion-rate requires --methylation-mode"),
1039 "error must mention required mode flag, got: {msg}"
1040 );
1041 }
1042
1043 #[test]
1046 fn test_methylation_failure_rate_without_mode_rejected() {
1047 let mut sim = make_default_simulate();
1048 sim.methylation_mode = None;
1049 sim.methylation_failure_rate = Some(0.05);
1050 let err =
1051 sim.validate().expect_err("validate must reject orphaned methylation failure rate");
1052 let msg = format!("{err}");
1053 assert!(
1054 msg.contains("--methylation-failure-rate requires --methylation-mode"),
1055 "error must mention required mode flag, got: {msg}"
1056 );
1057 }
1058
1059 #[test]
1062 fn test_methylation_failure_rate_out_of_range_rejected() {
1063 let mut sim = make_default_simulate();
1064 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1065 sim.methylation_failure_rate = Some(1.5);
1066 let err = sim
1067 .validate()
1068 .expect_err("validate must reject --methylation-failure-rate outside [0, 1]");
1069 let msg = format!("{err}");
1070 assert!(
1071 msg.contains("--methylation-failure-rate must be in [0.0, 1.0]"),
1072 "error must mention the valid range, got: {msg}"
1073 );
1074 }
1075
1076 #[test]
1079 fn test_methylation_conversion_rate_out_of_range_rejected() {
1080 let mut sim = make_default_simulate();
1081 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1082 sim.methylation_conversion_rate = Some(1.5);
1083 let err = sim
1084 .validate()
1085 .expect_err("validate must reject --methylation-conversion-rate outside [0, 1]");
1086 let msg = format!("{err}");
1087 assert!(
1088 msg.contains("--methylation-conversion-rate must be in [0.0, 1.0]"),
1089 "error must mention the valid range, got: {msg}"
1090 );
1091 }
1092
1093 #[test]
1096 fn test_methylation_conversion_rate_nan_rejected() {
1097 let mut sim = make_default_simulate();
1098 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1099 sim.methylation_conversion_rate = Some(f64::NAN);
1100 let err =
1101 sim.validate().expect_err("validate must reject a NaN --methylation-conversion-rate");
1102 assert!(
1103 format!("{err}").contains("--methylation-conversion-rate must be in [0.0, 1.0]"),
1104 "error must mention the valid range"
1105 );
1106 }
1107
1108 #[test]
1113 fn test_cpg_truth_bedgraph_missing_parent_rejected() {
1114 let mut sim = make_default_simulate();
1115 sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1116 sim.cpg_truth_bedgraph =
1117 Some(PathBuf::from("/nonexistent-path-for-holodeck-test/sub/cpg.bg"));
1118 let err = sim
1119 .validate()
1120 .expect_err("validate must reject --cpg-truth-bedgraph with a non-existent parent");
1121 let msg = format!("{err}");
1122 assert!(
1123 msg.contains("CpG truth bedGraph directory does not exist"),
1124 "error must mention missing CpG truth bedGraph directory, got: {msg}"
1125 );
1126 }
1127}