Source code for finaletoolkit.frag._breakpoint_motifs

"""
Breakpoint-motif features: k-mers centered on fragment start/stop breakpoints,
genome-wide and interval-stratified, plus motif diversity score.
"""
from __future__ import annotations

import warnings
from pathlib import Path
from sys import stderr, stdout
from time import time
from typing import Iterable

import numpy as np
from numpy.typing import NDArray

from finaletoolkit.io.alignment import AlignmentWrapper
from finaletoolkit.io.reference import ReferenceWrapper
from finaletoolkit.utils import gen_kmers, reverse_complement

from ._motif_common import (
    _genome_window_args,
    _MotifFreqs,
    _MotifsIntervals,
    aggregate_genome_motifs,
    aggregate_interval_motifs,
    resolve_motif_aliases,
    write_motif_freqs,
)

__all__ = [
    "BreakpointMotifFreqs",
    "BreakpointMotifsIntervals",
    "region_breakpoint_motifs",
    "breakpoint_motifs",
    "interval_breakpoint_motifs",
]


[docs] class BreakpointMotifFreqs(_MotifFreqs): """Genome-wide breakpoint-motif k-mer frequencies.""" def __init__(self, kmer_frequencies, k, quality_threshold: int = 30) -> None: super().__init__(kmer_frequencies, k, quality_threshold)
[docs] class BreakpointMotifsIntervals(_MotifsIntervals): """Interval-stratified breakpoint-motif k-mer counts.""" def __init__(self, intervals, k, quality_threshold: int = 30) -> None: super().__init__(intervals, k, quality_threshold)
[docs] def region_breakpoint_motifs( input_file: str, contig: str, start: int, stop: int, refseq_file: str | Path, k: int = 6, fraction_low: int = 10, fraction_high: int = 600, both_strands: bool = True, negative_strand: bool = False, output_file: str | None = None, quality_threshold: int = 30, verbose: bool | int = False, ) -> dict: """Count breakpoint-motif k-mers for fragments in a region. Unlike end motifs, the k-mer is read symmetrically around each breakpoint (``k//2`` bp on each side of the fragment start/stop). Parameters ---------- input_file : str BAM/CRAM/fragment input. contig : str Contig name. start : int 0-based start coordinate. stop : int 1-based stop coordinate. refseq_file : str or Path Reference the input was aligned to. k : int, optional Breakpoint-motif k-mer length (default 6). fraction_low, fraction_high : int, optional Minimum/maximum fragment length (defaults 10/600). both_strands : bool, optional Use both breakpoints; if ``False`` only the forward one is used unless ``negative_strand`` is set. negative_strand : bool, optional With ``both_strands=False``, use only the negative-strand breakpoint. output_file : str, optional Ignored (kept for signature compatibility). quality_threshold : int, optional Minimum mapping quality (default 30). verbose : bool or int, optional Print timing. Returns ------- dict Mapping of k-mer to count (all ``4**k`` k-mers present). """ if verbose: start_time = time() if both_strands and negative_strand: raise ValueError("Cannot have both both_strands and negative_strand.") half_k = k // 2 with AlignmentWrapper( input_file, reference_file=refseq_file, quality_threshold=quality_threshold, ) as wrapper: frag_ends = wrapper.fetch(contig, start, stop) kmer_list = gen_kmers(k, "ACGT") breakpoint_motif_counts = dict(zip(kmer_list, 4**k * [0])) with ReferenceWrapper(str(refseq_file), use_lock=False) as refseq: chroms_dict = refseq.chroms for frag in frag_ends: # Skip fragments whose breakpoint window falls off the contig. if (frag.start - half_k) < 0 or ( frag.start + half_k ) >= chroms_dict.get(frag.contig, 0): warnings.warn( f"Fragment {frag.contig}:{frag.start}-{frag.stop} is " "too close to the end of chromosome. Skipping." ) continue use_forward = both_strands or ( frag.is_forward and not negative_strand ) use_reverse = both_strands or negative_strand if use_forward: try: forward_kmer = refseq.sequence( contig, frag.start - half_k, frag.start + half_k ) if len(forward_kmer) != k: warnings.warn( f"Skipped fragment {contig}:{frag.start}-" f"{frag.stop} due to length discrepancy with " "motif. This may be due to the fragment being " "aligned to the end of a mitochondrial DNA." ) continue if "N" not in forward_kmer: breakpoint_motif_counts[forward_kmer] += 1 except ValueError: continue if use_reverse: try: reverse_kmer = refseq.sequence( contig, frag.stop - half_k, frag.stop + half_k ) if len(reverse_kmer) != k: warnings.warn( f"Skipped fragment {contig}:{frag.start}-" f"{frag.stop} due to length discrepancy with " "motif. This may be due to the fragment being " "aligned to the end of a mitochondrial DNA." ) continue if "N" not in reverse_kmer: breakpoint_motif_counts[ reverse_complement(reverse_kmer) ] += 1 except (RuntimeError, ValueError): if verbose > 1: stderr.write( f"Attempt to read interval at {contig}:" f"{int(frag.stop - half_k)}-" f"{int(frag.stop + half_k)} failed.Skipping." ) continue if verbose: stop_time = time() stderr.write( f"region_breakpoint_motifs took {stop_time - start_time} seconds " "to run\n" ) return breakpoint_motif_counts
def _region_breakpoint_motifs_star(args) -> NDArray[np.float64]: return np.array(list(region_breakpoint_motifs(*args).values()), dtype="<f8") def _region_breakpoint_motifs_dict_star(args) -> dict: return region_breakpoint_motifs(*args)
[docs] def breakpoint_motifs( input_file: str, refseq_file: str | Path, k: int = 6, min_length: int = 50, max_length: int = None, both_strands: bool = True, negative_strand: bool = False, output_file: None | str = None, quality_threshold: int = 30, workers: int = 1, verbose: bool | int = False, fraction_low: int | None = None, fraction_high: int | None = None, ) -> BreakpointMotifFreqs: """Compute genome-wide breakpoint-motif frequencies. Parameters ---------- input_file : str BAM/CRAM/fragment input. refseq_file : str or Path Reference genome. k : int, optional Breakpoint-motif k-mer length (default 6). min_length, max_length : int, optional Fragment-length filter (default min 50). both_strands : bool, optional Use both breakpoints (default ``True``). negative_strand : bool, optional With ``both_strands=False``, use only negative-strand breakpoints. output_file : str, optional TSV/CSV output path. quality_threshold : int, optional Minimum mapping quality (default 30). workers : int, optional Worker-process count (default 1). verbose : bool or int, optional Print progress/timing. fraction_low, fraction_high : int, optional Deprecated aliases for ``min_length``/``max_length``. Returns ------- BreakpointMotifFreqs Normalized genome-wide breakpoint-motif frequencies. """ if verbose: start_time = time() min_length, max_length = resolve_motif_aliases( min_length, max_length, fraction_low, fraction_high ) with ReferenceWrapper(str(refseq_file), use_lock=False) as refseq: chroms = refseq.chroms intervals = _genome_window_args( input_file, refseq_file, chroms, k, min_length, max_length, both_strands, negative_strand, quality_threshold, verbose, ) results = aggregate_genome_motifs( intervals, _region_breakpoint_motifs_star, BreakpointMotifFreqs, k, quality_threshold, workers, verbose, "Reading 1mb windows", "Counting breakpoint-motifs", ) write_motif_freqs(results, output_file) if verbose: stop_time = time() stdout.write( f"breakpoint_motifs took {stop_time - start_time} seconds to run\n" ) return results
[docs] def interval_breakpoint_motifs( input_file: str, refseq_file: str | Path, intervals: str | Iterable[tuple[str, int, int, str]], k: int = 6, min_length: int | None = 50, max_length: int | None = None, both_strands: bool = True, negative_strand: bool = False, output_file: str | None = None, quality_threshold: int = 30, workers: int = 1, verbose: bool | int = False, fraction_low: int | None = None, fraction_high: int | None = None, ) -> BreakpointMotifsIntervals: """Compute interval-stratified breakpoint-motif frequencies. Parameters ---------- input_file : str BAM/CRAM/fragment input. refseq_file : str or Path Reference genome. intervals : str or list of tuple BED path or list of ``(chrom, start, stop, name)`` tuples. k : int, optional Breakpoint-motif k-mer length (default 6). min_length, max_length : int, optional Fragment-length filter. both_strands : bool, optional Use both breakpoints (default ``True``). negative_strand : bool, optional With ``both_strands=False``, use only negative-strand breakpoints. output_file : str, optional TSV/CSV output path. quality_threshold : int, optional Minimum mapping quality (default 30). workers : int, optional Worker-process count (default 1). verbose : bool or int, optional Print progress/timing. fraction_low, fraction_high : int, optional Deprecated aliases for ``min_length``/``max_length``. Returns ------- BreakpointMotifsIntervals Per-interval breakpoint-motif counts. """ if verbose: start_time = time() min_length, max_length = resolve_motif_aliases( min_length, max_length, fraction_low, fraction_high ) results = aggregate_interval_motifs( input_file, refseq_file, intervals, _region_breakpoint_motifs_dict_star, BreakpointMotifsIntervals, k, min_length, max_length, both_strands, negative_strand, quality_threshold, workers, verbose, "Reading intervals", ) write_motif_freqs(results, output_file) if verbose: stop_time = time() stdout.write( f"breakpoint_motifs took {stop_time - start_time} seconds to run\n" ) return results
def _cli_mds(file_path: str, sep: str = "\t", header: int = 0) -> None: """CLI: print the motif diversity score of a k-mer frequency file.""" motifs = BreakpointMotifFreqs.from_file(file_path, 30, sep, header) stdout.write(f"{motifs.motif_diversity_score()}\n") def _cli_regional_mds( file_path: str, file_out: str, sep: str = ",", header: int = 0 ) -> None: """CLI: write the regional MDS (rMDS) of each region to ``file_out``.""" motifs = BreakpointMotifsIntervals.from_file(file_path, 30, sep, header) motifs.mds_bed(file_out)