Source code for finaletoolkit.genome.gaps

"""
finaletoolkit.genome.gaps
=========================

Classes and functions for the UCSC Genome Browser gap tracks (Kent et al.,
2002): telomere, centromere, and short-arm intervals for hg19/hg38/b37.
"""
from __future__ import annotations

import gzip
import importlib.resources
from os import PathLike
from sys import stdout
from typing import Iterable, Optional, Union

import numpy as np
from numpy.typing import NDArray

import finaletoolkit.genome as genome

HG19GAPS = importlib.resources.files(genome) / "data" / "hg19.gap.txt.gz"
HG38GAPS = importlib.resources.files(genome) / "data" / "hg38.gap.txt.gz"

__all__ = [
    "GenomeGaps",
    "ContigGaps",
    "ucsc_hg19_gap_bed",
    "b37_gap_bed",
    "ucsc_hg38_gap_bed",
]

_GAP_DTYPE = [
    ("contig", "<U32"),
    ("start", "<i8"),
    ("stop", "<i8"),
    ("type", "<U32"),
]


[docs] class GenomeGaps: """Telomere/centromere/short-arm intervals for a reference genome. Construct from a BED4 gap file, or use the :meth:`ucsc_hg19`/:meth:`b37`/ :meth:`hg38` classmethods for the bundled UCSC tracks. """ def __init__(self, gaps_bed: Union[PathLike, str, None] = None) -> None: self.centromeres: NDArray self.telomeres: NDArray self.short_arms: NDArray self.gaps: NDArray if gaps_bed is None: return gaps = np.genfromtxt(gaps_bed, dtype=_GAP_DTYPE) self._set_gaps(gaps) def _set_gaps(self, gaps: NDArray) -> None: self.centromeres = gaps[gaps["type"] == "centromere"] self.telomeres = gaps[gaps["type"] == "telomere"] self.short_arms = gaps[gaps["type"] == "short_arm"] self.gaps = gaps @classmethod def _from_track(cls, gap_resource, strip_chr: bool = False) -> "GenomeGaps": genome_gaps = cls() with importlib.resources.as_file(gap_resource) as gap_file: gaps = np.genfromtxt(gap_file, usecols=[1, 2, 3, 7], dtype=_GAP_DTYPE) if strip_chr: gaps["contig"] = np.char.replace(gaps["contig"], "chr", "") genome_gaps._set_gaps(gaps) return genome_gaps
[docs] @classmethod def ucsc_hg19(cls) -> "GenomeGaps": """GenomeGaps for UCSC hg19 (``chr``-prefixed, GRCh37-based).""" return cls._from_track(HG19GAPS)
[docs] @classmethod def b37(cls) -> "GenomeGaps": """GenomeGaps for Broad b37 (UCSC hg19 track with ``chr`` stripped). An ad-hoc approximation; other hg19/b37 differences are not accounted for. """ return cls._from_track(HG19GAPS, strip_chr=True)
[docs] @classmethod def hg38(cls) -> "GenomeGaps": """GenomeGaps for UCSC hg38 (``chr``-prefixed, == GRCh38).""" return cls._from_track(HG38GAPS)
[docs] def in_tcmere(self, contig: str, start: int, stop: int) -> bool | None: """Return whether an interval overlaps a centromere or telomere. Returns ``None`` if the contig has no centromere annotation. Parameters ---------- contig : str Chromosome name. start, stop : int Interval bounds. """ centromere = self.centromeres[self.centromeres["contig"] == contig] telomeres = self.telomeres[self.telomeres["contig"] == contig] if not centromere.shape[0]: return None in_centromere = bool( np.any( np.logical_and( stop > centromere["start"], start < centromere["stop"], ) ) ) # (Original code computed telomere overlap only when *no* telomeres # existed, making it always False; corrected to test overlap when # telomeres are present.) if telomeres.shape[0]: in_telomeres = bool( np.any( np.logical_and( stop > telomeres["start"], start < telomeres["stop"], ) ) ) else: in_telomeres = False return in_centromere or in_telomeres
[docs] def overlaps_gap(self, contig: str, start: int, stop: int) -> bool | None: """Return whether an interval overlaps any gap (``None`` if none).""" gaps = self.gaps[self.gaps["contig"] == contig] if not gaps.shape[0]: return None return bool( np.any(np.logical_and(start < gaps["stop"], stop > gaps["start"])) )
[docs] def get_arm(self, contig: str, start: int, stop: int) -> str: """Return the chromosome arm (e.g. ``"1p"``) or ``"NOARM"``. Raises ------ ValueError If ``stop < start``. """ if stop < start: raise ValueError("start must be less than stop") centromere = self.centromeres[self.centromeres["contig"] == contig] short_arm = self.short_arms[self.short_arms["contig"] == contig] has_short_arm = short_arm.shape[0] > 0 if stop < centromere["start"][0]: if not has_short_arm: return f"{contig.replace('chr', '')}p" return "NOARM" if start > centromere["stop"][0]: return f"{contig.replace('chr', '')}q" return "NOARM"
[docs] def get_contig_gaps(self, contig: str) -> Optional["ContigGaps"]: """Return a :class:`ContigGaps` for ``contig`` (``None`` if no centromere).""" centromere = self.centromeres[self.centromeres["contig"] == contig] try: centromere_ends = (centromere[0]["start"], centromere[0]["stop"]) except IndexError: return None telomeres = self.telomeres[self.telomeres["contig"] == contig] telomere_ends = [(t["start"], t["stop"]) for t in telomeres] short_arm = self.short_arms[self.short_arms["contig"] == contig] has_short_arm = short_arm.shape[0] > 0 return ContigGaps(contig, centromere_ends, telomere_ends, has_short_arm)
[docs] def to_bed(self, output_file: Union[str, PathLike]) -> None: """Write all gaps as a sorted BED4 (name = gap type). ``output_file`` may be a path, a ``.gz`` path, or ``"-"`` for stdout. """ gaps = np.sort(self.gaps) def _write(handle) -> None: for interval in gaps: handle.write( f"{interval['contig']}\t{interval['start']}\t" f"{interval['stop']}\t{interval['type']}\n" ) if str(output_file).endswith(".gz"): with gzip.open(output_file, "wt") as output: _write(output) elif str(output_file) == "-": _write(stdout) else: with open(output_file, "w") as output: _write(output)
[docs] class ContigGaps: """Centromere/telomere intervals for a single contig.""" def __init__( self, contig: str, centromere: tuple[int, int], telomeres: Iterable[tuple[int, int]], has_short_arm: bool = False, ) -> None: self.contig = contig self.centromere = centromere self.telomeres = list(telomeres) self.has_short_arm = has_short_arm
[docs] def in_tcmere(self, start: int, stop: int) -> bool: """Return whether an interval overlaps the centromere or a telomere. Notes ----- The telomere test uses :func:`all` (an interval must overlap *every* telomere). This matches the original implementation and is preserved deliberately: the bundled DELFI reference outputs were generated with this behavior, so changing it to :func:`any` would alter DELFI results. """ in_centromere = ( stop > self.centromere[0] and start < self.centromere[1] ) if not self.telomeres: in_telomeres = False else: in_telomeres = all( stop > telomere[0] and start < telomere[1] for telomere in self.telomeres ) return in_centromere or in_telomeres
[docs] def in_gap(self, start: int, stop: int) -> bool: """Alias of :meth:`in_tcmere` (preserved for compatibility).""" in_centromere = ( stop > self.centromere[0] and start < self.centromere[1] ) in_telomeres = all( stop > telomere[0] and start < telomere[1] for telomere in self.telomeres ) return in_centromere or in_telomeres
[docs] def get_arm(self, start: int, stop: int) -> str: """Return the chromosome arm or ``"NOARM"``. Raises ------ ValueError If ``stop < start``. """ if stop < start: raise ValueError("start must be less than stop") if stop < self.centromere[0]: if not self.has_short_arm: return f"{self.contig.replace('chr', '')}p" return "NOARM" if start > self.centromere[1]: return f"{self.contig.replace('chr', '')}q" return "NOARM"
[docs] def ucsc_hg19_gap_bed(output_file: Union[str, PathLike]) -> None: """Write a BED4 of UCSC hg19 centromeres/telomeres/short arms.""" return GenomeGaps.ucsc_hg19().to_bed(output_file)
[docs] def b37_gap_bed(output_file: Union[str, PathLike]) -> None: """Write a BED4 of Broad b37 centromeres/telomeres/short arms. Also useful for human_g1k_v37 (1000 Genomes) alignments. """ return GenomeGaps.b37().to_bed(output_file)
[docs] def ucsc_hg38_gap_bed(output_file: Union[str, PathLike]) -> None: """Write a BED4 of UCSC hg38 centromeres/telomeres/short arms.""" return GenomeGaps.hg38().to_bed(output_file)
def _cli_gap_bed(reference_genome: str, output_file: str) -> None: """CLI: write a gap BED for a named reference genome.""" if reference_genome == "hg19": ucsc_hg19_gap_bed(output_file) elif reference_genome in ("b37", "human_g1k_v37"): b37_gap_bed(output_file) elif reference_genome in ("hg38", "GRCh38"): ucsc_hg38_gap_bed(output_file) else: raise ValueError( f"Gap track for {reference_genome} is currently unavailable. It is " "possible to create a gap track de novo if interval data for " "centromeres, telomeres, and short_arms exist for the reference " "sequence of interest." )