Source code for genvarloader._table

"""Tabular interval track source for :func:`gvl.write()`.

Mirrors the :class:`BigWigs` reader API surface so that
:func:`genvarloader._dataset._write._write_track` can dispatch to either.
Overlap queries are served by the Rust ``RustTable`` (COITrees) backend.
"""

from __future__ import annotations

from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np
import polars as pl
from genoray._contigs import ContigNormalizer

if TYPE_CHECKING:
    from numpy.typing import ArrayLike, NDArray

    from ._ragged import RaggedIntervals


CANONICAL_COLS = ("sample_id", "chrom", "start", "end", "value")


[docs] class Table: """Long-form interval track keyed by ``(sample_id, chrom, start, end, value)``. Overlap queries are served by a Rust COITrees backend. Coordinates are zero-based, half-open ``[start, end)``. """ name: str samples: list[str] contigs: Mapping[str, int]
[docs] def __init__( self, name: str, data: pl.DataFrame | Mapping[str, pl.DataFrame], column_map: Mapping[str, str] | None = None, ) -> None: from .genvarloader import RustTable self.name = name df = self._normalize_input(data, column_map) df = df.cast( { "sample_id": pl.Utf8, "chrom": pl.Utf8, "start": pl.Int32, "end": pl.Int32, "value": pl.Float32, } ).sort("chrom", "sample_id", "start") self._df = df self.samples = sorted(df["sample_id"].unique().to_list()) self.contigs = { row["chrom"]: int(row["max_end"]) for row in df.group_by("chrom") .agg(pl.col("end").max().alias("max_end")) .iter_rows(named=True) } # Factor-encode for the Rust store. Contig order is the dict insertion # order of `self.contigs`; sample order is `self.samples`. self._contig_list = list(self.contigs.keys()) self._cnorm = ContigNormalizer(self._contig_list) sample_to_code = {s: i for i, s in enumerate(self.samples)} sample_codes = ( df.select( pl.col("sample_id").replace_strict( sample_to_code, return_dtype=pl.Int32 ) ) .to_series() .to_numpy() ) chrom_to_code = {c: i for i, c in enumerate(self._contig_list)} chrom_codes = ( df.select( pl.col("chrom").replace_strict(chrom_to_code, return_dtype=pl.Int32) ) .to_series() .to_numpy() ) self._rust = RustTable( np.ascontiguousarray(sample_codes, dtype=np.int32), np.ascontiguousarray(chrom_codes, dtype=np.int32), np.ascontiguousarray(df["start"].to_numpy(), dtype=np.int32), np.ascontiguousarray(df["end"].to_numpy(), dtype=np.int32), np.ascontiguousarray(df["value"].to_numpy(), dtype=np.float32), len(self.samples), len(self._contig_list), )
@classmethod def from_path( cls, name: str, path: str | Path | Mapping[str, str | Path], column_map: Mapping[str, str] | None = None, ) -> Table: if isinstance(path, Mapping): data: dict[str, pl.DataFrame] = { sid: cls._read_path(Path(p)) for sid, p in path.items() } return cls(name, data, column_map) return cls(name, cls._read_path(Path(path)), column_map) @staticmethod def _read_path(p: Path) -> pl.DataFrame: suf = p.suffix.lower() if suf == ".csv": return pl.read_csv(p) if suf in (".tsv", ".txt"): return pl.read_csv(p, separator="\t") if suf == ".parquet": return pl.read_parquet(p) if suf in (".arrow", ".ipc"): return pl.read_ipc(p) raise ValueError( f"Unsupported file extension {suf!r}. " "Expected one of .csv, .tsv, .txt, .parquet, .arrow, .ipc." ) @staticmethod def _normalize_input( data: pl.DataFrame | Mapping[str, pl.DataFrame], column_map: Mapping[str, str] | None, ) -> pl.DataFrame: if isinstance(data, pl.DataFrame): df = Table._apply_column_map(data, column_map, expect_sample_id=True) else: frames: list[pl.DataFrame] = [] for sid, sub in data.items(): renamed = Table._apply_column_map( sub, column_map, expect_sample_id=False ) frames.append(renamed.with_columns(sample_id=pl.lit(sid))) if not frames: raise ValueError("Empty mapping passed to Table.") df = pl.concat(frames, how="vertical_relaxed") missing = [c for c in CANONICAL_COLS if c not in df.columns] if missing: raise ValueError( f"Missing required column(s) {missing}. " f"Use `column_map` to rename if your columns differ from {CANONICAL_COLS}." ) return df.select(*CANONICAL_COLS) @staticmethod def _apply_column_map( df: pl.DataFrame, column_map: Mapping[str, str] | None, expect_sample_id: bool, ) -> pl.DataFrame: if not column_map: return df rename = { actual: canonical for canonical, actual in column_map.items() if actual in df.columns } if not expect_sample_id: rename.pop("sample_id", None) return df.rename(rename) def _resolve_samples(self, sample: str | list[str] | None) -> list[str]: if sample is None: return list(self.samples) if isinstance(sample, str): samples = [sample] else: samples = list(sample) if missing := set(samples) - set(self.samples): raise ValueError(f"Sample(s) {missing} not found in Table.") return samples def _chrom_code(self, contig: str) -> int: """Resolve `contig` to its code in this Table, or -1 if absent.""" norm = self._cnorm.norm(contig) if norm is None: return -1 return self._contig_list.index(norm) def _sample_codes(self, samples: list[str]) -> NDArray[np.int32]: s2c = {s: i for i, s in enumerate(self.samples)} return np.array([s2c[s] for s in samples], dtype=np.int32) def count_intervals( self, contig: str, starts: ArrayLike, ends: ArrayLike, sample: str | list[str] | None = None, **kwargs, ) -> NDArray[np.int32]: samples = self._resolve_samples(sample) starts_arr = np.ascontiguousarray(np.atleast_1d(starts), dtype=np.int32) ends_arr = np.ascontiguousarray(np.atleast_1d(ends), dtype=np.int32) return self._rust.count( self._chrom_code(contig), starts_arr, ends_arr, self._sample_codes(samples), ) def _intervals_from_offsets( self, contig: str, starts: ArrayLike, ends: ArrayLike, offsets: NDArray[np.int64], sample: str | list[str] | None = None, **kwargs, ) -> RaggedIntervals: from seqpro.rag import Ragged from ._ragged import RaggedIntervals samples = self._resolve_samples(sample) starts_arr = np.ascontiguousarray(np.atleast_1d(starts), dtype=np.int32) ends_arr = np.ascontiguousarray(np.atleast_1d(ends), dtype=np.int32) offsets = np.ascontiguousarray(offsets, dtype=np.int64) n_regions = len(starts_arr) n_samples = len(samples) shape = (n_regions, n_samples, None) coords, values = self._rust.intervals( self._chrom_code(contig), starts_arr, ends_arr, self._sample_codes(samples), offsets, ) flat_starts = np.ascontiguousarray(coords[:, 0], dtype=np.int32) flat_ends = np.ascontiguousarray(coords[:, 1], dtype=np.int32) return RaggedIntervals( Ragged.from_offsets(flat_starts, shape, offsets), Ragged.from_offsets(flat_ends, shape, offsets), Ragged.from_offsets(values, shape, offsets), )
def annot_overlap(regions: pl.DataFrame, annot: pl.DataFrame) -> "RaggedIntervals": """Sample-less interval overlap of `regions` (chrom/chromStart/chromEnd) against a BED-like `annot` (chrom/chromStart/chromEnd/score). Returns a RaggedIntervals of shape (n_regions, None) ordered by (region, start), via the Rust COITrees backend. """ from seqpro.rag import Ragged from ._ragged import RaggedIntervals from ._utils import lengths_to_offsets if annot.height == 0: n_regions = regions.height offsets = np.zeros(n_regions + 1, dtype=np.int64) shape = (n_regions, None) return RaggedIntervals( Ragged.from_offsets(np.empty(0, dtype=np.int32), shape, offsets), Ragged.from_offsets(np.empty(0, dtype=np.int32), shape, offsets), Ragged.from_offsets(np.empty(0, dtype=np.float32), shape, offsets), ) annot_long = annot.select( pl.lit("__annot__").alias("sample_id"), "chrom", pl.col("chromStart").alias("start"), pl.col("chromEnd").alias("end"), pl.col("score").alias("value"), ) table = Table("__annot__", annot_long) n_regions = regions.height # Per-region interval arrays indexed by input region position. per_start: list[NDArray[np.int32]] = [np.empty(0, np.int32)] * n_regions per_end: list[NDArray[np.int32]] = [np.empty(0, np.int32)] * n_regions per_val: list[NDArray[np.float32]] = [np.empty(0, np.float32)] * n_regions reg = regions.with_row_index("_q") for (contig,), part in reg.partition_by( "chrom", as_dict=True, include_key=False, maintain_order=True ).items(): contig = str(contig) q_idx = part["_q"].to_numpy() starts = part["chromStart"].to_numpy() ends = part["chromEnd"].to_numpy() counts = table.count_intervals(contig, starts, ends, sample=["__annot__"]) offsets = lengths_to_offsets(counts.ravel()) itvs = table._intervals_from_offsets( contig, starts, ends, offsets, sample=["__annot__"] ).squeeze(1) for j, qi in enumerate(q_idx): per_start[qi] = np.asarray(itvs.starts[j], dtype=np.int32) per_end[qi] = np.asarray(itvs.ends[j], dtype=np.int32) per_val[qi] = np.asarray(itvs.values[j], dtype=np.float32) lengths = np.array([len(s) for s in per_start], dtype=np.int32) offsets = lengths_to_offsets(lengths) flat_starts = np.concatenate(per_start) if n_regions else np.empty(0, np.int32) flat_ends = np.concatenate(per_end) if n_regions else np.empty(0, np.int32) flat_values = np.concatenate(per_val) if n_regions else np.empty(0, np.float32) shape = (n_regions, None) return RaggedIntervals( Ragged.from_offsets(flat_starts, shape, offsets), Ragged.from_offsets(flat_ends, shape, offsets), Ragged.from_offsets(flat_values, shape, offsets), )