File size: 10,729 Bytes
1c2cf5d
 
 
4b491b0
 
d8958b0
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
4b491b0
1c2cf5d
 
 
 
 
 
4b491b0
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
4b491b0
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
d8958b0
 
 
 
 
4b491b0
d8958b0
4b491b0
d8958b0
 
 
 
 
 
1c2cf5d
 
 
 
 
 
4b491b0
d8958b0
4b491b0
d8958b0
4b491b0
 
 
 
 
 
 
 
 
 
 
 
d8958b0
 
 
 
 
 
 
 
4b491b0
1c2cf5d
4b491b0
 
 
d8958b0
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
d8958b0
4b491b0
1c2cf5d
d8958b0
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
d8958b0
 
 
 
1c2cf5d
4b491b0
1c2cf5d
 
 
 
 
 
 
 
 
d8958b0
 
 
1c2cf5d
 
 
 
4b491b0
 
1c2cf5d
d8958b0
1c2cf5d
 
 
4b491b0
1c2cf5d
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""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,
    }