|
| 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 | + ) |
0 commit comments