Source code for finaletoolkit.utils.utils

"""
Core fragment/genomic utilities: chrom-size parsing, fragment arrays, quality
filtering, interval parsing, vectorized overlaps, and k-mer helpers.
"""
from __future__ import annotations

import gzip
import itertools
import time
from pathlib import Path
from sys import stderr

import numpy as np
import pysam
from numba import jit
from numpy.typing import NDArray

from ..io.alignment import AlignmentWrapper
from .logging import get_logger
from .typing import ChromSizes, FragFile, Intervals
from ._frag_generator import frag_generator
from ._intervals import (
    _convert_to_list,
    _merge_all_intervals,
    _merge_overlapping_intervals,
    _reduce_overlaps_in_file,
)

logger = get_logger(__name__)

__all__ = [
    "chrom_sizes_to_list",
    "chrom_sizes_to_dict",
    "frag_bam_to_bed",
    "frags_in_region",
    "frag_array",
    "low_quality_read_pairs",
    "_not_read1_or_low_quality",
    "get_intervals",
    "overlaps",
    "gen_kmers",
    "reverse_complement",
    "_merge_overlapping_intervals",
    "_reduce_overlaps_in_file",
    "_convert_to_list",
    "_merge_all_intervals",
]


# -- chrom.sizes parsing ----------------------------------------------------


