1use std::io::Write;
13
14use anyhow::{Result, ensure};
15
16use crate::haplotype::Haplotype;
17use crate::meth::ContigMethylation;
18use crate::sequence_dict::SequenceDictionary;
19
20pub fn write_bedgraph_header<W: Write>(writer: &mut W) -> Result<()> {
30 writeln!(
31 writer,
32 "track type=\"bedGraph\" description=\"holodeck methylate population fraction\""
33 )?;
34 Ok(())
35}
36
37pub fn write_bedgraph_records<W: Write>(
73 writer: &mut W,
74 chrom: &str,
75 reference: &[u8],
76 haplotypes: &[Haplotype],
77 methylation: &ContigMethylation,
78) -> Result<()> {
79 ensure!(
80 haplotypes.is_empty() || haplotypes.len() == methylation.len(),
81 "haplotypes length ({}) must equal methylation haplotype count ({}) \
82 on contig {chrom:?}",
83 haplotypes.len(),
84 methylation.len(),
85 );
86 if reference.len() < 2 {
87 return Ok(());
88 }
89
90 let mut by_ref_pos: std::collections::BTreeMap<u32, (u32, u32)> =
94 std::collections::BTreeMap::new();
95
96 if haplotypes.is_empty() {
97 for i in 0..reference.len() - 1 {
99 if !reference[i].eq_ignore_ascii_case(&b'C')
100 || !reference[i + 1].eq_ignore_ascii_case(&b'G')
101 {
102 continue;
103 }
104 #[expect(clippy::cast_possible_truncation, reason = "ref pos fits u32")]
105 let top_c = i as u32;
106 let entry = by_ref_pos.entry(top_c).or_insert((0, 0));
107 for hap_idx in 0..methylation.len() {
108 let table = methylation.table_for(hap_idx);
109 if table.is_methylated(top_c, false) {
110 entry.0 += 1;
111 } else {
112 entry.1 += 1;
113 }
114 if table.is_methylated(top_c + 1, true) {
115 entry.0 += 1;
116 } else {
117 entry.1 += 1;
118 }
119 }
120 }
121 } else {
122 for (hap_idx, hap) in haplotypes.iter().enumerate() {
129 #[expect(clippy::cast_possible_truncation, reason = "ref length fits u32")]
130 let hap_len = hap.hap_position_for(reference.len() as u32) as usize;
131 let (hap_bases, ref_positions, _hap_start) =
132 hap.extract_fragment(reference, 0, hap_len);
133 if hap_bases.len() < 2 {
134 continue;
135 }
136 let table = methylation.table_for(hap_idx);
137 for h in 0..hap_bases.len() - 1 {
138 if !hap_bases[h].eq_ignore_ascii_case(&b'C')
139 || !hap_bases[h + 1].eq_ignore_ascii_case(&b'G')
140 {
141 continue;
142 }
143 let ref_top_c = ref_positions[h];
149 let ref_bot_c = ref_positions[h + 1];
150 if ref_bot_c != ref_top_c + 1 {
151 continue;
152 }
153 let ref_top_idx = ref_top_c as usize;
154 let ref_bot_idx = ref_bot_c as usize;
155 if ref_bot_idx >= reference.len()
156 || !reference[ref_top_idx].eq_ignore_ascii_case(&b'C')
157 || !reference[ref_bot_idx].eq_ignore_ascii_case(&b'G')
158 {
159 continue;
160 }
161 #[expect(clippy::cast_possible_truncation, reason = "hap pos fits u32")]
162 let hap_top = h as u32;
163 let entry = by_ref_pos.entry(ref_top_c).or_insert((0, 0));
164 if table.is_methylated(hap_top, false) {
165 entry.0 += 1;
166 } else {
167 entry.1 += 1;
168 }
169 if table.is_methylated(hap_top + 1, true) {
170 entry.0 += 1;
171 } else {
172 entry.1 += 1;
173 }
174 }
175 }
176 }
177
178 for (top_c, (n_meth, n_unmeth)) in by_ref_pos {
179 let denom = n_meth + n_unmeth;
180 if denom == 0 {
181 continue;
182 }
183 let rate = (f64::from(n_meth) / f64::from(denom) * 100.0).round();
188 #[expect(clippy::cast_possible_truncation, reason = "rate is in [0, 100]")]
189 #[expect(clippy::cast_sign_loss, reason = "rate is non-negative")]
190 let rate = rate as u32;
191 writeln!(writer, "{chrom}\t{top_c}\t{}\t{rate}\t{n_meth}\t{n_unmeth}", top_c + 1)?;
192 }
193 Ok(())
194}
195
196pub fn write_bedgraph<W: Write>(
211 writer: &mut W,
212 dict: &SequenceDictionary,
213 per_contig: &[(String, ContigMethylation, Vec<u8>, Vec<Haplotype>)],
214) -> Result<()> {
215 let _ = dict; write_bedgraph_header(writer)?;
217 for (chrom, cm, reference, haplotypes) in per_contig {
218 write_bedgraph_records(writer, chrom, reference, haplotypes, cm)?;
219 }
220 Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::haplotype::{Haplotype, build_haplotypes};
227 use crate::meth::{ContigMethylation, MethylationTable};
228 use crate::sequence_dict::{SequenceDictionary, SequenceMetadata};
229 use crate::vcf::genotype::{Genotype, VariantRecord};
230
231 #[derive(Clone, Copy)]
234 struct HapMethState {
235 top: bool,
236 bottom: bool,
237 }
238
239 fn build_diploid_at_one_cpg(
242 hap0: HapMethState,
243 hap1: HapMethState,
244 ) -> (Vec<u8>, ContigMethylation) {
245 let reference = b"ACGT".to_vec();
247 let mut h0 = MethylationTable::with_len(4);
248 if hap0.top {
249 h0.set_top(1, true);
250 }
251 if hap0.bottom {
252 h0.set_bottom(2, true);
253 }
254 let mut h1 = MethylationTable::with_len(4);
255 if hap1.top {
256 h1.set_top(1, true);
257 }
258 if hap1.bottom {
259 h1.set_bottom(2, true);
260 }
261 let cm = ContigMethylation::from_tables(vec![h0, h1]);
262 (reference, cm)
263 }
264
265 fn single_contig_dict(name: &str, len: usize) -> SequenceDictionary {
267 let entry = SequenceMetadata::new(0, name.to_string(), len);
268 SequenceDictionary::from_entries(vec![entry])
269 }
270
271 #[test]
272 fn population_fraction_bedgraph_diploid_all_methylated() {
273 let (reference, cm) = build_diploid_at_one_cpg(
274 HapMethState { top: true, bottom: true },
275 HapMethState { top: true, bottom: true },
276 );
277 let dict = single_contig_dict("chr1", reference.len());
278 let mut buf = Vec::new();
279 write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
280 .unwrap();
281 let s = String::from_utf8(buf).unwrap();
282 assert!(s.starts_with("track type="), "missing track header: {s}");
284 assert!(s.contains("chr1\t1\t2\t100"), "expected rate 100: {s}");
286 assert!(s.contains("4\t0"), "expected n_meth=4 n_unmeth=0: {s}");
287 }
288
289 #[test]
290 fn population_fraction_bedgraph_diploid_hemi() {
291 let (reference, cm) = build_diploid_at_one_cpg(
293 HapMethState { top: true, bottom: false },
294 HapMethState { top: false, bottom: false },
295 );
296 let dict = single_contig_dict("chr1", reference.len());
297 let mut buf = Vec::new();
298 write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
299 .unwrap();
300 let s = String::from_utf8(buf).unwrap();
301 assert!(s.contains("chr1\t1\t2\t25"), "expected rate 25: {s}");
303 assert!(s.contains("1\t3"), "expected n_meth=1 n_unmeth=3: {s}");
304 }
305
306 #[test]
307 fn population_fraction_bedgraph_no_cpg_emits_only_header() {
308 let reference = b"AATT".to_vec();
310 let h0 = MethylationTable::with_len(4);
311 let cm = ContigMethylation::from_tables(vec![h0]);
312 let dict = single_contig_dict("chr1", reference.len());
313 let mut buf = Vec::new();
314 write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
315 .unwrap();
316 let s = String::from_utf8(buf).unwrap();
317 assert!(s.starts_with("track type="), "missing track header: {s}");
318 let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
320 assert!(data_lines.is_empty(), "unexpected data lines: {s}");
321 }
322
323 #[test]
324 fn population_fraction_bedgraph_multiple_cpgs() {
325 let reference = b"ACGTACG".to_vec();
328 let mut h0 = MethylationTable::with_len(7);
329 h0.set_top(1, true);
330 h0.set_bottom(2, true);
331 h0.set_top(5, true);
332 h0.set_bottom(6, true);
333 let mut h1 = MethylationTable::with_len(7);
334 h1.set_top(1, true);
335 h1.set_bottom(2, true);
336 h1.set_top(5, true);
337 h1.set_bottom(6, true);
338 let cm = ContigMethylation::from_tables(vec![h0, h1]);
339 let dict = single_contig_dict("chr1", reference.len());
340 let mut buf = Vec::new();
341 write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
342 .unwrap();
343 let s = String::from_utf8(buf).unwrap();
344 assert!(s.contains("chr1\t1\t2\t100"), "missing first CpG: {s}");
345 assert!(s.contains("chr1\t5\t6\t100"), "missing second CpG: {s}");
346 }
347
348 #[test]
349 fn population_fraction_bedgraph_rounds_non_integer_rate() {
350 let reference = b"ACGT".to_vec();
355 let mut h0 = MethylationTable::with_len(4);
356 h0.set_top(1, true);
357 let mut h1 = MethylationTable::with_len(4);
358 h1.set_top(1, true);
359 let h2 = MethylationTable::with_len(4);
360 let cm = ContigMethylation::from_tables(vec![h0, h1, h2]);
361 let dict = single_contig_dict("chr1", reference.len());
362 let mut buf = Vec::new();
363 write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
364 .unwrap();
365 let s = String::from_utf8(buf).unwrap();
366 assert!(s.contains("chr1\t1\t2\t33\t2\t4"), "expected rounded rate 33: {s}");
368 assert!(!s.contains("33.3"), "rate must be an integer, not a float: {s}");
369 }
370
371 #[test]
372 fn write_bedgraph_records_streaming_matches_write_bedgraph() {
373 let (reference, cm) = build_diploid_at_one_cpg(
376 HapMethState { top: true, bottom: false },
377 HapMethState { top: true, bottom: true },
378 );
379 let dict = single_contig_dict("chr1", reference.len());
380
381 let mut all_at_once = Vec::new();
382 write_bedgraph(
383 &mut all_at_once,
384 &dict,
385 &[("chr1".to_string(), cm.clone(), reference.clone(), Vec::new())],
386 )
387 .unwrap();
388
389 let mut streamed = Vec::new();
390 write_bedgraph_header(&mut streamed).unwrap();
391 let no_haps: Vec<Haplotype> = Vec::new();
392 write_bedgraph_records(&mut streamed, "chr1", &reference, &no_haps, &cm).unwrap();
393
394 assert_eq!(all_at_once, streamed, "streaming API must match batch API");
395 }
396
397 #[test]
405 fn population_fraction_bedgraph_respects_haplotype_coordinates_for_indel() {
406 let reference = b"AACGAA".to_vec();
411 let variants = vec![VariantRecord {
412 position: 1,
413 ref_allele: b"A".to_vec(),
414 alt_alleles: vec![b"AAA".to_vec()],
415 genotype: Genotype::parse("0|1").unwrap(),
416 }];
417 let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
418 assert_eq!(haplotypes.len(), 2);
419 let h0 = MethylationTable::with_len(reference.len());
424 let mut h1 = MethylationTable::with_len(reference.len() + 2);
425 h1.set_top(4, true);
426 h1.set_bottom(5, true);
427 let cm = ContigMethylation::from_tables(vec![h0, h1]);
428
429 let mut buf = Vec::new();
430 write_bedgraph_header(&mut buf).unwrap();
431 write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
432 let s = String::from_utf8(buf).unwrap();
433
434 assert!(s.contains("chr1\t2\t3\t50\t2\t2"), "expected rate 50, counts 2/2: {s}");
437 }
438
439 #[test]
443 fn population_fraction_bedgraph_drops_haplotypes_with_destroyed_cpg() {
444 let reference = b"ACGT".to_vec();
447 let variants = vec![VariantRecord {
448 position: 1,
449 ref_allele: b"C".to_vec(),
450 alt_alleles: vec![b"T".to_vec()],
451 genotype: Genotype::parse("0|1").unwrap(),
452 }];
453 let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
454 let mut h0 = MethylationTable::with_len(reference.len());
455 let h1 = MethylationTable::with_len(reference.len());
456 h0.set_top(1, true);
458 h0.set_bottom(2, true);
459 let cm = ContigMethylation::from_tables(vec![h0, h1]);
460
461 let mut buf = Vec::new();
462 write_bedgraph_header(&mut buf).unwrap();
463 write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
464 let s = String::from_utf8(buf).unwrap();
465
466 assert!(s.contains("chr1\t1\t2\t100\t2\t0"), "expected rate 100, counts 2/0: {s}");
470 }
471
472 #[test]
476 fn population_fraction_bedgraph_errors_when_haplotypes_len_mismatches_methylation() {
477 let reference = b"ACGT".to_vec();
478 let cm = ContigMethylation::from_tables(vec![
480 MethylationTable::with_len(reference.len()),
481 MethylationTable::with_len(reference.len()),
482 ]);
483 let haplotypes = build_haplotypes(&[], 1, &mut rand::rng());
485 assert_eq!(haplotypes.len(), 1);
486
487 let mut buf = Vec::new();
488 let err = write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm)
489 .expect_err("length mismatch should error");
490 let msg = format!("{err}");
491 assert!(msg.contains("haplotypes length"), "unexpected error message: {msg}");
492 assert!(msg.contains("methylation haplotype count"), "unexpected error: {msg}");
493 }
494
495 #[test]
498 fn population_fraction_bedgraph_skips_cpg_destroyed_on_every_haplotype() {
499 let reference = b"ACGT".to_vec();
501 let variants = vec![VariantRecord {
502 position: 1,
503 ref_allele: b"C".to_vec(),
504 alt_alleles: vec![b"T".to_vec()],
505 genotype: Genotype::parse("1|1").unwrap(),
506 }];
507 let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
508 let h0 = MethylationTable::with_len(reference.len());
509 let h1 = MethylationTable::with_len(reference.len());
510 let cm = ContigMethylation::from_tables(vec![h0, h1]);
511
512 let mut buf = Vec::new();
513 write_bedgraph_header(&mut buf).unwrap();
514 write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
515 let s = String::from_utf8(buf).unwrap();
516
517 let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
518 assert!(data_lines.is_empty(), "expected no data lines, got: {s}");
519 }
520}