Spaces:
Running
Running
Tengo Gzirishvili Claude Opus 4.8 commited on
Commit ·
91ed780
1
Parent(s): 1755b9c
Add pairwise sequence alignment (Needleman–Wunsch / Smith–Waterman)
Browse filesCloses the biggest "looks incomplete vs Benchling" gap from the competitive
audit — we couldn't align two sequences at all.
- dee/core/align.py: numpy global + local pairwise alignment with identity %,
match midline, gaps, score; |a|×|b| size cap. +8 unit tests.
- POST /api/align (stateless utility, rate-limited, no sign-in gate).
- "Align two sequences" card in the Plasmid workbench: two inputs, Global/Local
toggle, blocked alignment view + identity/score stats.
Verified live: global 88.9% identity with correct midline, local finds the
shared region (100%, 0 gaps), empty→400. Full suite 324 passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dee/core/align.py +112 -0
- dee/server.py +17 -0
- dee/static/app.css +10 -0
- dee/static/app.js +48 -0
- dee/static/index.html +23 -2
- tests/test_align.py +64 -0
dee/core/align.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pairwise sequence alignment — Needleman–Wunsch (global) and Smith–Waterman
|
| 2 |
+
(local). numpy + stdlib only; no Biopython dependency so it's trivially testable.
|
| 3 |
+
|
| 4 |
+
Identity-based scoring (match / mismatch + linear gap) — enough for the
|
| 5 |
+
"compare two sequences" use case: visualise percent identity, mismatches, and
|
| 6 |
+
gaps for DNA or protein. O(n·m) DP, so we cap |a|·|b| to keep it snappy.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
# |a| * |b| ceiling (~1225 × 1225). Above this we ask the user to align a
|
| 15 |
+
# shorter region — the pure-Python traceback + DP fill stays sub-second below it.
|
| 16 |
+
MAX_CELLS = 1_500_000
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _clean(s: str) -> str:
|
| 20 |
+
return "".join(c for c in (s or "").upper() if not c.isspace())
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def align(a: str, b: str, *, mode: str = "global",
|
| 24 |
+
match: int = 2, mismatch: int = -1, gap: int = -2) -> Dict[str, Any]:
|
| 25 |
+
"""Align sequences `a` and `b`.
|
| 26 |
+
|
| 27 |
+
mode: 'global' (Needleman–Wunsch) or 'local' (Smith–Waterman).
|
| 28 |
+
Returns aligned strings, a match midline, percent identity, score, gaps.
|
| 29 |
+
"""
|
| 30 |
+
a = _clean(a)
|
| 31 |
+
b = _clean(b)
|
| 32 |
+
if not a or not b:
|
| 33 |
+
raise ValueError("Both sequences must be non-empty.")
|
| 34 |
+
if len(a) * len(b) > MAX_CELLS:
|
| 35 |
+
raise ValueError(
|
| 36 |
+
f"Sequences too large to align in-browser (|a|×|b| > {MAX_CELLS:,}). "
|
| 37 |
+
"Align a shorter region."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
local = (mode == "local")
|
| 41 |
+
n, m = len(a), len(b)
|
| 42 |
+
H = np.zeros((n + 1, m + 1), dtype=np.int32)
|
| 43 |
+
# traceback codes: 0 diag, 1 up (gap in b), 2 left (gap in a), 3 stop
|
| 44 |
+
T = np.zeros((n + 1, m + 1), dtype=np.int8)
|
| 45 |
+
|
| 46 |
+
if not local:
|
| 47 |
+
H[:, 0] = np.arange(n + 1) * gap
|
| 48 |
+
H[0, :] = np.arange(m + 1) * gap
|
| 49 |
+
T[1:, 0] = 1
|
| 50 |
+
T[0, 1:] = 2
|
| 51 |
+
|
| 52 |
+
best_score, best_i, best_j = 0, 0, 0
|
| 53 |
+
for i in range(1, n + 1):
|
| 54 |
+
ai = a[i - 1]
|
| 55 |
+
Hi, Hprev, Ti = H[i], H[i - 1], T[i]
|
| 56 |
+
for j in range(1, m + 1):
|
| 57 |
+
sc = match if ai == b[j - 1] else mismatch
|
| 58 |
+
cell = Hprev[j - 1] + sc
|
| 59 |
+
t = 0
|
| 60 |
+
up = Hprev[j] + gap
|
| 61 |
+
if up > cell:
|
| 62 |
+
cell, t = up, 1
|
| 63 |
+
left = Hi[j - 1] + gap
|
| 64 |
+
if left > cell:
|
| 65 |
+
cell, t = left, 2
|
| 66 |
+
if local and cell < 0:
|
| 67 |
+
cell, t = 0, 3
|
| 68 |
+
Hi[j] = cell
|
| 69 |
+
Ti[j] = t
|
| 70 |
+
if local and cell > best_score:
|
| 71 |
+
best_score, best_i, best_j = cell, i, j
|
| 72 |
+
|
| 73 |
+
if local:
|
| 74 |
+
score, i, j = best_score, best_i, best_j
|
| 75 |
+
else:
|
| 76 |
+
score, i, j = int(H[n, m]), n, m
|
| 77 |
+
|
| 78 |
+
out_a, out_b = [], []
|
| 79 |
+
while i > 0 or j > 0:
|
| 80 |
+
t = int(T[i, j])
|
| 81 |
+
if local and (H[i, j] == 0 or t == 3):
|
| 82 |
+
break
|
| 83 |
+
if t == 0:
|
| 84 |
+
out_a.append(a[i - 1]); out_b.append(b[j - 1]); i -= 1; j -= 1
|
| 85 |
+
elif t == 1:
|
| 86 |
+
out_a.append(a[i - 1]); out_b.append("-"); i -= 1
|
| 87 |
+
elif t == 2:
|
| 88 |
+
out_a.append("-"); out_b.append(b[j - 1]); j -= 1
|
| 89 |
+
else:
|
| 90 |
+
break
|
| 91 |
+
out_a.reverse(); out_b.reverse()
|
| 92 |
+
aa, bb = "".join(out_a), "".join(out_b)
|
| 93 |
+
|
| 94 |
+
cols = len(aa)
|
| 95 |
+
matches = sum(1 for x, y in zip(aa, bb) if x == y and x != "-")
|
| 96 |
+
identity = round(100.0 * matches / cols, 1) if cols else 0.0
|
| 97 |
+
gaps = aa.count("-") + bb.count("-")
|
| 98 |
+
midline = "".join(
|
| 99 |
+
"|" if (x == y and x != "-") else (" " if (x == "-" or y == "-") else ".")
|
| 100 |
+
for x, y in zip(aa, bb)
|
| 101 |
+
)
|
| 102 |
+
return {
|
| 103 |
+
"mode": "local" if local else "global",
|
| 104 |
+
"score": int(score),
|
| 105 |
+
"identity": identity,
|
| 106 |
+
"length": cols,
|
| 107 |
+
"matches": matches,
|
| 108 |
+
"gaps": gaps,
|
| 109 |
+
"aligned_a": aa,
|
| 110 |
+
"aligned_b": bb,
|
| 111 |
+
"midline": midline,
|
| 112 |
+
}
|
dee/server.py
CHANGED
|
@@ -1567,6 +1567,23 @@ def create_app() -> Flask:
|
|
| 1567 |
return send_file(io.BytesIO(payload.encode("utf-8")), mimetype=mime,
|
| 1568 |
as_attachment=True, download_name=f"{stem}.{ext}")
|
| 1569 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1570 |
@app.post("/api/plasmid/save")
|
| 1571 |
def plasmid_save() -> Response:
|
| 1572 |
auth, denied = _plasmid_signin_guard()
|
|
|
|
| 1567 |
return send_file(io.BytesIO(payload.encode("utf-8")), mimetype=mime,
|
| 1568 |
as_attachment=True, download_name=f"{stem}.{ext}")
|
| 1569 |
|
| 1570 |
+
@app.post("/api/align")
|
| 1571 |
+
def align_sequences() -> Response:
|
| 1572 |
+
"""Pairwise alignment (global/local) of two sequences. Stateless utility,
|
| 1573 |
+
no sign-in needed; bounded by align()'s size cap + the global rate limit."""
|
| 1574 |
+
from .core import align as _AL
|
| 1575 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 1576 |
+
a, b = body.get("a") or "", body.get("b") or ""
|
| 1577 |
+
mode = "local" if body.get("mode") == "local" else "global"
|
| 1578 |
+
try:
|
| 1579 |
+
result = _AL.align(a, b, mode=mode)
|
| 1580 |
+
except ValueError as exc:
|
| 1581 |
+
return jsonify({"error": str(exc)}), 400
|
| 1582 |
+
except Exception: # noqa: BLE001
|
| 1583 |
+
logger.exception("alignment failed")
|
| 1584 |
+
return jsonify({"error": "Alignment failed — check the two sequences."}), 500
|
| 1585 |
+
return jsonify({"ok": True, **result})
|
| 1586 |
+
|
| 1587 |
@app.post("/api/plasmid/save")
|
| 1588 |
def plasmid_save() -> Response:
|
| 1589 |
auth, denied = _plasmid_signin_guard()
|
dee/static/app.css
CHANGED
|
@@ -5614,6 +5614,16 @@ h3, h4 {
|
|
| 5614 |
/* selection arc mirrored on the companion circular map */
|
| 5615 |
.pm-sel { stroke: var(--brand); stroke-width: 15; opacity: 0.42; stroke-linecap: round; }
|
| 5616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5617 |
@media (max-width: 640px) {
|
| 5618 |
.seqtool-input { width: 100%; }
|
| 5619 |
.seqtool-find { width: 100%; }
|
|
|
|
| 5614 |
/* selection arc mirrored on the companion circular map */
|
| 5615 |
.pm-sel { stroke: var(--brand); stroke-width: 15; opacity: 0.42; stroke-linecap: round; }
|
| 5616 |
|
| 5617 |
+
/* ── Pairwise alignment card ── */
|
| 5618 |
+
.plasmid-align-card .align-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 10px 0; }
|
| 5619 |
+
@media (max-width: 700px) { .plasmid-align-card .align-inputs { grid-template-columns: 1fr; } }
|
| 5620 |
+
.align-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
| 5621 |
+
.align-stats { font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); }
|
| 5622 |
+
.align-output { margin-top: 14px; overflow-x: auto; }
|
| 5623 |
+
.align-block { margin-bottom: 10px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; white-space: pre; }
|
| 5624 |
+
.align-row { white-space: pre; }
|
| 5625 |
+
.align-mid { color: var(--ink-faint); }
|
| 5626 |
+
|
| 5627 |
@media (max-width: 640px) {
|
| 5628 |
.seqtool-input { width: 100%; }
|
| 5629 |
.seqtool-find { width: 100%; }
|
dee/static/app.js
CHANGED
|
@@ -8026,3 +8026,51 @@ function runOracle(opts){
|
|
| 8026 |
outcomes: [{ subject, measured_value: parseFloat($('coResult').value), outcome: 'measured' }] };
|
| 8027 |
});
|
| 8028 |
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8026 |
outcomes: [{ subject, measured_value: parseFloat($('coResult').value), outcome: 'measured' }] };
|
| 8027 |
});
|
| 8028 |
})();
|
| 8029 |
+
|
| 8030 |
+
// ── Pairwise sequence alignment (Plasmid workbench card → /api/align) ──
|
| 8031 |
+
(function initAlign() {
|
| 8032 |
+
const A = document.getElementById('alignA');
|
| 8033 |
+
const B = document.getElementById('alignB');
|
| 8034 |
+
const runBtn = document.getElementById('alignRun');
|
| 8035 |
+
const out = document.getElementById('alignOutput');
|
| 8036 |
+
const stats = document.getElementById('alignStats');
|
| 8037 |
+
if (!A || !B || !runBtn || !out) return;
|
| 8038 |
+
let mode = 'global';
|
| 8039 |
+
document.querySelectorAll('[data-align-mode]').forEach((btn) => {
|
| 8040 |
+
btn.addEventListener('click', () => {
|
| 8041 |
+
mode = btn.getAttribute('data-align-mode');
|
| 8042 |
+
document.querySelectorAll('[data-align-mode]').forEach((b) =>
|
| 8043 |
+
b.classList.toggle('clone-method-active', b === btn));
|
| 8044 |
+
});
|
| 8045 |
+
});
|
| 8046 |
+
function render(r) {
|
| 8047 |
+
const W = 60, aa = r.aligned_a || '', bb = r.aligned_b || '', mid = r.midline || '';
|
| 8048 |
+
let html = '';
|
| 8049 |
+
for (let i = 0; i < aa.length; i += W) {
|
| 8050 |
+
const n = Math.min(i + W, aa.length);
|
| 8051 |
+
html += `<div class="align-block">`
|
| 8052 |
+
+ `<div class="align-row">${i + 1} ${escapeHtml(aa.slice(i, n))}</div>`
|
| 8053 |
+
+ `<div class="align-row align-mid">${' '.repeat(String(i + 1).length + 1)}${escapeHtml(mid.slice(i, n))}</div>`
|
| 8054 |
+
+ `<div class="align-row">${i + 1} ${escapeHtml(bb.slice(i, n))}</div></div>`;
|
| 8055 |
+
}
|
| 8056 |
+
out.innerHTML = html;
|
| 8057 |
+
out.hidden = false;
|
| 8058 |
+
stats.textContent = `${r.mode} · ${r.identity}% identity · ${r.matches}/${r.length} matches · ${r.gaps} gaps · score ${r.score}`;
|
| 8059 |
+
}
|
| 8060 |
+
runBtn.addEventListener('click', async () => {
|
| 8061 |
+
const a = (A.value || '').trim(), b = (B.value || '').trim();
|
| 8062 |
+
if (!a || !b) { stats.textContent = 'Paste two sequences first.'; out.hidden = true; return; }
|
| 8063 |
+
runBtn.disabled = true; stats.textContent = 'Aligning…'; out.hidden = true;
|
| 8064 |
+
try {
|
| 8065 |
+
const res = await fetch('/api/align', {
|
| 8066 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 8067 |
+
body: JSON.stringify({ a, b, mode }),
|
| 8068 |
+
});
|
| 8069 |
+
const data = await res.json().catch(() => ({}));
|
| 8070 |
+
if (!res.ok || data.error) { stats.textContent = data.error || `Alignment failed (${res.status}).`; }
|
| 8071 |
+
else render(data);
|
| 8072 |
+
} catch (e) {
|
| 8073 |
+
stats.textContent = 'Network error — try again.';
|
| 8074 |
+
} finally { runBtn.disabled = false; }
|
| 8075 |
+
});
|
| 8076 |
+
})();
|
dee/static/index.html
CHANGED
|
@@ -28,7 +28,7 @@
|
|
| 28 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 29 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 30 |
without them, users keep getting the stale file for up to a week. -->
|
| 31 |
-
<link rel="stylesheet" href="/static/app.css?v=20260609-
|
| 32 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 33 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 34 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
@@ -1270,6 +1270,27 @@
|
|
| 1270 |
circular-aware. Auto-annotation flags ORFs + common motifs; GenBank features are kept as-is.</p>
|
| 1271 |
</section>
|
| 1272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1273 |
<!-- Cloning / assembly -->
|
| 1274 |
<section class="card plasmid-clone-card">
|
| 1275 |
<div class="clone-head">
|
|
@@ -1816,6 +1837,6 @@
|
|
| 1816 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 1817 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 1818 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 1819 |
-
<script src="/static/app.js?v=20260609-
|
| 1820 |
</body>
|
| 1821 |
</html>
|
|
|
|
| 28 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 29 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 30 |
without them, users keep getting the stale file for up to a week. -->
|
| 31 |
+
<link rel="stylesheet" href="/static/app.css?v=20260609-align" />
|
| 32 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 33 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 34 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
|
|
| 1270 |
circular-aware. Auto-annotation flags ORFs + common motifs; GenBank features are kept as-is.</p>
|
| 1271 |
</section>
|
| 1272 |
|
| 1273 |
+
<!-- Pairwise sequence alignment -->
|
| 1274 |
+
<section class="card plasmid-align-card">
|
| 1275 |
+
<div class="clone-head">
|
| 1276 |
+
<h2 class="clone-title">Align two sequences</h2>
|
| 1277 |
+
<div class="clone-methods" role="tablist">
|
| 1278 |
+
<button class="clone-method clone-method-active" data-align-mode="global" type="button">Global</button>
|
| 1279 |
+
<button class="clone-method" data-align-mode="local" type="button">Local</button>
|
| 1280 |
+
</div>
|
| 1281 |
+
</div>
|
| 1282 |
+
<p class="field-hint">Compare two DNA or protein sequences — percent identity, mismatches and gaps. <strong>Global</strong> aligns end-to-end (Needleman–Wunsch); <strong>Local</strong> finds the best shared region (Smith–Waterman).</p>
|
| 1283 |
+
<div class="align-inputs">
|
| 1284 |
+
<textarea id="alignA" class="primer-textarea mono" rows="3" spellcheck="false" placeholder="Sequence A — ACGT… or protein"></textarea>
|
| 1285 |
+
<textarea id="alignB" class="primer-textarea mono" rows="3" spellcheck="false" placeholder="Sequence B"></textarea>
|
| 1286 |
+
</div>
|
| 1287 |
+
<div class="align-actions">
|
| 1288 |
+
<button class="primary" type="button" id="alignRun">Align</button>
|
| 1289 |
+
<span class="align-stats" id="alignStats"></span>
|
| 1290 |
+
</div>
|
| 1291 |
+
<div class="align-output" id="alignOutput" hidden></div>
|
| 1292 |
+
</section>
|
| 1293 |
+
|
| 1294 |
<!-- Cloning / assembly -->
|
| 1295 |
<section class="card plasmid-clone-card">
|
| 1296 |
<div class="clone-head">
|
|
|
|
| 1837 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 1838 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 1839 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 1840 |
+
<script src="/static/app.js?v=20260609-align" defer></script>
|
| 1841 |
</body>
|
| 1842 |
</html>
|
tests/test_align.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for pairwise alignment (dee/core/align.py)."""
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from dee.core import align as A
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_identical_global():
|
| 8 |
+
r = A.align("ACGTACGT", "ACGTACGT")
|
| 9 |
+
assert r["identity"] == 100.0
|
| 10 |
+
assert r["gaps"] == 0
|
| 11 |
+
assert r["aligned_a"] == r["aligned_b"] == "ACGTACGT"
|
| 12 |
+
assert r["mode"] == "global"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_single_mismatch():
|
| 16 |
+
r = A.align("ACGTACGT", "ACGTTCGT")
|
| 17 |
+
assert r["gaps"] == 0
|
| 18 |
+
assert r["length"] == 8
|
| 19 |
+
assert r["matches"] == 7
|
| 20 |
+
assert 80.0 < r["identity"] < 100.0
|
| 21 |
+
assert r["midline"].count(".") == 1 # one mismatch column
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_insertion_makes_a_gap():
|
| 25 |
+
# b has an extra base → a gap appears in the alignment
|
| 26 |
+
r = A.align("ACGTACGT", "ACGTAACGT")
|
| 27 |
+
assert r["gaps"] >= 1
|
| 28 |
+
assert "-" in r["aligned_a"]
|
| 29 |
+
# all original bases still match where aligned
|
| 30 |
+
assert r["matches"] == 8
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_local_finds_embedded_region():
|
| 34 |
+
a = "GGGGGGACGTACGTGGGGGG"
|
| 35 |
+
b = "TTTTACGTACGTTTTT"
|
| 36 |
+
r = A.align(a, b, mode="local")
|
| 37 |
+
assert r["mode"] == "local"
|
| 38 |
+
# the shared ACGTACGT core should align at 100% identity, no gaps
|
| 39 |
+
assert "ACGTACGT" in r["aligned_a"].replace("-", "")
|
| 40 |
+
assert r["identity"] == 100.0
|
| 41 |
+
assert r["gaps"] == 0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_whitespace_and_case_normalised():
|
| 45 |
+
r = A.align("ac gt\nac gt", "ACGTACGT")
|
| 46 |
+
assert r["identity"] == 100.0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_empty_raises():
|
| 50 |
+
with pytest.raises(ValueError):
|
| 51 |
+
A.align("", "ACGT")
|
| 52 |
+
with pytest.raises(ValueError):
|
| 53 |
+
A.align("ACGT", " ")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_oversize_raises():
|
| 57 |
+
big = "A" * 2000
|
| 58 |
+
with pytest.raises(ValueError):
|
| 59 |
+
A.align(big, big) # 4,000,000 cells > MAX_CELLS
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_midline_lengths_consistent():
|
| 63 |
+
r = A.align("ACGTACGTAC", "ACGAACGTTC")
|
| 64 |
+
assert len(r["aligned_a"]) == len(r["aligned_b"]) == len(r["midline"]) == r["length"]
|