czty's picture
Add files using upload-large-folder tool
d1ce356 verified
Raw
History Blame Contribute Delete
31.6 kB
from __future__ import annotations
import csv
import json
import os
import shutil
import sqlite3
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
try:
from Bio import SeqIO # type: ignore
except Exception: # pragma: no cover
SeqIO = None
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CACHE_ROOT = PROJECT_ROOT / "data" / "biomni_data" / "local_mcp_cache"
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _cache_root() -> Path:
raw = os.getenv("HYPOBIOOS_LOCAL_MCP_DATA")
root = Path(raw) if raw else DEFAULT_CACHE_ROOT
root.mkdir(parents=True, exist_ok=True)
(root / "blast_db").mkdir(exist_ok=True)
(root / "collections").mkdir(exist_ok=True)
return root
def _db_path() -> Path:
return _cache_root() / "local_bio_cache.sqlite3"
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(_db_path())
conn.row_factory = sqlite3.Row
_ensure_schema(conn)
return conn
def _ensure_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sequence_collections (
name TEXT PRIMARY KEY,
molecule_type TEXT NOT NULL,
source TEXT,
description TEXT,
fasta_path TEXT,
blast_db_prefix TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sequences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_name TEXT NOT NULL,
accession TEXT,
label TEXT,
organism TEXT,
description TEXT,
sequence TEXT NOT NULL,
seq_length INTEGER NOT NULL,
header TEXT,
FOREIGN KEY(collection_name) REFERENCES sequence_collections(name)
);
CREATE INDEX IF NOT EXISTS idx_sequences_collection ON sequences(collection_name);
CREATE INDEX IF NOT EXISTS idx_sequences_accession ON sequences(accession);
CREATE INDEX IF NOT EXISTS idx_sequences_label ON sequences(label);
CREATE TABLE IF NOT EXISTS tf_binding_collections (
name TEXT PRIMARY KEY,
source TEXT,
description TEXT,
table_path TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tf_binding_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_name TEXT NOT NULL,
tf_name TEXT,
target_gene TEXT,
cell_type TEXT,
biosample TEXT,
evidence TEXT,
score REAL,
chrom TEXT,
start INTEGER,
end INTEGER,
source TEXT,
raw_json TEXT NOT NULL,
FOREIGN KEY(collection_name) REFERENCES tf_binding_collections(name)
);
CREATE INDEX IF NOT EXISTS idx_tf_collection ON tf_binding_records(collection_name);
CREATE INDEX IF NOT EXISTS idx_tf_name ON tf_binding_records(tf_name);
CREATE INDEX IF NOT EXISTS idx_tf_target_gene ON tf_binding_records(target_gene);
CREATE INDEX IF NOT EXISTS idx_tf_cell_type ON tf_binding_records(cell_type);
"""
)
conn.commit()
def initialize_local_bio_cache() -> dict[str, str]:
root = _cache_root()
with _connect():
pass
return {
"cache_root": str(root),
"sqlite_db": str(_db_path()),
}
def list_sequence_collections() -> list[dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT c.name, c.molecule_type, c.source, c.description, c.fasta_path, c.blast_db_prefix, c.created_at,
COUNT(s.id) AS sequence_count
FROM sequence_collections c
LEFT JOIN sequences s ON s.collection_name = c.name
GROUP BY c.name
ORDER BY c.name
"""
).fetchall()
return [dict(row) for row in rows]
def list_tf_binding_collections() -> list[dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT c.name, c.source, c.description, c.table_path, c.created_at,
COUNT(r.id) AS record_count
FROM tf_binding_collections c
LEFT JOIN tf_binding_records r ON r.collection_name = c.name
GROUP BY c.name
ORDER BY c.name
"""
).fetchall()
return [dict(row) for row in rows]
def register_sequence_collection(
collection_name: str,
fasta_path: str,
molecule_type: str = "protein",
source: str = "local",
description: str = "",
rebuild: bool = True,
) -> dict[str, Any]:
path = Path(fasta_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"FASTA file not found: {path}")
records = list(_iter_fasta_records(path))
if not records:
raise ValueError(f"No FASTA records found in: {path}")
collection_dir = _cache_root() / "collections" / collection_name
collection_dir.mkdir(parents=True, exist_ok=True)
cached_fasta = collection_dir / path.name
if cached_fasta.resolve() != path:
shutil.copy2(path, cached_fasta)
else:
cached_fasta = path
blast_db_prefix = None
if rebuild:
blast_db_prefix = _maybe_build_blast_db(cached_fasta, collection_name, molecule_type)
with _connect() as conn:
conn.execute("DELETE FROM sequences WHERE collection_name = ?", (collection_name,))
conn.execute(
"""
INSERT INTO sequence_collections(name, molecule_type, source, description, fasta_path, blast_db_prefix, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
molecule_type=excluded.molecule_type,
source=excluded.source,
description=excluded.description,
fasta_path=excluded.fasta_path,
blast_db_prefix=excluded.blast_db_prefix,
created_at=excluded.created_at
""",
(
collection_name,
molecule_type,
source,
description,
str(cached_fasta),
blast_db_prefix,
_utc_now(),
),
)
conn.executemany(
"""
INSERT INTO sequences(
collection_name, accession, label, organism, description, sequence, seq_length, header
) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
collection_name,
record.accession,
record.label,
record.organism,
record.description,
record.sequence,
len(record.sequence),
record.header,
)
for record in records
],
)
conn.commit()
return {
"collection_name": collection_name,
"sequence_count": len(records),
"molecule_type": molecule_type,
"source": source,
"cached_fasta": str(cached_fasta),
"blast_db_prefix": blast_db_prefix,
"blast_db_built": bool(blast_db_prefix),
}
def remove_sequence_collection(collection_name: str) -> dict[str, Any]:
with _connect() as conn:
row = conn.execute(
"SELECT fasta_path, blast_db_prefix FROM sequence_collections WHERE name = ?",
(collection_name,),
).fetchone()
if row is None:
return {"removed": False, "collection_name": collection_name, "reason": "not found"}
conn.execute("DELETE FROM sequences WHERE collection_name = ?", (collection_name,))
conn.execute("DELETE FROM sequence_collections WHERE name = ?", (collection_name,))
conn.commit()
fasta_path = row["fasta_path"]
blast_db_prefix = row["blast_db_prefix"]
if fasta_path:
_best_effort_remove_path(Path(fasta_path))
if blast_db_prefix:
for suffix in (".pdb", ".phr", ".pin", ".pog", ".psq", ".ndb", ".nhr", ".nin", ".nog", ".nsq"):
_best_effort_remove_path(Path(str(blast_db_prefix) + suffix))
return {"removed": True, "collection_name": collection_name}
def local_blast_search(
query_sequence: str,
collection_name: str = "",
top_k: int = 5,
min_identity: float = 0.35,
prefer_blast: bool = True,
) -> dict[str, Any]:
normalized_query = _normalize_sequence(query_sequence)
if not normalized_query:
raise ValueError("Query sequence is empty after normalization.")
collection = _resolve_sequence_collection(collection_name)
if collection is None:
raise ValueError(f"Unknown sequence collection: {collection_name or '(empty)'}")
if prefer_blast:
blast_result = _run_local_blast(normalized_query, collection, top_k=top_k)
if blast_result is not None:
blast_result["fallback_used"] = False
return blast_result
sequences = _load_sequences(collection["name"])
hits = _search_sequences_python(normalized_query, sequences, top_k=top_k, min_identity=min_identity)
return {
"method": "python_fallback",
"collection_name": collection["name"],
"query_length": len(normalized_query),
"fallback_used": True,
"hits": hits,
}
def local_uniprot_sequence_search(
query_sequence: str,
collection_name: str = "",
top_k: int = 5,
min_identity: float = 0.35,
) -> dict[str, Any]:
resolved = collection_name
if not resolved:
for item in list_sequence_collections():
name = str(item.get("name", "")).lower()
source = str(item.get("source", "")).lower()
if "uniprot" in name or "uniprot" in source:
resolved = item["name"]
break
return local_blast_search(
query_sequence=query_sequence,
collection_name=resolved,
top_k=top_k,
min_identity=min_identity,
prefer_blast=True,
)
def register_tf_binding_table(
collection_name: str,
table_path: str,
source: str = "local",
description: str = "",
file_format: str = "auto",
) -> dict[str, Any]:
path = Path(table_path).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"TF binding table not found: {path}")
rows = list(_iter_table_rows(path, file_format=file_format))
if not rows:
raise ValueError(f"No rows found in TF binding file: {path}")
collection_dir = _cache_root() / "collections" / collection_name
collection_dir.mkdir(parents=True, exist_ok=True)
cached_table = collection_dir / path.name
if cached_table.resolve() != path:
shutil.copy2(path, cached_table)
else:
cached_table = path
normalized_rows = [_normalize_tf_row(row, source=source) for row in rows]
with _connect() as conn:
conn.execute("DELETE FROM tf_binding_records WHERE collection_name = ?", (collection_name,))
conn.execute(
"""
INSERT INTO tf_binding_collections(name, source, description, table_path, created_at)
VALUES(?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
source=excluded.source,
description=excluded.description,
table_path=excluded.table_path,
created_at=excluded.created_at
""",
(
collection_name,
source,
description,
str(cached_table),
_utc_now(),
),
)
conn.executemany(
"""
INSERT INTO tf_binding_records(
collection_name, tf_name, target_gene, cell_type, biosample, evidence, score,
chrom, start, end, source, raw_json
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
(
collection_name,
row.get("tf_name"),
row.get("target_gene"),
row.get("cell_type"),
row.get("biosample"),
row.get("evidence"),
row.get("score"),
row.get("chrom"),
row.get("start"),
row.get("end"),
row.get("source"),
json.dumps(row.get("raw", {}), ensure_ascii=False),
)
for row in normalized_rows
],
)
conn.commit()
return {
"collection_name": collection_name,
"record_count": len(normalized_rows),
"source": source,
"cached_table": str(cached_table),
}
def remove_tf_binding_collection(collection_name: str) -> dict[str, Any]:
with _connect() as conn:
row = conn.execute(
"SELECT table_path FROM tf_binding_collections WHERE name = ?",
(collection_name,),
).fetchone()
if row is None:
return {"removed": False, "collection_name": collection_name, "reason": "not found"}
conn.execute("DELETE FROM tf_binding_records WHERE collection_name = ?", (collection_name,))
conn.execute("DELETE FROM tf_binding_collections WHERE name = ?", (collection_name,))
conn.commit()
if row["table_path"]:
_best_effort_remove_path(Path(row["table_path"]))
return {"removed": True, "collection_name": collection_name}
def query_local_tf_binding(
tf_name: str = "",
target_gene: str = "",
cell_type: str = "",
collection_name: str = "",
top_k: int = 20,
) -> dict[str, Any]:
clauses = []
values: list[Any] = []
if collection_name:
clauses.append("collection_name = ?")
values.append(collection_name)
if tf_name:
clauses.append("LOWER(COALESCE(tf_name, '')) LIKE ?")
values.append(f"%{tf_name.lower()}%")
if target_gene:
clauses.append("LOWER(COALESCE(target_gene, '')) LIKE ?")
values.append(f"%{target_gene.lower()}%")
if cell_type:
clauses.append("(LOWER(COALESCE(cell_type, '')) LIKE ? OR LOWER(COALESCE(biosample, '')) LIKE ?)")
values.extend([f"%{cell_type.lower()}%", f"%{cell_type.lower()}%"])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
sql = (
"SELECT collection_name, tf_name, target_gene, cell_type, biosample, evidence, score, chrom, start, end, source, raw_json "
f"FROM tf_binding_records {where_sql} "
"ORDER BY COALESCE(score, 0) DESC, tf_name, target_gene "
"LIMIT ?"
)
values.append(top_k)
with _connect() as conn:
rows = conn.execute(sql, values).fetchall()
hits = []
for row in rows:
payload = dict(row)
payload["raw"] = json.loads(payload.pop("raw_json"))
hits.append(payload)
return {
"collection_name": collection_name or "all",
"filters": {"tf_name": tf_name, "target_gene": target_gene, "cell_type": cell_type},
"hit_count": len(hits),
"hits": hits,
}
def local_pairwise_alignment(
sequence_a: str,
sequence_b: str,
mode: str = "global",
match_score: int = 2,
mismatch_score: int = -1,
gap_score: int = -2,
) -> dict[str, Any]:
seq_a = _normalize_sequence(sequence_a)
seq_b = _normalize_sequence(sequence_b)
if not seq_a or not seq_b:
raise ValueError("Both sequences must contain non-empty characters after normalization.")
if mode not in {"global", "local"}:
raise ValueError("mode must be 'global' or 'local'")
alignment = _needleman_wunsch(seq_a, seq_b, match_score, mismatch_score, gap_score, local=(mode == "local"))
return alignment
@dataclass
class SequenceRecord:
accession: str
label: str
organism: str
description: str
sequence: str
header: str
def _iter_fasta_records(path: Path) -> list[SequenceRecord]:
if SeqIO is not None:
records = []
for entry in SeqIO.parse(str(path), "fasta"):
header = str(entry.description)
accession, label, organism, description = _parse_fasta_header(header)
records.append(
SequenceRecord(
accession=accession,
label=label,
organism=organism,
description=description,
sequence=_normalize_sequence(str(entry.seq)),
header=header,
)
)
return [record for record in records if record.sequence]
records: list[SequenceRecord] = []
header = None
chunks: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
if line.startswith(">"):
if header is not None:
accession, label, organism, description = _parse_fasta_header(header)
sequence = _normalize_sequence("".join(chunks))
if sequence:
records.append(
SequenceRecord(
accession=accession,
label=label,
organism=organism,
description=description,
sequence=sequence,
header=header,
)
)
header = line[1:].strip()
chunks = []
else:
chunks.append(line.strip())
if header is not None:
accession, label, organism, description = _parse_fasta_header(header)
sequence = _normalize_sequence("".join(chunks))
if sequence:
records.append(
SequenceRecord(
accession=accession,
label=label,
organism=organism,
description=description,
sequence=sequence,
header=header,
)
)
return records
def _parse_fasta_header(header: str) -> tuple[str, str, str, str]:
parts = header.split()
accession = parts[0] if parts else header[:40]
label = accession
organism = ""
description = header
if "|OS=" in header:
description, _, rest = header.partition("OS=")
organism = rest.split(" OX=")[0].strip()
description = description.strip()
return accession, label, organism, description
def _maybe_build_blast_db(fasta_path: Path, collection_name: str, molecule_type: str) -> str | None:
makeblastdb = shutil.which("makeblastdb")
if not makeblastdb:
return None
dbtype = "prot" if molecule_type.lower().startswith("prot") else "nucl"
out_prefix = _cache_root() / "blast_db" / collection_name
cmd = [makeblastdb, "-in", str(fasta_path), "-dbtype", dbtype, "-out", str(out_prefix)]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
except Exception:
return None
return str(out_prefix)
def _resolve_sequence_collection(collection_name: str) -> dict[str, Any] | None:
with _connect() as conn:
if collection_name:
row = conn.execute(
"SELECT * FROM sequence_collections WHERE name = ?",
(collection_name,),
).fetchone()
return dict(row) if row else None
row = conn.execute(
"SELECT * FROM sequence_collections ORDER BY created_at DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def _load_sequences(collection_name: str) -> list[dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT accession, label, organism, description, sequence, seq_length, header
FROM sequences
WHERE collection_name = ?
""",
(collection_name,),
).fetchall()
return [dict(row) for row in rows]
def _run_local_blast(query_sequence: str, collection: dict[str, Any], top_k: int) -> dict[str, Any] | None:
blast_prefix = collection.get("blast_db_prefix")
molecule_type = str(collection.get("molecule_type", "protein")).lower()
if not blast_prefix:
return None
blast_binary = shutil.which("blastp" if molecule_type.startswith("prot") else "blastn")
if not blast_binary:
return None
with tempfile.TemporaryDirectory(prefix="hypobioos_local_blast_") as tmp:
tmp_dir = Path(tmp)
query_path = tmp_dir / "query.fa"
out_path = tmp_dir / "hits.tsv"
query_path.write_text(">query\n" + query_sequence + "\n", encoding="utf-8")
outfmt = "6 sseqid pident length qstart qend sstart send evalue bitscore"
cmd = [
blast_binary,
"-query",
str(query_path),
"-db",
str(blast_prefix),
"-outfmt",
outfmt,
"-max_target_seqs",
str(max(top_k, 10)),
"-out",
str(out_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
except Exception:
return None
hits = []
if out_path.exists():
for line in out_path.read_text(encoding="utf-8").splitlines():
fields = line.split("\t")
if len(fields) != 9:
continue
hits.append(
{
"accession": fields[0],
"identity": round(float(fields[1]) / 100.0, 4),
"aligned_length": int(fields[2]),
"query_start": int(fields[3]),
"query_end": int(fields[4]),
"subject_start": int(fields[5]),
"subject_end": int(fields[6]),
"evalue": fields[7],
"bitscore": float(fields[8]),
}
)
return {
"method": "local_blast",
"collection_name": collection["name"],
"query_length": len(query_sequence),
"fallback_used": False,
"hits": hits[:top_k],
}
def _search_sequences_python(
query_sequence: str,
sequences: list[dict[str, Any]],
*,
top_k: int,
min_identity: float,
) -> list[dict[str, Any]]:
fragment_queries = [query_sequence]
if len(query_sequence) >= 300:
fragment_size = max(80, len(query_sequence) // 4)
fragment_queries = [
query_sequence[start : start + fragment_size]
for start in range(0, len(query_sequence) - fragment_size + 1, max(fragment_size // 2, 1))
][:4]
scored = []
for row in sequences:
target = row["sequence"]
best = _best_sequence_match(query_sequence, target)
fragment_best = best
if best["identity"] < min_identity and len(fragment_queries) > 1:
for fragment in fragment_queries:
candidate = _best_sequence_match(fragment, target)
if candidate["identity"] > fragment_best["identity"]:
fragment_best = candidate | {"fragment_query_length": len(fragment)}
result = fragment_best
if result["identity"] < min_identity:
continue
scored.append(
{
"accession": row.get("accession"),
"label": row.get("label"),
"organism": row.get("organism"),
"description": row.get("description"),
"identity": round(result["identity"], 4),
"coverage": round(result["coverage"], 4),
"subject_start": result["subject_start"],
"subject_end": result["subject_end"],
"query_length": result["query_length"],
"subject_length": len(target),
"matched_via_fragment": "fragment_query_length" in result,
"fragment_query_length": result.get("fragment_query_length"),
}
)
scored.sort(key=lambda item: (item["identity"], item["coverage"], -(item["subject_length"])), reverse=True)
return scored[:top_k]
def _best_sequence_match(query: str, target: str) -> dict[str, Any]:
if not query or not target:
return {"identity": 0.0, "coverage": 0.0, "subject_start": 0, "subject_end": 0, "query_length": len(query)}
if len(query) <= len(target):
best_identity = 0.0
best_start = 0
for start in range(0, len(target) - len(query) + 1):
window = target[start : start + len(query)]
matches = sum(1 for a, b in zip(query, window, strict=False) if a == b)
identity = matches / len(query)
if identity > best_identity:
best_identity = identity
best_start = start
return {
"identity": best_identity,
"coverage": 1.0,
"subject_start": best_start + 1,
"subject_end": best_start + len(query),
"query_length": len(query),
}
best = _best_sequence_match(target, query)
return {
"identity": best["identity"],
"coverage": len(target) / len(query),
"subject_start": 1,
"subject_end": len(target),
"query_length": len(query),
}
def _iter_table_rows(path: Path, *, file_format: str) -> list[dict[str, Any]]:
suffix = path.suffix.lower()
resolved_format = file_format
if resolved_format == "auto":
if suffix in {".jsonl", ".ndjson"}:
resolved_format = "jsonl"
elif suffix == ".json":
resolved_format = "json"
elif suffix == ".csv":
resolved_format = "csv"
else:
resolved_format = "tsv"
if resolved_format == "jsonl":
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
if resolved_format == "json":
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, list):
return payload
raise ValueError("JSON TF binding file must contain a list of row objects.")
delimiter = "," if resolved_format == "csv" else "\t"
with path.open("r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle, delimiter=delimiter))
def _normalize_tf_row(row: dict[str, Any], *, source: str) -> dict[str, Any]:
lowered = {str(key).lower().strip(): value for key, value in row.items()}
def pick(*names: str) -> Any:
for name in names:
if name in lowered and lowered[name] not in {None, ""}:
return lowered[name]
return None
score = pick("score", "signal", "qvalue", "pvalue", "fold_enrichment")
try:
score_value = float(score) if score not in {None, ""} else None
except Exception:
score_value = None
start = pick("start", "chromstart", "peak_start")
end = pick("end", "chromend", "peak_end")
try:
start_value = int(float(start)) if start not in {None, ""} else None
except Exception:
start_value = None
try:
end_value = int(float(end)) if end not in {None, ""} else None
except Exception:
end_value = None
return {
"tf_name": _string_or_none(pick("tf_name", "tf", "transcription_factor", "factor", "gene_symbol")),
"target_gene": _string_or_none(pick("target_gene", "target", "gene", "gene_name", "target_symbol")),
"cell_type": _string_or_none(pick("cell_type", "cell", "cell line", "cell_line", "tissue")),
"biosample": _string_or_none(pick("biosample", "sample", "biosample_name")),
"evidence": _string_or_none(pick("evidence", "assay", "experiment", "peak_id", "dataset")),
"score": score_value,
"chrom": _string_or_none(pick("chrom", "chr", "chromosome")),
"start": start_value,
"end": end_value,
"source": _string_or_none(pick("source")) or source,
"raw": row,
}
def _needleman_wunsch(
seq_a: str,
seq_b: str,
match_score: int,
mismatch_score: int,
gap_score: int,
*,
local: bool,
) -> dict[str, Any]:
rows = len(seq_a) + 1
cols = len(seq_b) + 1
score = [[0] * cols for _ in range(rows)]
trace = [[""] * cols for _ in range(rows)]
if not local:
for i in range(1, rows):
score[i][0] = i * gap_score
trace[i][0] = "U"
for j in range(1, cols):
score[0][j] = j * gap_score
trace[0][j] = "L"
best_i = 0
best_j = 0
best_score = 0 if local else float("-inf")
for i in range(1, rows):
for j in range(1, cols):
diag = score[i - 1][j - 1] + (match_score if seq_a[i - 1] == seq_b[j - 1] else mismatch_score)
up = score[i - 1][j] + gap_score
left = score[i][j - 1] + gap_score
candidates = [(diag, "D"), (up, "U"), (left, "L")]
if local:
candidates.append((0, "0"))
cell_score, cell_trace = max(candidates, key=lambda item: item[0])
score[i][j] = cell_score
trace[i][j] = cell_trace
if cell_score > best_score:
best_score = cell_score
best_i = i
best_j = j
if local:
i, j = best_i, best_j
final_score = best_score
else:
i, j = len(seq_a), len(seq_b)
final_score = score[i][j]
aligned_a: list[str] = []
aligned_b: list[str] = []
while i > 0 or j > 0:
direction = trace[i][j]
if local and (direction == "0" or score[i][j] == 0):
break
if direction == "D":
aligned_a.append(seq_a[i - 1])
aligned_b.append(seq_b[j - 1])
i -= 1
j -= 1
elif direction == "U":
aligned_a.append(seq_a[i - 1])
aligned_b.append("-")
i -= 1
elif direction == "L":
aligned_a.append("-")
aligned_b.append(seq_b[j - 1])
j -= 1
else:
break
aligned_a_str = "".join(reversed(aligned_a))
aligned_b_str = "".join(reversed(aligned_b))
aligned_pairs = [(a, b) for a, b in zip(aligned_a_str, aligned_b_str, strict=False) if a != "-" and b != "-"]
matches = sum(1 for a, b in aligned_pairs if a == b)
identity = matches / len(aligned_pairs) if aligned_pairs else 0.0
coverage_a = sum(1 for char in aligned_a_str if char != "-") / len(seq_a) if seq_a else 0.0
coverage_b = sum(1 for char in aligned_b_str if char != "-") / len(seq_b) if seq_b else 0.0
return {
"mode": "local" if local else "global",
"score": final_score,
"identity": round(identity, 4),
"coverage_a": round(coverage_a, 4),
"coverage_b": round(coverage_b, 4),
"aligned_sequence_a": aligned_a_str,
"aligned_sequence_b": aligned_b_str,
}
def _normalize_sequence(sequence: str) -> str:
return "".join(ch for ch in sequence.upper() if ch.isalpha())
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _best_effort_remove_path(path: Path) -> None:
try:
if path.exists():
path.unlink()
except Exception:
pass