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}