"""
5' end-motif features (Zhou et al., 2023): genome-wide, region, and
interval-stratified k-mer end-motif frequencies 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 (
FPROFILE_PATH,
MIN_QUALITY,
_genome_window_args,
_MotifFreqs,
_MotifsIntervals,
aggregate_genome_motifs,
aggregate_interval_motifs,
resolve_motif_aliases,
write_motif_freqs,
)
__all__ = [
"EndMotifFreqs",
"EndMotifsIntervals",
"region_end_motifs",
"end_motifs",
"interval_end_motifs",
"FPROFILE_PATH",
"MIN_QUALITY",
]
[docs]
class EndMotifFreqs(_MotifFreqs):
"""Genome-wide 5' end-motif k-mer frequencies (Zhou et al., 2023)."""
[docs]
class EndMotifsIntervals(_MotifsIntervals):
"""Interval-stratified 5' end-motif k-mer counts."""
[docs]
def region_end_motifs(
input_file: str,
contig: str,
start: int,
stop: int,
refseq_file: str | Path,
k: int = 4,
fraction_low: int | None = 50,
fraction_high: int | None = None,
both_strands: bool = True,
negative_strand: bool = False,
output_file: str | None = None,
quality_threshold: int = MIN_QUALITY,
verbose: bool | int = False,
) -> dict:
"""Count 5' end-motif k-mers for fragments in a region.
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
End-motif k-mer length (default 4).
fraction_low, fraction_high : int, optional
Minimum/maximum fragment length.
both_strands : bool, optional
Use 5' ends of both mates; if ``False`` only the forward end is used
unless ``negative_strand`` is set.
negative_strand : bool, optional
With ``both_strands=False``, use only negative-strand 5' ends.
output_file : str, optional
Ignored (kept for signature compatibility).
quality_threshold : int, optional
Minimum mapping quality (default 20).
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.")
# fraction_low < k breaks py2bit sequence queries.
if fraction_low < k:
warnings.warn(
f"fraction_low={fraction_low} < k={k}, which may cause errors. "
"Automatically setting fraction_low=k."
)
fraction_low = k
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")
end_motif_counts = dict(zip(kmer_list, 4**k * [0]))
with ReferenceWrapper(str(refseq_file), use_lock=False) as refseq:
chroms_dict = refseq.chroms
if both_strands:
for frag in frag_ends:
try:
forward_kmer = refseq.sequence(
contig, int(frag.start), int(frag.start + k)
)
if len(forward_kmer) == k and "N" not in forward_kmer:
end_motif_counts[forward_kmer] += 1
except ValueError:
continue
try:
reverse_kmer = refseq.sequence(
contig, int(frag.stop - k), int(frag.stop)
)
if len(reverse_kmer) == k and "N" not in reverse_kmer:
end_motif_counts[reverse_complement(reverse_kmer)] += 1
except ValueError as e:
raise RuntimeError(
"Error querying sequence at "
f"{contig}:{int(frag.stop - k)}-{int(frag.stop)}. "
f"Chrom length: {chroms_dict.get(contig, 'unknown')}. "
"Please verify that the reference file matches the "
f"fragment file. Original error: {e}"
)
else:
for frag in frag_ends:
if frag.is_forward and not negative_strand:
try:
forward_kmer = refseq.sequence(
contig, int(frag.start), int(frag.start + k)
)
if len(forward_kmer) == k and "N" not in forward_kmer:
end_motif_counts[forward_kmer] += 1
except ValueError:
continue
elif negative_strand:
try:
reverse_kmer = refseq.sequence(
contig, int(frag.stop - k), int(frag.stop)
)
if len(reverse_kmer) == k and "N" not in reverse_kmer:
end_motif_counts[
reverse_complement(reverse_kmer)
] += 1
except ValueError:
if verbose > 1:
stderr.write(
f"Attempt to read interval at {contig}:"
f"{int(frag.stop - k)}-{int(frag.stop)} failed."
"Skipping."
)
continue
if verbose:
stop_time = time()
stderr.write(
f"region_end_motifs took {stop_time - start_time} seconds to run\n"
)
return end_motif_counts
def _region_end_motifs_star(args) -> NDArray[np.float64]:
return np.array(list(region_end_motifs(*args).values()), dtype="<f8")
def _region_end_motifs_dict_star(args) -> dict:
return region_end_motifs(*args)
[docs]
def end_motifs(
input_file: str,
refseq_file: str | Path,
k: int = 4,
min_length: int | None = 50,
max_length: int | None = 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,
) -> EndMotifFreqs:
"""Compute genome-wide 5' end-motif frequencies (Zhou et al., 2023).
Parameters
----------
input_file : str
BAM/CRAM/fragment input.
refseq_file : str or Path
Reference genome (``.2bit`` or FASTA).
k : int, optional
End-motif k-mer length (default 4).
min_length, max_length : int, optional
Fragment-length filter (default min 50).
both_strands : bool, optional
Use both mates' 5' ends (default ``True``).
negative_strand : bool, optional
With ``both_strands=False``, use only negative-strand ends.
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
-------
EndMotifFreqs
Normalized genome-wide end-motif frequencies.
"""
if verbose:
start_time = time()
min_length, max_length = resolve_motif_aliases(
min_length, max_length, fraction_low, fraction_high
)
if min_length is not None and min_length < k:
warnings.warn(
f"min_length={min_length} < k={k}, which may cause errors. "
"Automatically setting min_length=k."
)
min_length = k
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_end_motifs_star,
EndMotifFreqs,
k,
quality_threshold,
workers,
verbose,
"Reading 1mb windows",
"Counting end-motifs",
)
write_motif_freqs(results, output_file)
if verbose:
stop_time = time()
stdout.write(f"end_motifs took {stop_time - start_time} seconds to run\n")
return results
[docs]
def interval_end_motifs(
input_file: str,
refseq_file: str | Path,
intervals: str | Iterable[tuple[str, int, int, str]],
k: int = 4,
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,
) -> EndMotifsIntervals:
"""Compute interval-stratified 5' end-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
End-motif k-mer length (default 4).
min_length, max_length : int, optional
Fragment-length filter.
both_strands : bool, optional
Use both mates' 5' ends (default ``True``).
negative_strand : bool, optional
With ``both_strands=False``, use only negative-strand ends.
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
-------
EndMotifsIntervals
Per-interval end-motif counts.
"""
if verbose:
start_time = time()
min_length, max_length = resolve_motif_aliases(
min_length, max_length, fraction_low, fraction_high
)
if min_length is not None and min_length < k:
warnings.warn(
f"min_length={min_length} < k={k}, which may cause errors. "
"Automatically setting min_length=k."
)
min_length = k
results = aggregate_interval_motifs(
input_file,
refseq_file,
intervals,
_region_end_motifs_dict_star,
EndMotifsIntervals,
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"end_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."""
# 30 is a placeholder quality threshold; it does not affect MDS.
motifs = EndMotifFreqs.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 = EndMotifsIntervals.from_file(file_path, 30, sep, header)
motifs.mds_bed(file_out)