Source code for finaletoolkit.frag._coverage

"""
Fragment coverage over intervals.

Coverage is estimated by counting fragments (by the configured intersect
policy) that fall in each interval, optionally scaled and/or normalized by the
genome-wide fragment count.
"""
from __future__ import annotations

import gzip
import sys
import time
from functools import partial
from multiprocessing import Pool
from pathlib import Path
from typing import NamedTuple, Union

import pysam
from tqdm import tqdm

from finaletoolkit.utils import frag_generator, get_intervals

__all__ = ["coverage", "single_coverage", "CoverageResult"]


[docs] class CoverageResult(NamedTuple): """Coverage over a single interval. A named tuple: it unpacks and indexes like ``(contig, start, stop, name, coverage)`` and also exposes named fields. Attributes ---------- contig : str or None Interval contig. start : int or None 0-based start coordinate. stop : int or None Stop coordinate. name : str Interval name. coverage : float Coverage value over the interval. """ contig: str | None start: int | None stop: int | None name: str coverage: float
[docs] def single_coverage( input_file: Union[str, pysam.TabixFile, pysam.AlignmentFile, Path], contig: str | None = None, start: int | None = 0, stop: int | None = None, name: str | None = ".", min_length: int | None = None, max_length: int | None = None, intersect_policy: str = "midpoint", quality_threshold: int = 30, verbose: bool | int = False, reference_file: str | Path | None = None, ) -> CoverageResult: """Estimate fragment coverage over a single region. Counts fragments (per ``intersect_policy``) falling in ``contig:[start, stop)``. Not suitable when fragment sizes approach the region size. Parameters ---------- input_file : str or pysam handle BAM/CRAM/fragment input. contig : str, optional Contig name. start : int, optional 0-based start (default 0). stop : int, optional 1-based stop (default: end of contig). name : str, optional Interval name (``'.'`` if ``None``). min_length, max_length : int, optional Fragment-length filter. intersect_policy : {"midpoint", "any"}, optional Region-membership policy (default ``"midpoint"``). quality_threshold : int, optional Minimum mapping quality (default 30). verbose : bool or int, optional Print timing information. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- CoverageResult ``(contig, start, stop, name, coverage)``. """ if verbose: start_time = time.time() tqdm.write( f""" input_file: {input_file} contig: {contig} start: {start} stop: {stop} name: {name} min_length: {min_length} max_length: {max_length} intersect_policy: {intersect_policy} quality_threshold: {quality_threshold} verbose: {verbose} """ ) coverage = 0 frags = frag_generator( input_file=input_file, contig=contig, quality_threshold=quality_threshold, start=start, stop=stop, min_length=min_length, max_length=max_length, intersect_policy=intersect_policy, reference_file=reference_file, ) for _ in frags: coverage += 1 if verbose: end_time = time.time() tqdm.write(f"single_coverage took {end_time - start_time} s to complete\n") adjusted_name = "." if name is None else name return CoverageResult(contig, start, stop, adjusted_name, coverage)
def _single_coverage_star(partial_coverage, interval) -> CoverageResult: contig, start, stop, name = interval return partial_coverage(contig=contig, start=start, stop=stop, name=name)
[docs] def coverage( input_file: Union[str, pysam.TabixFile, pysam.AlignmentFile, Path], interval_file: str, output_file: str, scale_factor: float = 1.0, min_length: int | None = None, max_length: int | None = None, normalize: bool = False, intersect_policy: str = "midpoint", quality_threshold: int = 30, workers: int = 1, verbose: Union[bool, int] = False, reference_file: str | Path | None = None, ) -> list[CoverageResult]: """Estimate fragment coverage over every interval in a BED file. Parameters ---------- input_file : str or pysam handle BAM/CRAM/fragment input. interval_file : str BED4 file of intervals. output_file : str Output BED/`.bedgraph`/`.bed.gz` path, or ``"-"`` for stdout. ``None`` suppresses file output and only returns results. scale_factor : float, optional Multiplier applied to coverage values (default 1.0). min_length, max_length : int, optional Fragment-length filter. normalize : bool, optional Divide ``scale_factor`` by the genome-wide coverage before scaling. intersect_policy : {"midpoint", "any"}, optional Region-membership policy (default ``"midpoint"``). quality_threshold : int, optional Minimum mapping quality (default 30). workers : int, optional Worker-process count (default 1). verbose : bool or int, optional Print timing/config information. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- list of CoverageResult Scaled coverage for each interval. """ return_val: list[CoverageResult] = [] if verbose: start_time = time.time() tqdm.write( f""" input_file: {input_file} interval file: {interval_file} output_file: {output_file} scale_factor: {scale_factor} min_length: {min_length} max_length: {max_length} intersect_policy: {intersect_policy} quality_threshold: {quality_threshold} workers: {workers} normalize: {normalize} verbose: {verbose} \n """ ) tqdm.write("Creating process pool\n") pool = Pool(processes=workers) try: # Kick off the genome-wide total coverage asynchronously if normalizing. if normalize: total_coverage_results = pool.apply_async( single_coverage, (input_file, None, 0, None, "."), { "min_length": min_length, "max_length": max_length, "intersect_policy": intersect_policy, "quality_threshold": quality_threshold, "verbose": verbose, "reference_file": reference_file, }, ) intervals = get_intervals(interval_file) if verbose: tqdm.write("calculating coverage\n") partial_single_coverage = partial( single_coverage, input_file=input_file, min_length=min_length, max_length=max_length, intersect_policy=intersect_policy, quality_threshold=quality_threshold, verbose=max(0, verbose - 1), reference_file=reference_file, ) coverages = pool.imap( partial(_single_coverage_star, partial_single_coverage), intervals, chunksize=max(len(intervals) // workers, 1), ) if normalize: total_coverage = total_coverage_results.get() if verbose: tqdm.write(f"Total coverage is {total_coverage}\n") scale_factor /= total_coverage[4] output_is_file = False if output_file is not None: if verbose: tqdm.write("Writing results to output\n") try: if output_file.endswith(".bed") or output_file.endswith(".bedgraph"): output_is_file = True output = open(output_file, "w") elif output_file.endswith(".bed.gz"): output = gzip.open(output_file, "wt") output_is_file = True elif output_file == "-": output = sys.stdout else: raise ValueError( "output_file should have .bed or .bed.gz as suffix" ) if output_file.endswith(".bedgraph"): for contig, start, stop, name, cov in coverages: output.write( f"{contig}\t{start}\t{stop}\t{cov * scale_factor}\n" ) return_val.append( CoverageResult(contig, start, stop, name, cov * scale_factor) ) else: for contig, start, stop, name, cov in coverages: output.write( f"{contig}\t{start}\t{stop}\t{name}\t" f"{cov * scale_factor}\n" ) return_val.append( CoverageResult(contig, start, stop, name, cov * scale_factor) ) finally: if output_is_file: output.close() else: return_val = [ CoverageResult(contig, start, stop, name, cov * scale_factor) for (contig, start, stop, name, cov) in coverages ] finally: pool.close() if verbose: end_time = time.time() tqdm.write(f"coverage took {end_time - start_time} s to complete\n") return return_val