use std::io::Write;
use anyhow::{Result, ensure};
use crate::haplotype::Haplotype;
use crate::meth::ContigMethylation;
use crate::sequence_dict::SequenceDictionary;
pub fn write_bedgraph_header<W: Write>(writer: &mut W) -> Result<()> {
writeln!(
writer,
"track type=\"bedGraph\" description=\"holodeck methylate population fraction\""
)?;
Ok(())
}
pub fn write_bedgraph_records<W: Write>(
writer: &mut W,
chrom: &str,
reference: &[u8],
haplotypes: &[Haplotype],
methylation: &ContigMethylation,
) -> Result<()> {
ensure!(
haplotypes.is_empty() || haplotypes.len() == methylation.len(),
"haplotypes length ({}) must equal methylation haplotype count ({}) \
on contig {chrom:?}",
haplotypes.len(),
methylation.len(),
);
if reference.len() < 2 {
return Ok(());
}
let mut by_ref_pos: std::collections::BTreeMap<u32, (u32, u32)> =
std::collections::BTreeMap::new();
if haplotypes.is_empty() {
for i in 0..reference.len() - 1 {
if !reference[i].eq_ignore_ascii_case(&b'C')
|| !reference[i + 1].eq_ignore_ascii_case(&b'G')
{
continue;
}
#[expect(clippy::cast_possible_truncation, reason = "ref pos fits u32")]
let top_c = i as u32;
let entry = by_ref_pos.entry(top_c).or_insert((0, 0));
for hap_idx in 0..methylation.len() {
let table = methylation.table_for(hap_idx);
if table.is_methylated(top_c, false) {
entry.0 += 1;
} else {
entry.1 += 1;
}
if table.is_methylated(top_c + 1, true) {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
}
} else {
for (hap_idx, hap) in haplotypes.iter().enumerate() {
#[expect(clippy::cast_possible_truncation, reason = "ref length fits u32")]
let hap_len = hap.hap_position_for(reference.len() as u32) as usize;
let (hap_bases, ref_positions, _hap_start) =
hap.extract_fragment(reference, 0, hap_len);
if hap_bases.len() < 2 {
continue;
}
let table = methylation.table_for(hap_idx);
for h in 0..hap_bases.len() - 1 {
if !hap_bases[h].eq_ignore_ascii_case(&b'C')
|| !hap_bases[h + 1].eq_ignore_ascii_case(&b'G')
{
continue;
}
let ref_top_c = ref_positions[h];
let ref_bot_c = ref_positions[h + 1];
if ref_bot_c != ref_top_c + 1 {
continue;
}
let ref_top_idx = ref_top_c as usize;
let ref_bot_idx = ref_bot_c as usize;
if ref_bot_idx >= reference.len()
|| !reference[ref_top_idx].eq_ignore_ascii_case(&b'C')
|| !reference[ref_bot_idx].eq_ignore_ascii_case(&b'G')
{
continue;
}
#[expect(clippy::cast_possible_truncation, reason = "hap pos fits u32")]
let hap_top = h as u32;
let entry = by_ref_pos.entry(ref_top_c).or_insert((0, 0));
if table.is_methylated(hap_top, false) {
entry.0 += 1;
} else {
entry.1 += 1;
}
if table.is_methylated(hap_top + 1, true) {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
}
}
for (top_c, (n_meth, n_unmeth)) in by_ref_pos {
let denom = n_meth + n_unmeth;
if denom == 0 {
continue;
}
let rate = (f64::from(n_meth) / f64::from(denom) * 100.0).round();
#[expect(clippy::cast_possible_truncation, reason = "rate is in [0, 100]")]
#[expect(clippy::cast_sign_loss, reason = "rate is non-negative")]
let rate = rate as u32;
writeln!(writer, "{chrom}\t{top_c}\t{}\t{rate}\t{n_meth}\t{n_unmeth}", top_c + 1)?;
}
Ok(())
}
pub fn write_bedgraph<W: Write>(
writer: &mut W,
dict: &SequenceDictionary,
per_contig: &[(String, ContigMethylation, Vec<u8>, Vec<Haplotype>)],
) -> Result<()> {
let _ = dict; write_bedgraph_header(writer)?;
for (chrom, cm, reference, haplotypes) in per_contig {
write_bedgraph_records(writer, chrom, reference, haplotypes, cm)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::haplotype::{Haplotype, build_haplotypes};
use crate::meth::{ContigMethylation, MethylationTable};
use crate::sequence_dict::{SequenceDictionary, SequenceMetadata};
use crate::vcf::genotype::{Genotype, VariantRecord};
#[derive(Clone, Copy)]
struct HapMethState {
top: bool,
bottom: bool,
}
fn build_diploid_at_one_cpg(
hap0: HapMethState,
hap1: HapMethState,
) -> (Vec<u8>, ContigMethylation) {
let reference = b"ACGT".to_vec();
let mut h0 = MethylationTable::with_len(4);
if hap0.top {
h0.set_top(1, true);
}
if hap0.bottom {
h0.set_bottom(2, true);
}
let mut h1 = MethylationTable::with_len(4);
if hap1.top {
h1.set_top(1, true);
}
if hap1.bottom {
h1.set_bottom(2, true);
}
let cm = ContigMethylation::from_tables(vec![h0, h1]);
(reference, cm)
}
fn single_contig_dict(name: &str, len: usize) -> SequenceDictionary {
let entry = SequenceMetadata::new(0, name.to_string(), len);
SequenceDictionary::from_entries(vec![entry])
}
#[test]
fn population_fraction_bedgraph_diploid_all_methylated() {
let (reference, cm) = build_diploid_at_one_cpg(
HapMethState { top: true, bottom: true },
HapMethState { top: true, bottom: true },
);
let dict = single_contig_dict("chr1", reference.len());
let mut buf = Vec::new();
write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("track type="), "missing track header: {s}");
assert!(s.contains("chr1\t1\t2\t100"), "expected rate 100: {s}");
assert!(s.contains("4\t0"), "expected n_meth=4 n_unmeth=0: {s}");
}
#[test]
fn population_fraction_bedgraph_diploid_hemi() {
let (reference, cm) = build_diploid_at_one_cpg(
HapMethState { top: true, bottom: false },
HapMethState { top: false, bottom: false },
);
let dict = single_contig_dict("chr1", reference.len());
let mut buf = Vec::new();
write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t1\t2\t25"), "expected rate 25: {s}");
assert!(s.contains("1\t3"), "expected n_meth=1 n_unmeth=3: {s}");
}
#[test]
fn population_fraction_bedgraph_no_cpg_emits_only_header() {
let reference = b"AATT".to_vec();
let h0 = MethylationTable::with_len(4);
let cm = ContigMethylation::from_tables(vec![h0]);
let dict = single_contig_dict("chr1", reference.len());
let mut buf = Vec::new();
write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.starts_with("track type="), "missing track header: {s}");
let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
assert!(data_lines.is_empty(), "unexpected data lines: {s}");
}
#[test]
fn population_fraction_bedgraph_multiple_cpgs() {
let reference = b"ACGTACG".to_vec();
let mut h0 = MethylationTable::with_len(7);
h0.set_top(1, true);
h0.set_bottom(2, true);
h0.set_top(5, true);
h0.set_bottom(6, true);
let mut h1 = MethylationTable::with_len(7);
h1.set_top(1, true);
h1.set_bottom(2, true);
h1.set_top(5, true);
h1.set_bottom(6, true);
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let dict = single_contig_dict("chr1", reference.len());
let mut buf = Vec::new();
write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t1\t2\t100"), "missing first CpG: {s}");
assert!(s.contains("chr1\t5\t6\t100"), "missing second CpG: {s}");
}
#[test]
fn population_fraction_bedgraph_rounds_non_integer_rate() {
let reference = b"ACGT".to_vec();
let mut h0 = MethylationTable::with_len(4);
h0.set_top(1, true);
let mut h1 = MethylationTable::with_len(4);
h1.set_top(1, true);
let h2 = MethylationTable::with_len(4);
let cm = ContigMethylation::from_tables(vec![h0, h1, h2]);
let dict = single_contig_dict("chr1", reference.len());
let mut buf = Vec::new();
write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
.unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t1\t2\t33\t2\t4"), "expected rounded rate 33: {s}");
assert!(!s.contains("33.3"), "rate must be an integer, not a float: {s}");
}
#[test]
fn write_bedgraph_records_streaming_matches_write_bedgraph() {
let (reference, cm) = build_diploid_at_one_cpg(
HapMethState { top: true, bottom: false },
HapMethState { top: true, bottom: true },
);
let dict = single_contig_dict("chr1", reference.len());
let mut all_at_once = Vec::new();
write_bedgraph(
&mut all_at_once,
&dict,
&[("chr1".to_string(), cm.clone(), reference.clone(), Vec::new())],
)
.unwrap();
let mut streamed = Vec::new();
write_bedgraph_header(&mut streamed).unwrap();
let no_haps: Vec<Haplotype> = Vec::new();
write_bedgraph_records(&mut streamed, "chr1", &reference, &no_haps, &cm).unwrap();
assert_eq!(all_at_once, streamed, "streaming API must match batch API");
}
#[test]
fn population_fraction_bedgraph_respects_haplotype_coordinates_for_indel() {
let reference = b"AACGAA".to_vec();
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"AAA".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
}];
let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
assert_eq!(haplotypes.len(), 2);
let h0 = MethylationTable::with_len(reference.len());
let mut h1 = MethylationTable::with_len(reference.len() + 2);
h1.set_top(4, true);
h1.set_bottom(5, true);
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_bedgraph_header(&mut buf).unwrap();
write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t2\t3\t50\t2\t2"), "expected rate 50, counts 2/2: {s}");
}
#[test]
fn population_fraction_bedgraph_drops_haplotypes_with_destroyed_cpg() {
let reference = b"ACGT".to_vec();
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"C".to_vec(),
alt_alleles: vec![b"T".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
}];
let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
let mut h0 = MethylationTable::with_len(reference.len());
let h1 = MethylationTable::with_len(reference.len());
h0.set_top(1, true);
h0.set_bottom(2, true);
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_bedgraph_header(&mut buf).unwrap();
write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t1\t2\t100\t2\t0"), "expected rate 100, counts 2/0: {s}");
}
#[test]
fn population_fraction_bedgraph_errors_when_haplotypes_len_mismatches_methylation() {
let reference = b"ACGT".to_vec();
let cm = ContigMethylation::from_tables(vec![
MethylationTable::with_len(reference.len()),
MethylationTable::with_len(reference.len()),
]);
let haplotypes = build_haplotypes(&[], 1, &mut rand::rng());
assert_eq!(haplotypes.len(), 1);
let mut buf = Vec::new();
let err = write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm)
.expect_err("length mismatch should error");
let msg = format!("{err}");
assert!(msg.contains("haplotypes length"), "unexpected error message: {msg}");
assert!(msg.contains("methylation haplotype count"), "unexpected error: {msg}");
}
#[test]
fn population_fraction_bedgraph_skips_cpg_destroyed_on_every_haplotype() {
let reference = b"ACGT".to_vec();
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"C".to_vec(),
alt_alleles: vec![b"T".to_vec()],
genotype: Genotype::parse("1|1").unwrap(),
}];
let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
let h0 = MethylationTable::with_len(reference.len());
let h1 = MethylationTable::with_len(reference.len());
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_bedgraph_header(&mut buf).unwrap();
write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
let s = String::from_utf8(buf).unwrap();
let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
assert!(data_lines.is_empty(), "expected no data lines, got: {s}");
}
}