1use noodles::sam::alignment::record::cigar::op::{Kind, Op};
9use noodles::sam::alignment::record_buf::Cigar;
10use rand::Rng;
11
12use crate::error_model::{self, ErrorModel, ReadEnd};
13use crate::fragment::{
14 Fragment, extract_read_bases, lowercase_fraction, reverse_complement, uppercase_in_place,
15};
16use crate::meth::{
17 ConversionType, MethylationAnnotation, MethylationConfig, apply_methylation_conversion,
18};
19use crate::read_naming::{TruthAlignment, encoded_pe_name, encoded_se_name, simple_name};
20
21#[derive(Debug, Clone)]
23pub struct SimulatedRead {
24 pub name: String,
26 pub bases: Vec<u8>,
28 pub qualities: Vec<u8>,
30}
31
32#[derive(Debug)]
34pub struct ReadPair {
35 pub read1: SimulatedRead,
37 pub read2: Option<SimulatedRead>,
39 pub r1_truth: TruthAlignment,
41 pub r2_truth: Option<TruthAlignment>,
43 pub r1_cigar: Cigar,
46 pub r2_cigar: Option<Cigar>,
48 pub methylation: Option<crate::meth::MethylationAnnotation>,
53}
54
55struct MateOutput {
57 bases: Vec<u8>,
59 qualities: Vec<u8>,
61 pre_conversion: Option<Vec<u8>>,
64 cigar: Cigar,
66 truth: TruthAlignment,
68}
69
70fn apply_fragment_chemistry(
92 pre_chem_top: &[u8],
93 hap_start: u32,
94 is_forward: bool,
95 haplotype_index: usize,
96 config: &MethylationConfig<'_>,
97 rng: &mut impl Rng,
98) -> (Vec<u8>, bool) {
99 let mut bases = pre_chem_top.to_vec();
100 if !is_forward {
101 reverse_complement(&mut bases);
102 }
103 let n = bases.len();
104 let conversion_failed = apply_methylation_conversion(
105 &mut bases,
106 n,
107 !is_forward,
108 hap_start,
109 haplotype_index,
110 config,
111 rng,
112 );
113 if !is_forward {
114 reverse_complement(&mut bases);
115 }
116 (bases, conversion_failed)
117}
118
119#[allow(clippy::too_many_arguments)]
131fn build_mate(
132 fragment: &Fragment,
133 contig_name: &str,
134 end: ReadEnd,
135 is_negative_strand: bool,
136 mut bases: Vec<u8>,
137 pre_conversion: Option<Vec<u8>>,
138 adapter_bases: usize,
139 model: &impl ErrorModel,
140 rng: &mut impl Rng,
141) -> MateOutput {
142 let frag_len = fragment.bases.len();
143 let genomic = frag_len.min(bases.len());
144 let right_start = frag_len.saturating_sub(genomic);
145
146 let positions = if is_negative_strand {
149 &fragment.ref_positions[right_start..frag_len]
150 } else {
151 &fragment.ref_positions[..genomic]
152 };
153
154 let (n_errors, qualities) = error_model::apply_errors(model, &mut bases, end, rng);
155
156 let cigar = cigar_from_ref_positions(positions, adapter_bases, is_negative_strand);
157
158 let ref_pos = if fragment.ref_positions.is_empty() {
159 0
160 } else if is_negative_strand {
161 fragment.ref_positions[right_start] + 1
162 } else {
163 fragment.ref_positions[0] + 1
164 };
165
166 #[expect(clippy::cast_possible_truncation, reason = "fragment length fits in u32")]
167 let fragment_length = frag_len as u32;
168
169 let truth = TruthAlignment {
170 contig: contig_name.to_string(),
171 position: ref_pos,
172 is_forward: match end {
173 ReadEnd::Read1 => fragment.is_forward,
174 ReadEnd::Read2 => !fragment.is_forward,
175 },
176 haplotype: fragment.haplotype_index,
177 fragment_length,
178 n_errors,
179 };
180
181 MateOutput { bases, qualities, pre_conversion, cigar, truth }
182}
183
184#[allow(clippy::too_many_arguments)] pub fn generate_read_pair(
220 fragment: &Fragment,
221 contig_name: &str,
222 read_num: u64,
223 read_length: usize,
224 paired: bool,
225 adapter_r1: &[u8],
226 adapter_r2: &[u8],
227 max_n_frac: f64,
228 model: &impl ErrorModel,
229 simple_names: bool,
230 methylation: Option<&MethylationConfig>,
231 capture_pre_conversion: bool,
232 rng: &mut impl Rng,
233) -> Option<ReadPair> {
234 let frag_len = fragment.bases.len();
235 let adapter_bases = read_length.saturating_sub(frag_len.min(read_length));
236
237 let r1_negative_strand = !fragment.is_forward;
238 let r2_negative_strand = fragment.is_forward;
239
240 let r1_raw = extract_read_bases(&fragment.bases, read_length, adapter_r1, r1_negative_strand);
245 if lowercase_fraction(&r1_raw) > max_n_frac {
246 return None;
247 }
248 if paired {
249 let r2_raw =
250 extract_read_bases(&fragment.bases, read_length, adapter_r2, r2_negative_strand);
251 if lowercase_fraction(&r2_raw) > max_n_frac {
252 return None;
253 }
254 }
255
256 let mut pre_chem_top = fragment.bases.clone();
261 uppercase_in_place(&mut pre_chem_top);
262
263 let (post_chem_top, conversion_failed) = match methylation {
268 Some(mc) => apply_fragment_chemistry(
269 &pre_chem_top,
270 fragment.hap_start,
271 fragment.is_forward,
272 fragment.haplotype_index,
273 mc,
274 rng,
275 ),
276 None => (pre_chem_top.clone(), false),
277 };
278
279 let r1_bases = extract_read_bases(&post_chem_top, read_length, adapter_r1, r1_negative_strand);
283
284 let want_pre = capture_pre_conversion && methylation.is_some();
288 let r1_pre_conversion = want_pre
289 .then(|| extract_read_bases(&pre_chem_top, read_length, adapter_r1, r1_negative_strand));
290
291 let r1 = build_mate(
292 fragment,
293 contig_name,
294 ReadEnd::Read1,
295 r1_negative_strand,
296 r1_bases,
297 r1_pre_conversion,
298 adapter_bases,
299 model,
300 rng,
301 );
302
303 if !paired {
304 let name =
305 if simple_names { simple_name(read_num) } else { encoded_se_name(read_num, &r1.truth) };
306
307 let methylation_annotation = methylation.map(|_| MethylationAnnotation {
308 conversion_type: ConversionType::from_strand(fragment.is_forward),
309 conversion_failed,
310 r1_pre_conversion_bases: r1.pre_conversion,
311 r2_pre_conversion_bases: None,
312 r1_call_tags: None,
313 r2_call_tags: None,
314 });
315
316 return Some(ReadPair {
317 read1: SimulatedRead { name, bases: r1.bases, qualities: r1.qualities },
318 read2: None,
319 r1_truth: r1.truth,
320 r2_truth: None,
321 r1_cigar: r1.cigar,
322 r2_cigar: None,
323 methylation: methylation_annotation,
324 });
325 }
326
327 let r2_bases = extract_read_bases(&post_chem_top, read_length, adapter_r2, r2_negative_strand);
328 let r2_pre_conversion = want_pre
329 .then(|| extract_read_bases(&pre_chem_top, read_length, adapter_r2, r2_negative_strand));
330 let r2 = build_mate(
331 fragment,
332 contig_name,
333 ReadEnd::Read2,
334 r2_negative_strand,
335 r2_bases,
336 r2_pre_conversion,
337 adapter_bases,
338 model,
339 rng,
340 );
341
342 let name = if simple_names {
343 simple_name(read_num)
344 } else {
345 encoded_pe_name(read_num, &r1.truth, &r2.truth)
346 };
347
348 let methylation_annotation = methylation.map(|_| MethylationAnnotation {
349 conversion_type: ConversionType::from_strand(fragment.is_forward),
350 conversion_failed,
351 r1_pre_conversion_bases: r1.pre_conversion,
352 r2_pre_conversion_bases: r2.pre_conversion,
353 r1_call_tags: None,
354 r2_call_tags: None,
355 });
356
357 Some(ReadPair {
358 read1: SimulatedRead { name: name.clone(), bases: r1.bases, qualities: r1.qualities },
359 read2: Some(SimulatedRead { name, bases: r2.bases, qualities: r2.qualities }),
360 r1_truth: r1.truth,
361 r2_truth: Some(r2.truth),
362 r1_cigar: r1.cigar,
363 r2_cigar: Some(r2.cigar),
364 methylation: methylation_annotation,
365 })
366}
367
368#[must_use]
385pub fn cigar_from_ref_positions(
386 positions: &[u32],
387 adapter_bases: usize,
388 negative_strand: bool,
389) -> Cigar {
390 let mut ops: Vec<Op> = Vec::new();
391
392 if positions.is_empty() {
393 if adapter_bases > 0 {
394 ops.push(Op::new(Kind::SoftClip, adapter_bases));
395 }
396 return Cigar::from(ops);
397 }
398
399 let mut match_run: usize = 1; let mut ins_run: usize = 0;
401
402 for i in 1..positions.len() {
403 let prev = positions[i - 1];
404 let curr = positions[i];
405
406 if curr == prev + 1 {
407 if ins_run > 0 {
409 ops.push(Op::new(Kind::Insertion, ins_run));
410 ins_run = 0;
411 }
412 match_run += 1;
413 } else if curr == prev {
414 if match_run > 0 {
416 ops.push(Op::new(Kind::Match, match_run));
417 match_run = 0;
418 }
419 ins_run += 1;
420 } else {
421 if ins_run > 0 {
423 ops.push(Op::new(Kind::Insertion, ins_run));
424 ins_run = 0;
425 }
426 if match_run > 0 {
427 ops.push(Op::new(Kind::Match, match_run));
428 }
429 let gap = curr.saturating_sub(prev).saturating_sub(1);
430 if gap > 0 {
431 ops.push(Op::new(Kind::Deletion, gap as usize));
432 }
433 match_run = 1; }
435 }
436
437 if ins_run > 0 {
439 ops.push(Op::new(Kind::Insertion, ins_run));
440 }
441 if match_run > 0 {
442 ops.push(Op::new(Kind::Match, match_run));
443 }
444
445 if adapter_bases > 0 {
449 if negative_strand {
450 ops.insert(0, Op::new(Kind::SoftClip, adapter_bases));
451 } else {
452 ops.push(Op::new(Kind::SoftClip, adapter_bases));
453 }
454 }
455
456 Cigar::from(ops)
457}
458
459#[must_use]
461pub fn cigar_to_string(cigar: &Cigar) -> String {
462 use std::fmt::Write;
463 cigar.as_ref().iter().fold(String::new(), |mut s, op| {
464 let kind_char = match op.kind() {
465 Kind::Match => 'M',
466 Kind::Insertion => 'I',
467 Kind::Deletion => 'D',
468 Kind::SoftClip => 'S',
469 Kind::HardClip => 'H',
470 Kind::Skip => 'N',
471 Kind::Pad => 'P',
472 Kind::SequenceMatch => '=',
473 Kind::SequenceMismatch => 'X',
474 };
475 let _ = write!(s, "{}{kind_char}", op.len());
476 s
477 })
478}
479
480#[cfg(test)]
481mod tests {
482 use rand::SeedableRng;
483 use rand::rngs::SmallRng;
484
485 use super::*;
486 use crate::error_model::illumina::IlluminaErrorModel;
487
488 fn test_fragment(bases: &[u8], ref_start: u32) -> Fragment {
490 #[expect(clippy::cast_possible_truncation, reason = "test data is small")]
491 let ref_positions: Vec<u32> = (ref_start..ref_start + bases.len() as u32).collect();
492 Fragment {
493 bases: bases.to_vec(),
494 ref_positions,
495 ref_start,
496 hap_start: ref_start,
497 is_forward: true,
498 haplotype_index: 0,
499 }
500 }
501
502 #[test]
505 fn test_cigar_all_match() {
506 let cigar = cigar_from_ref_positions(&[0, 1, 2, 3, 4], 0, false);
507 assert_eq!(cigar_to_string(&cigar), "5M");
508 }
509
510 #[test]
511 fn test_cigar_with_insertion() {
512 let cigar = cigar_from_ref_positions(&[0, 1, 2, 2, 2, 3, 4], 0, false);
514 assert_eq!(cigar_to_string(&cigar), "3M2I2M");
515 }
516
517 #[test]
518 fn test_cigar_with_deletion() {
519 let cigar = cigar_from_ref_positions(&[0, 1, 2, 5, 6], 0, false);
521 assert_eq!(cigar_to_string(&cigar), "3M2D2M");
522 }
523
524 #[test]
525 fn test_cigar_with_adapter_softclip_forward() {
526 let cigar = cigar_from_ref_positions(&[0, 1, 2], 2, false);
528 assert_eq!(cigar_to_string(&cigar), "3M2S");
529 }
530
531 #[test]
532 fn test_cigar_with_adapter_softclip_negative_strand() {
533 let cigar = cigar_from_ref_positions(&[0, 1, 2], 2, true);
535 assert_eq!(cigar_to_string(&cigar), "2S3M");
536 }
537
538 #[test]
539 fn test_cigar_all_adapter() {
540 let cigar = cigar_from_ref_positions(&[], 5, false);
542 assert_eq!(cigar_to_string(&cigar), "5S");
543 }
544
545 #[test]
546 fn test_cigar_with_insertion_and_deletion() {
547 let cigar = cigar_from_ref_positions(&[0, 1, 2, 2, 5, 6], 0, false);
549 assert_eq!(cigar_to_string(&cigar), "3M1I2D2M");
550 }
551
552 #[test]
553 fn test_cigar_with_adapter_and_deletion() {
554 let cigar = cigar_from_ref_positions(&[0, 1, 4, 5], 3, false);
555 assert_eq!(cigar_to_string(&cigar), "2M2D2M3S");
556 }
557
558 #[test]
559 fn test_cigar_single_base() {
560 let cigar = cigar_from_ref_positions(&[42], 0, false);
561 assert_eq!(cigar_to_string(&cigar), "1M");
562 }
563
564 #[test]
565 fn test_cigar_high_positions() {
566 let cigar = cigar_from_ref_positions(&[100, 101, 102, 103, 104], 0, true);
569 assert_eq!(cigar_to_string(&cigar), "5M");
570 }
571
572 #[test]
575 fn test_generate_pe_read_pair() {
576 let fragment = test_fragment(b"ACGTACGTACGTACGTACGT", 100);
577 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
578 let mut rng = SmallRng::seed_from_u64(42);
579
580 let pair = generate_read_pair(
581 &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 1.0, &model, false, None,
582 false, &mut rng,
583 )
584 .expect("no ambiguous bases — should not reject");
585
586 assert_eq!(pair.read1.bases, b"ACGTACGTAC");
587 assert!(pair.read2.is_some());
588 assert_eq!(pair.r1_truth.position, 101);
589 assert!(pair.r2_truth.is_some());
590 assert_eq!(cigar_to_string(&pair.r1_cigar), "10M");
591 assert_eq!(cigar_to_string(pair.r2_cigar.as_ref().unwrap()), "10M");
592 }
593
594 #[test]
595 fn test_generate_se_read() {
596 let fragment = test_fragment(b"ACGTACGTAC", 100);
597 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
598 let mut rng = SmallRng::seed_from_u64(42);
599
600 let pair = generate_read_pair(
601 &fragment, "chr1", 5, 10, false, b"ADAPTER", b"ADAPTER", 1.0, &model, false, None,
602 false, &mut rng,
603 )
604 .unwrap();
605
606 assert!(pair.read2.is_none());
607 assert!(pair.r2_cigar.is_none());
608 assert_eq!(cigar_to_string(&pair.r1_cigar), "10M");
609 }
610
611 #[test]
612 fn test_adapter_cigar_softclip() {
613 let fragment = test_fragment(b"AC", 0);
616 let model = IlluminaErrorModel::new(5, 0.0, 0.0);
617 let mut rng = SmallRng::seed_from_u64(42);
618
619 let pair = generate_read_pair(
620 &fragment, "chr1", 1, 5, true, b"TTTTT", b"GGGGG", 1.0, &model, false, None, false,
621 &mut rng,
622 )
623 .unwrap();
624
625 assert_eq!(cigar_to_string(&pair.r1_cigar), "2M3S");
626 assert_eq!(cigar_to_string(pair.r2_cigar.as_ref().unwrap()), "3S2M");
627 }
628
629 #[test]
630 fn test_simple_name_mode() {
631 let fragment = test_fragment(b"ACGT", 0);
632 let model = IlluminaErrorModel::new(4, 0.0, 0.0);
633 let mut rng = SmallRng::seed_from_u64(42);
634
635 let pair = generate_read_pair(
636 &fragment, "chr1", 42, 4, true, b"A", b"A", 1.0, &model, true, None, false, &mut rng,
637 )
638 .unwrap();
639
640 assert_eq!(pair.read1.name, "holodeck::42");
641 }
642
643 #[test]
644 fn test_quality_scores_correct_length() {
645 let fragment = test_fragment(b"ACGTACGTAC", 0);
646 let model = IlluminaErrorModel::new(10, 0.001, 0.01);
647 let mut rng = SmallRng::seed_from_u64(42);
648
649 let pair = generate_read_pair(
650 &fragment, "chr1", 1, 10, true, b"A", b"A", 1.0, &model, false, None, false, &mut rng,
651 )
652 .unwrap();
653
654 assert_eq!(pair.read1.qualities.len(), 10);
655 assert_eq!(pair.read2.as_ref().unwrap().qualities.len(), 10);
656 }
657
658 #[test]
659 fn test_rejects_when_r1_exceeds_max_n_frac() {
660 let mut fragment = test_fragment(b"acgtacgtac", 100);
663 fragment.is_forward = true;
665 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
666 let mut rng = SmallRng::seed_from_u64(42);
667
668 let pair = generate_read_pair(
669 &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 0.5, &model, false, None,
670 false, &mut rng,
671 );
672
673 assert!(pair.is_none(), "all-lowercase fragment should be rejected at threshold 0.5");
674 }
675
676 #[test]
677 fn test_accepts_when_lowercase_below_threshold() {
678 let fragment = test_fragment(b"ACaGcTAtCA", 0);
681 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
682 let mut rng = SmallRng::seed_from_u64(42);
683
684 let pair = generate_read_pair(
685 &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 0.5, &model, false, None,
686 false, &mut rng,
687 )
688 .expect("0.3 < 0.5 — should accept");
689
690 for &b in &pair.read1.bases {
692 assert!(matches!(b, b'A' | b'C' | b'G' | b'T' | b'N'), "r1 got {b:?}");
693 }
694 for &b in &pair.read2.as_ref().unwrap().bases {
695 assert!(matches!(b, b'A' | b'C' | b'G' | b'T' | b'N'), "r2 got {b:?}");
696 }
697 }
698
699 #[test]
700 fn test_generate_read_pair_with_em_seq_converts_forward_r1() {
701 use crate::meth::{
702 ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
703 };
704
705 let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(1000)]);
706 let mc = MethylationConfig {
707 contig_methylation: &cm,
708 mode: MethylationMode::EmSeq,
709 conversion_rate: 1.0,
710 failure_rate: 0.0,
711 };
712
713 let fragment = test_fragment(b"ACGTACGTAC", 100);
717 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
718 let mut rng = SmallRng::seed_from_u64(42);
719
720 let pair = generate_read_pair(
721 &fragment,
722 "chr1",
723 1,
724 10,
725 true,
726 b"ADAPTER",
727 b"ADAPTER",
728 1.0,
729 &model,
730 false,
731 Some(&mc),
732 true,
733 &mut rng,
734 )
735 .unwrap();
736
737 #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
740 let n_c_in_r1 = pair.read1.bases.iter().filter(|&&b| b == b'C').count();
741 assert_eq!(
742 n_c_in_r1, 0,
743 "expected all C's in R1 converted, got bases {:?}",
744 pair.read1.bases
745 );
746
747 let ann = pair.methylation.as_ref().expect("methylation annotation must be set");
748 assert_eq!(ann.conversion_type, crate::meth::ConversionType::Ct);
749 let r1_pre = ann
751 .r1_pre_conversion_bases
752 .as_ref()
753 .expect("capture_pre_conversion=true → R1 pre-conversion bases must be Some");
754 #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
755 let n_c_pre = r1_pre.iter().filter(|&&b| b == b'C').count();
756 assert!(n_c_pre > 0, "pre-conversion R1 should still have C's, got {n_c_pre}");
757 assert_eq!(
758 r1_pre.len(),
759 pair.read1.bases.len(),
760 "pre-conversion length must match post-conversion read length"
761 );
762 assert!(
764 ann.r2_pre_conversion_bases.is_some(),
765 "PE methylation annotation must include R2 pre-conversion bases"
766 );
767 }
768
769 #[test]
770 fn test_generate_read_pair_with_em_seq_reverse_strand_yields_ga_conversion_type() {
771 use crate::meth::{
772 ContigMethylation, ConversionType, MethylationConfig, MethylationMode, MethylationTable,
773 };
774
775 let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(1000)]);
776 let mc = MethylationConfig {
777 contig_methylation: &cm,
778 mode: MethylationMode::EmSeq,
779 conversion_rate: 1.0,
780 failure_rate: 0.0,
781 };
782
783 let mut fragment = test_fragment(b"ACGTACGTAC", 100);
785 fragment.is_forward = false;
786 let model = IlluminaErrorModel::new(10, 0.0, 0.0);
787 let mut rng = SmallRng::seed_from_u64(42);
788
789 let pair = generate_read_pair(
790 &fragment,
791 "chr1",
792 1,
793 10,
794 true,
795 b"ADAPTER",
796 b"ADAPTER",
797 1.0,
798 &model,
799 false,
800 Some(&mc),
801 true,
802 &mut rng,
803 )
804 .unwrap();
805
806 let ann = pair.methylation.as_ref().expect("methylation annotation must be set");
807 assert_eq!(ann.conversion_type, ConversionType::Ga);
808 assert!(ann.r2_pre_conversion_bases.is_some());
809
810 #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
817 let cytosines_in_r1 = pair.read1.bases.iter().filter(|&&b| b == b'C').count();
818 assert_eq!(
819 cytosines_in_r1, 0,
820 "R1 should have no C's after full conversion of source strand"
821 );
822 let r2 = pair.read2.as_ref().unwrap();
823 #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
824 let guanines_in_r2 = r2.bases.iter().filter(|&&b| b == b'G').count();
825 assert_eq!(
826 guanines_in_r2, 0,
827 "R2 5'→3' = revcomp(c2t(bottom)) must have no G's after full conversion"
828 );
829 }
830
831 #[test]
832 fn test_directional_r2_of_ct_fragment_is_revcomp_of_c2t_top() {
833 use crate::meth::{
834 ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
835 };
836
837 let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(100)]);
850 let mc = MethylationConfig {
851 contig_methylation: &cm,
852 mode: MethylationMode::EmSeq,
853 conversion_rate: 1.0,
854 failure_rate: 0.0,
855 };
856
857 let fragment = test_fragment(b"ACAGACAGACAG", 0);
858 let model = IlluminaErrorModel::new(12, 0.0, 0.0);
859 let mut rng = SmallRng::seed_from_u64(42);
860
861 let pair = generate_read_pair(
862 &fragment,
863 "chr1",
864 1,
865 12,
866 true,
867 b"ADAPTER",
868 b"ADAPTER",
869 1.0,
870 &model,
871 false,
872 Some(&mc),
873 false,
874 &mut rng,
875 )
876 .unwrap();
877
878 assert_eq!(&pair.read1.bases, b"ATAGATAGATAG", "R1 must equal c2t(top)");
879 let r2 = pair.read2.unwrap();
880 assert_eq!(
881 &r2.bases, b"CTATCTATCTAT",
882 "R2 must equal revcomp(c2t(top)) for directional behavior; \
883 c2t(revcomp(top)) = TTGTTTGTTTGT would indicate the wrong (per-mate) chemistry model"
884 );
885 }
886
887 #[test]
888 fn test_directional_r2_of_ga_fragment_is_revcomp_of_c2t_bottom() {
889 use crate::meth::{
890 ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
891 };
892
893 let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(100)]);
903 let mc = MethylationConfig {
904 contig_methylation: &cm,
905 mode: MethylationMode::EmSeq,
906 conversion_rate: 1.0,
907 failure_rate: 0.0,
908 };
909
910 let mut fragment = test_fragment(b"ACAGACAGACAG", 0);
911 fragment.is_forward = false;
912 let model = IlluminaErrorModel::new(12, 0.0, 0.0);
913 let mut rng = SmallRng::seed_from_u64(42);
914
915 let pair = generate_read_pair(
916 &fragment,
917 "chr1",
918 1,
919 12,
920 true,
921 b"ADAPTER",
922 b"ADAPTER",
923 1.0,
924 &model,
925 false,
926 Some(&mc),
927 false,
928 &mut rng,
929 )
930 .unwrap();
931
932 assert_eq!(&pair.read1.bases, b"TTGTTTGTTTGT", "R1 of GA must equal c2t(bottom)");
933 let r2 = pair.read2.unwrap();
934 assert_eq!(
935 &r2.bases, b"ACAAACAAACAA",
936 "R2 of GA must equal revcomp(c2t(bottom)) for directional behavior"
937 );
938 }
939
940 #[test]
941 fn test_rejects_before_applying_errors_to_r1() {
942 let mut fragment_half_bad = test_fragment(&[b'A'; 20], 0);
948 for b in &mut fragment_half_bad.bases[10..] {
950 *b = b'a';
951 }
952 fragment_half_bad.is_forward = true;
953
954 let clean_fragment = test_fragment(&[b'A'; 20], 0);
955 let model = IlluminaErrorModel::new(10, 0.5, 0.5); let mut rng_a = SmallRng::seed_from_u64(123);
959 let rejected = generate_read_pair(
960 &fragment_half_bad,
961 "chr1",
962 1,
963 10,
964 true,
965 b"TTTTTTTTTT",
966 b"TTTTTTTTTT",
967 0.5,
968 &model,
969 false,
970 None,
971 false,
972 &mut rng_a,
973 );
974 assert!(rejected.is_none(), "expected rejection when R2 is all-lowercase");
975 let after_reject = generate_read_pair(
976 &clean_fragment,
977 "chr1",
978 2,
979 10,
980 true,
981 b"TTTTTTTTTT",
982 b"TTTTTTTTTT",
983 0.5,
984 &model,
985 false,
986 None,
987 false,
988 &mut rng_a,
989 )
990 .unwrap();
991
992 let mut rng_b = SmallRng::seed_from_u64(123);
994 let direct = generate_read_pair(
995 &clean_fragment,
996 "chr1",
997 2,
998 10,
999 true,
1000 b"TTTTTTTTTT",
1001 b"TTTTTTTTTT",
1002 0.5,
1003 &model,
1004 false,
1005 None,
1006 false,
1007 &mut rng_b,
1008 )
1009 .unwrap();
1010
1011 assert_eq!(after_reject.read1.bases, direct.read1.bases);
1014 assert_eq!(after_reject.read1.qualities, direct.read1.qualities);
1015 assert_eq!(
1016 after_reject.read2.as_ref().unwrap().bases,
1017 direct.read2.as_ref().unwrap().bases
1018 );
1019 }
1020}