Skip to content

Commit 9cd6ef2

Browse files
authored
Methurator: New module (MultiQC#3447)
1 parent 4374f02 commit 9cd6ef2

4 files changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .methurator import MultiqcModule
2+
3+
__all__ = ["MultiqcModule"]
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import logging
2+
from typing import Dict, Any
3+
4+
import yaml
5+
6+
from multiqc.base_module import BaseMultiqcModule, ModuleNoSamplesFound
7+
from multiqc.plots import linegraph
8+
from multiqc.plots.linegraph import LinePlotConfig
9+
10+
log = logging.getLogger(__name__)
11+
12+
13+
def _make_alert(message: str, level: str = "info") -> str:
14+
"""Create an HTML alert div for empty state messages."""
15+
return f'<div class="alert alert-{level}"><strong>{message}</strong></div>'
16+
17+
18+
class MultiqcModule(BaseMultiqcModule):
19+
"""
20+
Methurator is a Python package designed to estimate sequencing saturation for
21+
reduced-representation bisulfite sequencing (RRBS) data.
22+
23+
The module parses the `methurator_summary.yml` file generated by the `methurator downsample`
24+
command. This file contains:
25+
26+
- **Reads summary**: Read counts at each downsampling percentage
27+
- **CpGs summary**: CpG site counts at different coverage thresholds
28+
- **Saturation analysis**: Model fit results including asymptote and saturation estimates
29+
30+
The module displays key metrics in the General Statistics table and creates saturation
31+
curve plots showing how CpG detection changes with sequencing depth.
32+
"""
33+
34+
def __init__(self):
35+
super(MultiqcModule, self).__init__(
36+
name="Methurator",
37+
anchor="methurator",
38+
href="https://github.com/VIBTOBIlab/methurator",
39+
info="Estimates sequencing saturation for reduced-representation bisulfite sequencing (RRBS) data.",
40+
# doi="", # No DOI available
41+
)
42+
43+
# Store parsed data
44+
self.methurator_data: Dict[str, Dict[str, Any]] = {}
45+
46+
# Find and parse methurator summary files
47+
for f in self.find_log_files("methurator"):
48+
try:
49+
parsed = yaml.load(f["f"], Loader=yaml.SafeLoader)
50+
except Exception as e:
51+
log.warning(f"Could not parse methurator YAML file '{f['fn']}': {e}")
52+
continue
53+
54+
# Extract sample data from the parsed YAML
55+
samples_data = self._extract_sample_data(parsed)
56+
if not samples_data:
57+
log.warning(f"No sample data found in {f['fn']}")
58+
continue
59+
60+
for sample_name, sample_data in samples_data.items():
61+
s_name = self.clean_s_name(sample_name, f)
62+
if s_name in self.methurator_data:
63+
log.debug(f"Duplicate sample name found! Overwriting: {s_name}")
64+
self.methurator_data[s_name] = sample_data
65+
self.add_data_source(f, s_name=s_name)
66+
67+
# Add software version if available
68+
version = parsed.get("methurator_summary", {}).get("metadata", {}).get("methurator_version")
69+
self.add_software_version(version, s_name)
70+
71+
# Filter ignored samples
72+
self.methurator_data = self.ignore_samples(self.methurator_data)
73+
74+
# Raise exception if no data found
75+
if not self.methurator_data:
76+
raise ModuleNoSamplesFound
77+
78+
log.info(f"Found {len(self.methurator_data)} samples")
79+
80+
# Add general stats
81+
self._add_general_stats()
82+
83+
# Add saturation curve plot (CpGs vs reads)
84+
self._add_saturation_plot()
85+
86+
# Add saturation percentage plot (% saturation vs reads)
87+
self._add_saturation_pct_plot()
88+
89+
# Write data file (must be last)
90+
self.write_data_file(self.methurator_data, "methurator")
91+
92+
def _extract_sample_data(self, parsed: Dict) -> Dict[str, Dict[str, Any]]:
93+
"""Extract per-sample data from the parsed YAML structure."""
94+
samples_data: Dict[str, Dict[str, Any]] = {}
95+
96+
summary = parsed.get("methurator_summary", {})
97+
reads_summary = summary.get("reads_summary", [])
98+
cpgs_summary = summary.get("cpgs_summary", [])
99+
saturation_analysis = summary.get("saturation_analysis", [])
100+
101+
# Build a mapping of sample names to their data
102+
# The structure is: [{sample_name: [[pct, value], ...]}, ...]
103+
104+
# Parse reads summary
105+
reads_by_sample: Dict[str, list] = {}
106+
for item in reads_summary:
107+
if isinstance(item, dict):
108+
for sample_name, data in item.items():
109+
reads_by_sample[sample_name] = data
110+
111+
# Parse CpGs summary
112+
cpgs_by_sample: Dict[str, Dict[int, list]] = {}
113+
for item in cpgs_summary:
114+
if isinstance(item, dict):
115+
for sample_name, coverage_data in item.items():
116+
cpgs_by_sample[sample_name] = {}
117+
if isinstance(coverage_data, list):
118+
for cov_entry in coverage_data:
119+
if isinstance(cov_entry, dict):
120+
min_cov = cov_entry.get("minimum_coverage")
121+
data = cov_entry.get("data", [])
122+
if min_cov is not None:
123+
cpgs_by_sample[sample_name][min_cov] = data
124+
125+
# Parse saturation analysis
126+
saturation_by_sample: Dict[str, Dict[int, Dict[str, Any]]] = {}
127+
for item in saturation_analysis:
128+
if isinstance(item, dict):
129+
for sample_name, sat_data in item.items():
130+
saturation_by_sample[sample_name] = {}
131+
if isinstance(sat_data, list):
132+
for sat_entry in sat_data:
133+
if isinstance(sat_entry, dict):
134+
min_cov = sat_entry.get("minimum_coverage")
135+
if min_cov is not None:
136+
saturation_by_sample[sample_name][min_cov] = sat_entry
137+
138+
# Combine all data for each sample
139+
all_samples = set(reads_by_sample.keys()) | set(cpgs_by_sample.keys()) | set(saturation_by_sample.keys())
140+
141+
for sample_name in all_samples:
142+
sample_data: Dict[str, Any] = {
143+
"reads": reads_by_sample.get(sample_name, []),
144+
"cpgs": cpgs_by_sample.get(sample_name, {}),
145+
"saturation_analysis": saturation_by_sample.get(sample_name, {}),
146+
}
147+
148+
# Extract key metrics for general stats (use minimum_coverage=1 as default)
149+
# Get total reads at 100% (pct=1.0)
150+
for pct, reads in sample_data["reads"]:
151+
if pct == 1.0:
152+
sample_data["total_reads"] = reads
153+
break
154+
155+
# Get CpGs and saturation metrics for the lowest minimum coverage
156+
if sample_data["cpgs"]:
157+
min_coverage = min(sample_data["cpgs"].keys())
158+
sample_data["min_coverage_used"] = min_coverage
159+
160+
# Get total CpGs at 100%
161+
for pct, cpgs in sample_data["cpgs"].get(min_coverage, []):
162+
if pct == 1.0:
163+
sample_data["total_cpgs"] = cpgs
164+
break
165+
166+
# Get saturation metrics
167+
sat_data = sample_data["saturation_analysis"].get(min_coverage, {})
168+
if sat_data.get("fit_success"):
169+
sample_data["asymptote"] = sat_data.get("asymptote")
170+
sample_data["saturation_pct"] = sat_data.get("saturation")
171+
sample_data["fit_success"] = True
172+
else:
173+
sample_data["fit_success"] = False
174+
sample_data["fit_error"] = sat_data.get("fit_error")
175+
176+
samples_data[sample_name] = sample_data
177+
178+
return samples_data
179+
180+
def _add_general_stats(self):
181+
"""Add columns to the general statistics table."""
182+
general_stats_data: Dict[str, Dict[str, Any]] = {}
183+
184+
for s_name, data in self.methurator_data.items():
185+
general_stats_data[s_name] = {
186+
"total_reads": data.get("total_reads"),
187+
"total_cpgs": data.get("total_cpgs"),
188+
"asymptote": data.get("asymptote"),
189+
"saturation_pct": data.get("saturation_pct"),
190+
}
191+
192+
headers = {
193+
"saturation_pct": {
194+
"title": "Saturation",
195+
"description": "CpG sequencing saturation (percentage of theoretical maximum CpGs detected)",
196+
"suffix": "%",
197+
"max": 100,
198+
"min": 0,
199+
"scale": "RdYlGn",
200+
},
201+
"asymptote": {
202+
"title": "Asymptote",
203+
"description": "Theoretical maximum number of CpG sites (model estimate)",
204+
"format": "{:,.0f}",
205+
"scale": "BuPu",
206+
},
207+
"total_cpgs": {
208+
"title": "CpGs",
209+
"description": "Total number of CpG sites detected at full sequencing depth",
210+
"format": "{:,.0f}",
211+
"scale": "Greens",
212+
},
213+
"total_reads": {
214+
"title": "Reads",
215+
"description": "Total number of reads in the BAM file",
216+
"format": "{:,.0f}",
217+
"scale": "Blues",
218+
"hidden": True,
219+
},
220+
}
221+
222+
self.general_stats_addcols(general_stats_data, headers)
223+
224+
def _add_saturation_plot(self):
225+
"""Add the CpG saturation curve plot showing CpGs vs number of reads."""
226+
# Collect plot data for each sample, one dataset per minimum coverage level
227+
plot_data_by_coverage: Dict[int, Dict[str, Dict[float, float]]] = {}
228+
229+
for s_name, data in self.methurator_data.items():
230+
saturation_analysis = data.get("saturation_analysis", {})
231+
# Build a lookup from percentage to reads count
232+
reads_data = data.get("reads", [])
233+
pct_to_reads = {pct: reads for pct, reads in reads_data}
234+
235+
for min_cov, sat_data in saturation_analysis.items():
236+
if min_cov not in plot_data_by_coverage:
237+
plot_data_by_coverage[min_cov] = {}
238+
239+
# Extract data points: [downsampling_pct, cpgs, saturation_pct, is_extrapolated]
240+
sat_points = sat_data.get("data", [])
241+
sample_curve: Dict[float, float] = {}
242+
for point in sat_points:
243+
if len(point) >= 2:
244+
pct = point[0]
245+
cpgs = point[1]
246+
# Map percentage to reads count if available
247+
if pct in pct_to_reads:
248+
sample_curve[float(pct_to_reads[pct])] = float(cpgs)
249+
250+
if sample_curve:
251+
plot_data_by_coverage[min_cov][s_name] = sample_curve
252+
253+
# Filter out coverage levels with no valid data and sort
254+
coverage_levels = sorted([cov for cov, data in plot_data_by_coverage.items() if data])
255+
256+
if not coverage_levels:
257+
self.add_section(
258+
name="CpG Saturation Curve",
259+
anchor="methurator_saturation",
260+
description="Saturation curves showing the number of CpG sites detected at each sequencing depth.",
261+
content=_make_alert(
262+
"No CpG saturation data available. "
263+
"No valid saturation analysis data was found for any coverage level."
264+
),
265+
)
266+
return
267+
268+
# Build datasets and labels (works for single or multiple coverage levels)
269+
datasets = [plot_data_by_coverage[cov] for cov in coverage_levels]
270+
data_labels = [
271+
{
272+
"name": f"Min Coverage {cov}x",
273+
"ylab": "Number of CpGs",
274+
"title": f"Methurator: CpG Saturation Curve (min. coverage {cov}x)",
275+
}
276+
for cov in coverage_levels
277+
]
278+
279+
pconfig = LinePlotConfig(
280+
id="methurator_saturation_plot",
281+
title=f"Methurator: CpG Saturation Curve (min. coverage {coverage_levels[0]}x)",
282+
xlab="Number of Reads",
283+
ylab="Number of CpGs",
284+
xmin=0,
285+
ymin=0,
286+
tt_label="<b>{point.x:,.0f}</b> reads: {point.y:,.0f} CpGs",
287+
data_labels=data_labels,
288+
)
289+
290+
self.add_section(
291+
name="CpG Saturation Curve",
292+
anchor="methurator_saturation",
293+
description="Saturation curves showing the number of CpG sites detected at each sequencing depth.",
294+
helptext="A flattening curve indicates saturation, where additional sequencing yields diminishing returns.",
295+
plot=linegraph.plot(datasets, pconfig),
296+
)
297+
298+
def _add_saturation_pct_plot(self):
299+
"""Add a plot showing saturation percentage vs percentage of reads."""
300+
# Collect plot data for each sample, one dataset per minimum coverage level
301+
plot_data_by_coverage: Dict[int, Dict[str, Dict[float, float]]] = {}
302+
303+
for s_name, data in self.methurator_data.items():
304+
saturation_analysis = data.get("saturation_analysis", {})
305+
for min_cov, sat_data in saturation_analysis.items():
306+
if min_cov not in plot_data_by_coverage:
307+
plot_data_by_coverage[min_cov] = {}
308+
309+
# Extract data points: [downsampling_pct, cpgs, saturation_pct, is_extrapolated]
310+
sat_points = sat_data.get("data", [])
311+
sample_curve: Dict[float, float] = {}
312+
for point in sat_points:
313+
if len(point) >= 3 and point[2] is not None:
314+
sample_curve[point[0] * 100] = float(point[2])
315+
316+
if sample_curve:
317+
plot_data_by_coverage[min_cov][s_name] = sample_curve
318+
319+
# Filter out coverage levels with no valid data and sort
320+
coverage_levels = sorted([cov for cov, data in plot_data_by_coverage.items() if data])
321+
322+
if not coverage_levels:
323+
self.add_section(
324+
name="Saturation Percentage",
325+
anchor="methurator_saturation_pct",
326+
description="Saturation percentage curves showing the fraction of theoretical maximum "
327+
"CpG sites detected at each sequencing depth.",
328+
content=_make_alert(
329+
"No saturation percentage data available. "
330+
"The saturation model failed to fit for all coverage levels, "
331+
"so saturation percentages could not be calculated. "
332+
"This typically occurs when there are too few CpG sites detected."
333+
),
334+
)
335+
return
336+
337+
# Build datasets and labels (works for single or multiple coverage levels)
338+
datasets = [plot_data_by_coverage[cov] for cov in coverage_levels]
339+
data_labels = [
340+
{
341+
"name": f"Min Coverage {cov}x",
342+
"ylab": "CpG Saturation (%)",
343+
"title": f"Methurator: Saturation Percentage (min. coverage {cov}x)",
344+
}
345+
for cov in coverage_levels
346+
]
347+
348+
pconfig = LinePlotConfig(
349+
id="methurator_saturation_pct_plot",
350+
title=f"Methurator: Saturation Percentage (min. coverage {coverage_levels[0]}x)",
351+
xlab="% Sequenced Reads",
352+
ylab="CpG Saturation (%)",
353+
xmin=0,
354+
ymin=0,
355+
ymax=100,
356+
tt_label="<b>{point.x:.0f}%</b> depth: {point.y:.1f}% saturation",
357+
data_labels=data_labels,
358+
)
359+
360+
self.add_section(
361+
name="Saturation Percentage",
362+
anchor="methurator_saturation_pct",
363+
description="Saturation percentage curves showing the fraction of theoretical maximum "
364+
"CpG sites detected at each sequencing depth. Only coverage levels with successful model fits are shown.",
365+
helptext="100% on the x-axis represents the actual sequencing depth, with extrapolation beyond. "
366+
"100% saturation on the y-axis would mean all detectable CpG sites have been found.",
367+
plot=linegraph.plot(datasets, pconfig),
368+
)

multiqc/search_patterns.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,8 @@ megahit:
572572
metaphlan:
573573
fn: "*.txt"
574574
contents: "#clade_name\tNCBI_tax_id\trelative_abundance\t"
575+
methurator:
576+
fn: "*methurator_summary.yml"
575577
methylqa:
576578
fn: "*.report"
577579
shared: true

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ malt = "multiqc.modules.malt:MultiqcModule"
189189
mapdamage = "multiqc.modules.mapdamage:MultiqcModule"
190190
megahit = "multiqc.modules.megahit:MultiqcModule"
191191
metaphlan = "multiqc.modules.metaphlan:MultiqcModule"
192+
methurator = "multiqc.modules.methurator:MultiqcModule"
192193
methylqa = "multiqc.modules.methylqa:MultiqcModule"
193194
mgikit = "multiqc.modules.mgikit:MultiqcModule"
194195
minionqc = "multiqc.modules.minionqc:MultiqcModule"

0 commit comments

Comments
 (0)