[docs] def chrom_sizes_to_list(chrom_sizes_file: ChromSizes) -> list[tuple[str, int]]: """Read a ``.chrom.sizes`` file into a list of ``(name, size)`` tuples. Parameters ---------- chrom_sizes_file : str or Path Tab-delimited file with contig name and size columns. Returns ------- list of (str, int) Contig names and sizes, in file order. """ chrom_sizes: list[tuple[str, int]] = [] with open(chrom_sizes_file, "r") as file: for line in file: if line != "\n": chrom, size = line.strip().split("\t") chrom_sizes.append((chrom, int(size))) return chrom_sizes
[docs] def chrom_sizes_to_dict(chrom_sizes_file: ChromSizes) -> dict[str, int]: """Read a ``.chrom.sizes`` file into a ``{name: size}`` dict. Parameters ---------- chrom_sizes_file : str or Path Tab-delimited file with contig name and size columns. Returns ------- dict Maps contig name to integer size. """ chrom_sizes: dict[str, int] = {} with open(chrom_sizes_file, "r") as file: for line in file: if line != "\n": chrom, size = line.strip().split("\t") chrom_sizes[chrom] = int(size) return chrom_sizes
# -- fragment export -------------------------------------------------------- def frag_bam_to_bed( input_file: str | pysam.AlignmentFile, output_file: str, contig: str | None = None, quality_threshold: int = 30, verbose: bool = False, reference_file: str | Path | None = None, ) -> None: """Write paired-end fragments from a BAM/CRAM file to a 3-column BED. Parameters ---------- input_file : str or pysam.AlignmentFile Input alignment file. output_file : str Output BED path (gzip-compressed when it ends with ``.gz``). contig : str, optional Restrict export to a single contig. quality_threshold : int, optional Minimum mapping quality (default 30). verbose : bool, optional Print timing information. reference_file : str or Path, optional Reference genome (required for CRAM). """ if verbose: start_time = time.time() print("Opening file") out = None try: if output_file.endswith(".gz"): out = gzip.open(output_file, "wt") else: out = open(output_file, "w") with AlignmentWrapper( input_file, reference_file=reference_file, quality_threshold=quality_threshold, ) as wrapper: for frag in wrapper.fetch(contig=contig): out.write(f"{frag.contig}\t{frag.start}\t{frag.stop}\n") except Exception as e: logger.error("An error occurred during BAM to BED conversion: %s", str(e)) finally: if out is not None: out.close() if verbose: end_time = time.time() print( f"frag_bam_to_bed took {end_time - start_time} s to complete", flush=True, ) # -- fragment arrays -------------------------------------------------------- @jit(nopython=True) def frags_in_region(frag_array: NDArray, start: int, stop: int) -> NDArray: """Return fragments overlapping ``[start, stop)`` (numba-accelerated). A fragment is kept when ``frag.start < stop`` and ``frag.stop >= start``. Parameters ---------- frag_array : ndarray Structured array with ``'start'`` and ``'stop'`` fields. start : int Left-most coordinate of the region. stop : int Right-most coordinate of the region. Returns ------- ndarray The subset of ``frag_array`` overlapping the region. """ starts = frag_array["start"] stops = frag_array["stop"] in_region = np.logical_and(np.less(starts, stop), np.greater_equal(stops, start)) return frag_array[in_region]
[docs] def frag_array( input_file: FragFile, contig: str, quality_threshold: int = 30, start: int | None = None, stop: int | None = None, min_length: int | None = None, max_length: int | None = None, intersect_policy: str = "midpoint", verbose: bool = False, reference_file: str | Path | None = None, ) -> NDArray: """Read fragments into a structured ``(start, stop, strand)`` array. Parameters ---------- input_file : str or pysam handle BAM/CRAM/fragment input. contig : str Contig to read. quality_threshold : int, optional Minimum mapping quality (default 30). start, stop : int, optional Region bounds. min_length, max_length : int, optional Inclusive fragment-length bounds. intersect_policy : {"midpoint", "any"}, optional Region-membership policy (default ``"midpoint"``). verbose : bool, optional Print progress to stderr. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- ndarray 1-D structured array with dtype ``[('start', 'i8'), ('stop', 'i8'), ('strand', '?')]``. ``strand`` is ``True`` on the ``+`` strand. Shape ``(0,)`` if no fragments match. """ if verbose: stderr.write("frag_array: fetching fragments\n") frag_list = [ (frag_start, frag_stop, strand) for _, frag_start, frag_stop, _, strand in frag_generator( input_file, contig, quality_threshold, start, stop, min_length, max_length, intersect_policy, verbose, reference_file=reference_file, ) ] if verbose: stderr.write("frag_array: converting to array\n") frag_ends = np.array( frag_list, dtype=[("start", "i8"), ("stop", "i8"), ("strand", "?")] ) assert frag_ends.ndim == 1, ( f"frag_ends has dims {frag_ends.ndim} and shape {frag_ends.shape}" ) return frag_ends
# -- read-level quality filtering -------------------------------------------
[docs] def low_quality_read_pairs(read: pysam.AlignedSegment, min_mapq: int = 30) -> bool: """Return ``True`` if ``read`` is not a clean, properly-paired alignment. Equivalent to samtools ``-F 3852 -f 3`` plus a ``-G 48`` style same-strand check and an optional mate mapping-quality (``MQ`` tag) check. Adapted from cofragr's ``frag_summary_in_intervals.py``. Parameters ---------- read : pysam.AlignedSegment The read to inspect. min_mapq : int, optional Minimum Phred mapping quality (default 30). Returns ------- bool ``True`` if the read is low quality, unmapped, or improperly paired. """ if ( read.is_unmapped # 0x4 or read.is_secondary # 0x100 or (not read.is_paired) # not 0x1 or read.mate_is_unmapped # 0x8 or read.is_duplicate # 0x400 or read.mapping_quality < min_mapq or read.is_qcfail # 0x200 or read.is_supplementary # 0x800 or (not read.is_proper_pair) # not 0x2 or (read.is_reverse and read.mate_is_reverse) # -G 48 ): return True try: if read.has_tag("MQ") and read.get_tag("MQ") < min_mapq: return True except Exception: pass return False
def _not_read1_or_low_quality(read: pysam.AlignedSegment, min_mapq: int = 30) -> bool: """Return ``True`` if ``read`` is not read1 or fails the quality filter.""" return low_quality_read_pairs(read, min_mapq=min_mapq) or not read.is_read1 # -- interval / overlap helpers --------------------------------------------- def get_intervals(interval_file: Intervals) -> list[tuple[str, int, int, str]]: """Read intervals from a BED file. ``track``/``browser``/comment/blank lines and lines with fewer than three columns are skipped. A missing name column defaults to ``'.'``. Parameters ---------- interval_file : str or Path Path to the BED file. Returns ------- list of (str, int, int, str) ``(contig, start, stop, name)`` tuples. """ intervals: list[tuple[str, int, int, str]] = [] with open(interval_file, "r") as bed: for line in bed: if line.startswith(("#", "track", "browser")) or not line.strip(): continue parts = line.strip().split("\t") if len(parts) < 3: continue contig = parts[0] start = int(parts[1]) stop = int(parts[2]) name = parts[3] if len(parts) > 3 else "." intervals.append((contig, start, stop, name)) return intervals
[docs] def overlaps( contigs_1: NDArray, starts_1: NDArray, stops_1: NDArray, contigs_2: NDArray, starts_2: NDArray, stops_2: NDArray, ) -> NDArray: """Vectorized "does interval-set 1 overlap any interval in set 2?". Parameters ---------- contigs_1, starts_1, stops_1 : ndarray Parallel arrays describing the query intervals. contigs_2, starts_2, stops_2 : ndarray Parallel arrays describing the reference intervals. Returns ------- ndarray of bool Same shape as ``contigs_1``; ``True`` where that query interval overlaps at least one interval in set 2 (same contig required). """ contigs_1 = contigs_1[:, np.newaxis] starts_1 = starts_1[:, np.newaxis] stops_1 = stops_1[:, np.newaxis] contigs_2 = contigs_2[np.newaxis] starts_2 = starts_2[np.newaxis] stops_2 = stops_2[np.newaxis] contig_blind_overlaps = np.logical_and( (starts_1 < stops_2), (stops_1 > starts_2) ) in_same_contig = contigs_1 == contigs_2 raw_overlaps = np.logical_and(contig_blind_overlaps, in_same_contig) return np.any(raw_overlaps, axis=1)
# -- k-mer helpers ---------------------------------------------------------- def gen_kmers(k: int, bases: str = "ACGT") -> list[str]: """Generate every length-``k`` k-mer over ``bases``. Parameters ---------- k : int K-mer length (must be non-negative). bases : str, optional Alphabet, by default ``'ACGT'``. Returns ------- list of str All ``len(bases) ** k`` k-mers, in lexicographic order. Raises ------ ValueError If ``k`` is negative. """ if k < 0: raise ValueError("k must be non-negative") return ["".join(p) for p in itertools.product(bases, repeat=k)] @jit(nopython=True) def _reverse_complement_numba(kmer_bytes: NDArray[np.uint8]) -> NDArray[np.uint8]: """Numba-accelerated reverse complement operating on ASCII bytes.""" n = len(kmer_bytes) res = np.empty(n, dtype=np.uint8) for i in range(n): b = kmer_bytes[n - 1 - i] if b == 65 or b == 97: # A / a res[i] = 84 # T elif b == 84 or b == 116: # T / t res[i] = 65 # A elif b == 67 or b == 99: # C / c res[i] = 71 # G elif b == 71 or b == 103: # G / g res[i] = 67 # C else: res[i] = b # keep (e.g. N) return res def reverse_complement(kmer: str) -> str: """Return the reverse complement of a DNA string (``N`` preserved).""" kmer_bytes = np.frombuffer(kmer.encode("ascii"), dtype=np.uint8) rc_bytes = _reverse_complement_numba(kmer_bytes) return rc_bytes.tobytes().decode("ascii")