Skip to main content

holodeck_lib/commands/
methylate.rs

1//! `methylate` subcommand — given a reference (and optional variant VCF),
2//! produce a methylation-annotated VCF that records per-haplotype
3//! per-strand methylation state at every CpG.
4
5use std::fs::File;
6use std::io::{BufWriter, Write as _};
7use std::path::PathBuf;
8
9use anyhow::Result;
10use clap::Parser;
11use rand::SeedableRng as _;
12
13use super::command::Command;
14use super::common::{ReferenceOptions, SeedOptions, VcfOptions};
15
16/// Generate a methylation-annotated VCF.
17///
18/// Scans every CpG dinucleotide in the reference (optionally after applying
19/// variants from a VCF) and records per-haplotype, per-strand methylation
20/// state for each site. Methylation is assigned with a context-aware Markov
21/// model: CpGs are classified into island / shore / open-sea (detected de
22/// novo from the sequence), each with its own target rate and spatial
23/// correlation length, so islands come out hypomethylated, open-sea
24/// hypermethylated, with autocorrelated runs in between. Methylation is
25/// symmetric by default with a low sporadic hemimethylation rate. Per-haplotype
26/// draws give allele-specific methylation. All draws are deterministic from
27/// `--seed`.
28#[derive(Parser, Debug)]
29#[command(after_long_help = "EXAMPLES:\n  \
30    holodeck methylate -r ref.fa -o meth.vcf.gz\n  \
31    holodeck methylate -r ref.fa -v variants.vcf -o meth.vcf.gz --seed 42\n  \
32    holodeck methylate -r ref.fa -o meth.vcf.gz --methylation-rate-open-sea 0.9")]
33pub struct Methylate {
34    #[command(flatten)]
35    pub reference: ReferenceOptions,
36
37    #[command(flatten)]
38    pub vcf: VcfOptions,
39
40    #[command(flatten)]
41    pub seed: SeedOptions,
42
43    /// Target methylation fraction for CpG-island-interior CpGs. Islands are
44    /// characteristically hypomethylated, so this defaults low. Must be in
45    /// `[0.0, 1.0]`. To methylate uniformly (no island structure), set the
46    /// three context rates equal.
47    #[arg(long, default_value_t = crate::meth::DEFAULT_ISLAND_RATE, value_name = "FLOAT")]
48    pub methylation_rate_island: f64,
49
50    /// Target methylation fraction for island-shore CpGs (within 2 kb of an
51    /// island; intermediate methylation). Must be in `[0.0, 1.0]`.
52    #[arg(long, default_value_t = crate::meth::DEFAULT_SHORE_RATE, value_name = "FLOAT")]
53    pub methylation_rate_shore: f64,
54
55    /// Target methylation fraction for open-sea CpGs (the hypermethylated bulk
56    /// of the genome, away from islands). Must be in `[0.0, 1.0]`.
57    #[arg(long, default_value_t = crate::meth::DEFAULT_OPEN_SEA_RATE, value_name = "FLOAT")]
58    pub methylation_rate_open_sea: f64,
59
60    /// Spatial correlation length (bp) for island-interior CpGs: larger values
61    /// give longer runs of like-methylated CpGs. Must be `> 0`.
62    #[arg(long, default_value_t = crate::meth::DEFAULT_CORRELATION_LENGTH_BP, value_name = "BP")]
63    pub methylation_correlation_length_island: f64,
64
65    /// Spatial correlation length (bp) for shore CpGs. Must be `> 0`.
66    #[arg(long, default_value_t = crate::meth::DEFAULT_CORRELATION_LENGTH_BP, value_name = "BP")]
67    pub methylation_correlation_length_shore: f64,
68
69    /// Spatial correlation length (bp) for open-sea CpGs. Must be `> 0`.
70    #[arg(long, default_value_t = crate::meth::DEFAULT_CORRELATION_LENGTH_BP, value_name = "BP")]
71    pub methylation_correlation_length_open_sea: f64,
72
73    /// Probability that a methylated CpG is made hemimethylated — exactly one
74    /// strand left unmethylated. Real hemimethylation is sporadic and rare, so
75    /// this defaults low. Must be in `[0.0, 1.0]`.
76    #[arg(long, default_value_t = crate::meth::DEFAULT_HEMI_RATE, value_name = "FLOAT")]
77    pub hemimethylation_rate: f64,
78
79    /// Output methylation-annotated VCF (BGZF-compressed).
80    #[arg(long, short = 'o', value_name = "PATH")]
81    pub output: PathBuf,
82
83    /// Optional MethylDackel-format population-fraction bedGraph derived
84    /// closed-form from the genome model. Independent of any read coverage.
85    #[arg(long, value_name = "PATH")]
86    pub bedgraph: Option<PathBuf>,
87}
88
89impl Methylate {
90    /// Assemble the per-context methylation model from the CLI flags.
91    fn methylation_model(&self) -> crate::meth::MethylationModel {
92        use crate::meth::{ContextParams, MethylationModel};
93        MethylationModel {
94            island: ContextParams {
95                rate: self.methylation_rate_island,
96                correlation_length_bp: self.methylation_correlation_length_island,
97            },
98            shore: ContextParams {
99                rate: self.methylation_rate_shore,
100                correlation_length_bp: self.methylation_correlation_length_shore,
101            },
102            open_sea: ContextParams {
103                rate: self.methylation_rate_open_sea,
104                correlation_length_bp: self.methylation_correlation_length_open_sea,
105            },
106            hemi_rate: self.hemimethylation_rate,
107        }
108    }
109}
110
111impl Command for Methylate {
112    fn execute(&self) -> Result<()> {
113        use noodles_bgzf as bgzf;
114
115        use crate::fasta::Fasta;
116        use crate::haplotype::build_haplotypes;
117        use crate::meth::ContigMethylation;
118        use crate::seed::{derive_seed, resolve_seed};
119        use crate::vcf::methylation::write_contig;
120        use crate::vcf::methylation::write_vcf_header;
121        use crate::version::VERSION;
122
123        // 1. Build and validate the methylation model from the per-context flags.
124        let model = self.methylation_model();
125        model.validate()?;
126
127        // 2. Resolve seed deterministically from args if not explicit. Fold
128        //    every model parameter into the description so default-seed runs
129        //    with different parameters get distinct streams.
130        let seed_desc = format!(
131            "{}:methylate:{}:{}:{}:{}:{}:{}:{}",
132            self.reference.reference.display(),
133            self.methylation_rate_island,
134            self.methylation_rate_shore,
135            self.methylation_rate_open_sea,
136            self.methylation_correlation_length_island,
137            self.methylation_correlation_length_shore,
138            self.methylation_correlation_length_open_sea,
139            self.hemimethylation_rate,
140        );
141        let seed = resolve_seed(self.seed.seed, &seed_desc);
142        log::info!("Using random seed: {seed}");
143
144        // 3. Open the reference and collect contig names.
145        let mut fasta = Fasta::from_path(&self.reference.reference)?;
146        let dict = fasta.dict().clone();
147        let contig_names: Vec<String> = dict.names().into_iter().map(String::from).collect();
148
149        // 4. Resolve the sample for any VCF, before per-contig variant loading.
150        // Reject --sample without --vcf so a typo or missing flag fails fast
151        // instead of running a reference-only job with the input silently
152        // ignored. Mirrors the simulate guard.
153        if self.vcf.sample.is_some() && self.vcf.vcf.is_none() {
154            anyhow::bail!("--sample requires --vcf");
155        }
156        let resolved_sample = if let Some(vcf_path) = &self.vcf.vcf {
157            Some(crate::vcf::validate_vcf_sample(vcf_path, self.vcf.sample.as_deref())?)
158        } else {
159            None
160        };
161
162        // 5. Open the output VCF, BGZF-compressed.
163        let file = File::create(&self.output)?;
164        let mut bgzf = bgzf::io::Writer::new(file);
165
166        // 6. Write the VCF header.
167        let cmd_line = capture_command_line();
168        write_vcf_header(&mut bgzf, &dict, resolved_sample.as_deref(), &VERSION, &cmd_line)?;
169
170        // 6b. Open the bedGraph output file and write the track header, if requested.
171        let mut bedgraph_writer: Option<BufWriter<File>> =
172            if let Some(bedgraph_path) = &self.bedgraph {
173                let bg_file = File::create(bedgraph_path)?;
174                let mut w = BufWriter::new(bg_file);
175                crate::output::methylation_bedgraph::write_bedgraph_header(&mut w)?;
176                Some(w)
177            } else {
178                None
179            };
180
181        // 6c. Parse the entire VCF once, partitioning variants by contig, so
182        // the per-contig loop below performs O(1) lookups instead of
183        // re-reading the full VCF per contig (O(contigs × records)). The scan
184        // also resolves the sample's ploidy from unfiltered GTs and reuses it
185        // everywhere — variant-bearing contigs and variant-free ones alike.
186        // A per-contig `unwrap_or(2)` would give haploid / triploid samples
187        // the wrong MT/MB shape on contigs with no ALT calls.
188        let parsed_variants = if let Some(vcf_path) = &self.vcf.vcf {
189            Some(crate::vcf::parse_variants_by_contig(vcf_path, resolved_sample.as_deref(), &dict)?)
190        } else {
191            None
192        };
193        let sample_ploidy = parsed_variants.as_ref().map_or(2, |p| p.sample_ploidy);
194
195        // 7. For each contig, build haplotypes, compute methylation, emit rows.
196        for contig_name in &contig_names {
197            // Per-contig deterministic seed for reference normalization.
198            // Use the bare contig name (no prefix) to match `simulate`'s
199            // `derive_seed(main_seed, contig_name)` at simulate.rs line ~578,
200            // so both commands resolve IUPAC codes identically for the same
201            // `--seed` and reference.
202            let ref_seed = derive_seed(seed, contig_name);
203            let mut ref_rng = rand::rngs::SmallRng::seed_from_u64(ref_seed);
204            let reference = fasta.load_contig(contig_name, &mut ref_rng)?;
205
206            // Look this contig's variants up from the once-parsed map. An
207            // absent key or `None` map (no VCF) both yield an empty slice.
208            let variants: &[crate::vcf::genotype::VariantRecord] = parsed_variants
209                .as_ref()
210                .and_then(|p| p.by_contig.get(contig_name))
211                .map_or(&[], Vec::as_slice);
212
213            // Build haplotypes with their own deterministic sub-seed, sized
214            // by the whole-VCF `sample_ploidy` so variant-free contigs land
215            // the same number of haplotypes as variant-bearing ones.
216            let hap_seed = derive_seed(seed, &format!("haps@{contig_name}"));
217            let mut hap_rng = rand::rngs::SmallRng::seed_from_u64(hap_seed);
218            let haplotypes = build_haplotypes(variants, sample_ploidy, &mut hap_rng);
219
220            // Draw per-haplotype methylation with another deterministic sub-seed.
221            let meth_seed = derive_seed(seed, &format!("meth@{contig_name}"));
222            let mut meth_rng = rand::rngs::SmallRng::seed_from_u64(meth_seed);
223            let methylation =
224                ContigMethylation::from_haplotypes(&haplotypes, &reference, &model, &mut meth_rng);
225
226            write_contig(
227                &mut bgzf,
228                contig_name,
229                &reference,
230                variants,
231                &methylation,
232                sample_ploidy,
233            )?;
234
235            // Append per-contig bedGraph records, if a bedGraph was requested.
236            // Pass `&haplotypes` so the writer can map every reference CpG
237            // through each haplotype's local coordinate system before reading
238            // the methylation bitmap; variant-shifted and variant-destroyed
239            // CpGs would otherwise be miscounted.
240            if let Some(bg) = bedgraph_writer.as_mut() {
241                crate::output::methylation_bedgraph::write_bedgraph_records(
242                    bg,
243                    contig_name,
244                    &reference,
245                    &haplotypes,
246                    &methylation,
247                )?;
248            }
249        }
250
251        // 8. Flush and close the VCF.
252        bgzf.flush()?;
253        drop(bgzf);
254
255        // 8b. Flush and close the bedGraph, if open.
256        if let Some(mut bg) = bedgraph_writer {
257            bg.flush()?;
258            if let Some(path) = &self.bedgraph {
259                log::info!("Wrote population-fraction bedGraph to {}", path.display());
260            }
261        }
262
263        log::info!("Wrote methylation VCF to {}", self.output.display());
264        Ok(())
265    }
266}
267
268/// Capture the current process's command-line arguments as a space-joined
269/// string, using lossy UTF-8 for non-Unicode arguments.
270fn capture_command_line() -> String {
271    std::env::args_os().map(|arg| arg.to_string_lossy().into_owned()).collect::<Vec<_>>().join(" ")
272}