"""Dot plot computation for sequence-vs-sequence comparison. A dot plot marks every pair of positions ``(i, j)`` whose surrounding ``window`` residues are similar at or above ``stringency``. Similarity is either simple identity (nucleotide sequences) or a substitution-matrix score (BLOSUM/PAM for proteins). ``stringency`` always means "% of a perfect match": * identity scoring — at least ``stringency``% of the window residues match. * substitution scoring — the window must reach ``stringency``% of its own maximum possible score (the score it would get against a perfectly identical window). This is residue-composition independent: a window of alanines has a lower ceiling than a window of tryptophans, so identical sequences always light the main diagonal and conserved regions appear regardless of their amino-acid content. Identical sequences produce the classic diagonal; repeats and rearrangements show up as off-diagonal lines; inverted repeats as anti-diagonal lines. Uses a vectorised (numpy) scan: scores are computed positionally along each ``(i, j)`` diagonal with sliding-window sums, so a 2000 x 2000 comparison completes in well under a second. Pure local computation — no network calls. """ from __future__ import annotations import functools import math import numpy as np from app.services.sequence_utils import detect_sequence_type class DotPlotError(ValueError): pass MAX_CELLS = 4_000_000 # ~2000 x 2000 MAX_DOTS = 20_000 SCORING_OPTIONS = ("identity", "blosum62", "blosum50", "blosum45", "pam30", "pam70", "pam250") @functools.lru_cache(maxsize=8) def _load_matrix(name: str) -> tuple[np.ndarray, dict[str, int]]: """Load a substitution matrix as (data, letter->row index).""" from Bio.Align import substitution_matrices m = substitution_matrices.load(name.upper()) letters = list(m.alphabet) index = {ch: i for i, ch in enumerate(letters)} return np.asarray(m.data, dtype=np.int16), index def _normalize(seq: str) -> str: return "".join(ch for ch in seq.upper() if ch.isalpha()) def _detect_features(ys: np.ndarray, xs: np.ndarray, n: int, m: int, window: int) -> dict: """Structurally meaningful signals from the (pre-downsampled) dot set. * Main-diagonal coverage: how much of the principal diagonal is lit up, measured over the ``diag_len - window + 1`` positions that can actually hold a window. * Gap runs on the main diagonal: maximal stretches of unlit positions, which correspond to insertions/deletions. * Off-diagonal lines: dominant constant offsets ``x - y`` -> repeats, tandem duplications and translocated segments. * Anti-diagonal lines: dominant constant ``x + y`` -> inverted repeats (mostly relevant for nucleotide comparisons). """ empty = {"main_diagonal_pct": 0.0, "gaps": {"count": 0, "largest": 0}, "off_diagonal": [], "anti_diagonal": []} if ys.size == 0: return empty offsets = (xs - ys).astype(np.int64) sums = (xs + ys).astype(np.int64) diag_len = min(n, m) diag_positions = max(1, diag_len - window + 1) min_count = max(2, int(0.02 * diag_positions)) # Main diagonal coverage + gap runs on_diag = np.unique(ys[offsets == 0]) main_pct = round(100.0 * on_diag.size / diag_positions, 1) covered = set(on_diag.tolist()) gap_runs: list[int] = [] run = 0 for pos in range(diag_positions): if pos in covered: if run > 0: gap_runs.append(run) run = 0 else: run += 1 if run > 0: gap_runs.append(run) # Off-diagonal repeat offsets off_vals, off_counts = np.unique(offsets[offsets != 0], return_counts=True) off_diagonal = [ {"offset": int(o), "count": int(c)} for o, c in zip(off_vals.tolist(), off_counts.tolist()) if int(c) >= min_count ] off_diagonal.sort(key=lambda d: -d["count"]) off_diagonal = off_diagonal[:5] # Anti-diagonal (inverted repeat) lines anti_vals, anti_counts = np.unique(sums, return_counts=True) anti_diagonal = [ {"sum": int(s), "count": int(c)} for s, c in zip(anti_vals.tolist(), anti_counts.tolist()) if int(c) >= min_count ] anti_diagonal.sort(key=lambda d: -d["count"]) anti_diagonal = anti_diagonal[:5] return { "main_diagonal_pct": main_pct, "gaps": {"count": len(gap_runs), "largest": max(gap_runs) if gap_runs else 0}, "off_diagonal": off_diagonal, "anti_diagonal": anti_diagonal, } def compute_dotplot( seq_a: str, seq_b: str, window: int = 10, stringency: int = 80, scoring: str = "identity", max_dots: int = MAX_DOTS, ) -> dict: seq_a = _normalize(seq_a) seq_b = _normalize(seq_b) if not seq_a or not seq_b: raise DotPlotError("Both sequences are required") n, m = len(seq_a), len(seq_b) if n * m > MAX_CELLS: raise DotPlotError( f"Sequences too large for a dot plot ({n} x {m} cells, max {MAX_CELLS}). " "Use shorter sequences or trim the input." ) window = max(1, min(int(window), n, m)) stringency = max(1, min(100, int(stringency))) if scoring not in SCORING_OPTIONS: raise DotPlotError( f"Unknown scoring scheme '{scoring}'. Use one of: {', '.join(SCORING_OPTIONS)}" ) type_a = detect_sequence_type(seq_a) type_b = detect_sequence_type(seq_b) # Protein substitution matrices only make sense when BOTH inputs are # protein; mixing protein with a nucleotide sequence silently scores # nucleotide letters as if they were amino acids, so fall back to identity. scoring_used = scoring if scoring != "identity" and (type_a != "protein" or type_b != "protein"): scoring_used = "identity" if type_a == "protein" and type_b == "protein": seq_type = "protein" elif type_a == type_b: seq_type = type_a else: seq_type = "mixed" a = np.frombuffer(seq_a.encode("ascii", "ignore"), dtype=np.uint8) b = np.frombuffer(seq_b.encode("ascii", "ignore"), dtype=np.uint8) if a.size == 0 or b.size == 0: raise DotPlotError("Both sequences are required") if scoring_used == "identity": # "stringency" is the % of window residues that must be identical. threshold = max(1, math.ceil(window * stringency / 100.0)) match_rule = "window_identity" else: data, index = _load_matrix(scoring_used) # Map letters to matrix rows; unknown residues (B/Z/U/O/X, ambiguous) # get a dedicated zero-scoring row/column. rows_a = np.array([index.get(chr(c), len(index)) for c in a.tolist()], dtype=np.intp) rows_b = np.array([index.get(chr(c), len(index)) for c in b.tolist()], dtype=np.intp) if len(index) < data.shape[0]: data = data[: len(index), : len(index)] extra = np.zeros((1, data.shape[1]), dtype=np.int16) data = np.vstack([data, extra]) extra = np.zeros((data.shape[0], 1), dtype=np.int16) data = np.hstack([data, extra]) # For substitution scoring, "stringency" is the % of the window's own # maximum possible score (its perfect self-match) that must be reached. # This makes the threshold residue-composition independent: a window # of alanines needs 4 x window, a window of tryptophans needs 11 x # window, and identical sequences always light the main diagonal. self_diag_a = data[rows_a, rows_a] max_self_window = 1 match_rule = "percent_of_perfect_self_match" if window == 1: if scoring_used == "identity": eq = (a[:, None] == b[None, :]) else: score_mat = data[rows_a[:, None], rows_b[None, :]] denom = self_diag_a[:, None] eq = (denom > 0) & (score_mat.astype(np.int64) * 100 >= stringency * denom) if self_diag_a.size: max_self_window = max(max_self_window, int(self_diag_a.max())) ys, xs = np.nonzero(eq) else: ys_list: list[np.ndarray] = [] xs_list: list[np.ndarray] = [] for d in range(-(n - 1), m): i0 = max(0, -d) j0 = max(0, d) length = min(n - i0, m - j0) if length < window: continue if scoring_used == "identity": score_diag = (a[i0:i0 + length] == b[j0:j0 + length]).astype(np.int16) else: score_diag = data[rows_a[i0:i0 + length], rows_b[j0:j0 + length]] self_diag = data[rows_a[i0:i0 + length], rows_a[i0:i0 + length]] csum = np.concatenate([[0], np.cumsum(score_diag)]) sums = csum[window:] - csum[:-window] if scoring_used == "identity": kk = np.nonzero(sums >= threshold)[0] else: csum_self = np.concatenate([[0], np.cumsum(self_diag)]) self_sums = csum_self[window:] - csum_self[:-window] if self_sums.size: max_self_window = max(max_self_window, int(self_sums.max())) kk = np.nonzero( (self_sums > 0) & (sums.astype(np.int64) * 100 >= stringency * self_sums) )[0] if kk.size: ys_list.append(i0 + kk) xs_list.append(j0 + kk) if ys_list: ys = np.concatenate(ys_list) xs = np.concatenate(xs_list) else: ys = np.empty(0, dtype=np.int64) xs = np.empty(0, dtype=np.int64) if scoring_used != "identity": # Reported raw-score baseline: the strongest self-scoring window. threshold = max(1, math.floor(max_self_window * stringency / 100.0)) total_matches = int(ys.size) features = _detect_features(ys, xs, n, m, window) downsampled = False if total_matches > max_dots: step = max(1, int(math.ceil(total_matches / max_dots))) ys = ys[::step] xs = xs[::step] downsampled = True dots = [[int(y), int(x)] for y, x in zip(ys.tolist(), xs.tolist())] return { "sequence_type": seq_type, "sequence_type_a": type_a, "sequence_type_b": type_b, "seq_a_length": n, "seq_b_length": m, "window": window, "stringency": stringency, "scoring": scoring, "scoring_used": scoring_used, "threshold": threshold, "match_rule": match_rule, "total_matches": total_matches, "dot_count": len(dots), "downsampled": downsampled, "features": features, "dots": dots, }