Spaces:
Running
Running
Download bioai-platform/backend/app/tools/sequence_utilities.py from Samad14/bio-nexus-api: direct link, hf CLI and curl.
- Browser
- Download file 8.76 kB
-
https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/backend/app/tools/sequence_utilities.py
- Command line
-
hf download hf://spaces/Samad14/bio-nexus-api/bioai-platform/backend/app/tools/sequence_utilities.py
-
curl -L -o sequence_utilities.py https://huggingface.co/spaces/Samad14/bio-nexus-api/resolve/main/bioai-platform/backend/app/tools/sequence_utilities.py
8.76 kB
| """Sequence utilities toolkit. | |
| Computes the everyday metrics a biologist needs on a single sequence: | |
| * GC content (nucleotides) | |
| * Reverse complement (nucleotides, IUPAC-aware) | |
| * Molecular weight (ssDNA / ssRNA / protein average-residue) | |
| * Six-frame-free translation of the forward three frames with the longest | |
| open reading frame (ORF) flagged | |
| * Amino-acid composition (proteins / translated CDS) | |
| * Restriction-enzyme site scan against a curated set of common, unambiguous | |
| (palindromic) recognition sequences | |
| Pure local computation — no network calls. Mirrors the "Sequence Stats" | |
| workflow every bench tool (ExPASy, BioPython scripts) implements. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from Bio.SeqUtils.ProtParam import ProteinAnalysis | |
| from app.services.sequence_utils import detect_sequence_type | |
| class SequenceUtilitiesError(ValueError): | |
| pass | |
| # Standard genetic code — stop codons map to '*', ambiguous codons to 'X'. | |
| _CODON_TABLE = { | |
| "TTT": "F", "TTC": "F", "TTA": "L", "TTG": "L", | |
| "TCT": "S", "TCC": "S", "TCA": "S", "TCG": "S", | |
| "TAT": "Y", "TAC": "Y", "TAA": "*", "TAG": "*", | |
| "TGT": "C", "TGC": "C", "TGA": "*", "TGG": "W", | |
| "CTT": "L", "CTC": "L", "CTA": "L", "CTG": "L", | |
| "CCT": "P", "CCC": "P", "CCA": "P", "CCG": "P", | |
| "CAT": "H", "CAC": "H", "CAA": "Q", "CAG": "Q", | |
| "CGT": "R", "CGC": "R", "CGA": "R", "CGG": "R", | |
| "ATT": "I", "ATC": "I", "ATA": "I", "ATG": "M", | |
| "ACT": "T", "ACC": "T", "ACA": "T", "ACG": "T", | |
| "AAT": "N", "AAC": "N", "AAA": "K", "AAG": "K", | |
| "AGT": "S", "AGC": "S", "AGA": "R", "AGG": "R", | |
| "GTT": "V", "GTC": "V", "GTA": "V", "GTG": "V", | |
| "GCT": "A", "GCC": "A", "GCA": "A", "GCG": "A", | |
| "GAT": "D", "GAC": "D", "GAA": "E", "GAG": "E", | |
| "GGT": "G", "GGC": "G", "GGA": "G", "GGG": "G", | |
| } | |
| # Monoisotopic-ish single-strand base weights (g/mol). | |
| _SSDNA_MW = {"A": 313.21, "C": 289.18, "G": 329.21, "T": 304.20} | |
| _SSRNA_MW = {"A": 329.21, "C": 305.18, "G": 345.21, "U": 306.17} | |
| # Curated common restriction enzymes — all palindromic so a forward-strand | |
| # scan finds every cut site. Recognition site is given 5' -> 3'. | |
| _RESTRICTION_ENZYMES = [ | |
| ("EcoRI", "GAATTC"), | |
| ("BamHI", "GGATCC"), | |
| ("HindIII", "AAGCTT"), | |
| ("SalI", "GTCGAC"), | |
| ("XbaI", "TCTAGA"), | |
| ("XhoI", "CTCGAG"), | |
| ("NotI", "GCGGCCGC"), | |
| ("KpnI", "GGTACC"), | |
| ("SmaI", "CCCGGG"), | |
| ("PstI", "CTGCAG"), | |
| ("SacI", "GAGCTC"), | |
| ] | |
| _RNA_TO_DNA = str.maketrans("Uu", "Tt") | |
| _AA_ALPHABET = "ACDEFGHIKLMNPQRSTVWY" | |
| def _strip_fasta(seq: str) -> str: | |
| """Return just the sequence body of a raw or FASTA-formatted input.""" | |
| lines = (seq or "").strip().splitlines() | |
| lines = [ln.strip() for ln in lines if not ln.strip().startswith(">")] | |
| return "".join(lines) | |
| def clean_sequence(seq: str, seq_type: str) -> str: | |
| """Normalize to uppercase alpha-only. For nucleotides, drop ambiguous IUPAC | |
| codes so downstream math (GC%, MW) only counts real bases.""" | |
| body = _strip_fasta(seq) | |
| letters = "".join(re.findall(r"[A-Za-z]", body)).upper() | |
| if not letters: | |
| raise SequenceUtilitiesError("Sequence is empty") | |
| if seq_type in ("dna", "rna"): | |
| allowed = set("ACGTRYSWKMBDHVN") if seq_type == "dna" else set("ACGURSYKMWBDHVN") | |
| kept = "".join(c for c in letters if c in allowed) | |
| if not kept: | |
| raise SequenceUtilitiesError(f"Sequence contains no valid {seq_type.upper()} bases") | |
| return kept.translate(_RNA_TO_DNA) if seq_type == "rna" else kept | |
| valid = set("ACDEFGHIKLMNPQRSTVWY") | |
| kept = "".join(c for c in letters if c in valid) | |
| if not kept: | |
| raise SequenceUtilitiesError("Sequence contains no valid amino acids") | |
| return kept | |
| def _translate_frame(seq: str, frame: int) -> str: | |
| codons = [seq[i:i + 3] for i in range(frame, len(seq) - 2, 3)] | |
| return "".join(_CODON_TABLE.get(c, "X") for c in codons) | |
| def _best_orf(seq: str, frame: int, translated: str) -> dict | None: | |
| """Longest ORF (M -> stop/end) in a translated frame, with 1-based start.""" | |
| best = None | |
| for m in re.finditer("M[^*]*", translated): | |
| length = len(m.group(0)) | |
| start = frame + m.start() * 3 + 1 | |
| if best is None or length > best["length"]: | |
| best = { | |
| "frame": frame + 1, | |
| "protein": m.group(0), | |
| "start": start, | |
| "length": length, | |
| "has_stop": len(translated) > m.end() and translated[m.end()] == "*", | |
| "starts_with_m": True, | |
| } | |
| return best | |
| def _protein_mw(seq: str) -> float: | |
| try: | |
| return round(ProteinAnalysis(seq).molecular_weight(), 2) | |
| except Exception: | |
| avg = 110.0 | |
| return round(sum(avg for _ in seq), 2) | |
| def _nucleotide_mw(seq: str, seq_type: str) -> float: | |
| table = _SSDNA_MW if seq_type == "dna" else _SSRNA_MW | |
| n = len(seq) | |
| if n == 0: | |
| return 0.0 | |
| total = sum(table.get(c, table["A"]) for c in seq) | |
| return round(total - 61.96 * (n - 1) + 18.02, 2) | |
| def _aa_composition(seq: str) -> list[dict]: | |
| counts: dict[str, int] = {} | |
| for c in seq.upper(): | |
| if c in _AA_ALPHABET: | |
| counts[c] = counts.get(c, 0) + 1 | |
| total = sum(counts.values()) | |
| comp = [ | |
| {"aa": aa, "count": count, "pct": round(count / total * 100, 1) if total else 0.0} | |
| for aa, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) | |
| ] | |
| return comp | |
| def _restriction_scan(seq: str) -> list[dict]: | |
| sites = [] | |
| for name, recognition in _RESTRICTION_ENZYMES: | |
| positions = [m.start() + 1 for m in re.finditer(recognition, seq)] | |
| if positions: | |
| sites.append({"name": name, "recognition": recognition, "count": len(positions), "positions": positions}) | |
| return sites | |
| def analyze_sequence(sequence: str, seq_type: str = "auto") -> dict: | |
| """Analyze a single sequence and return a flat report dict. | |
| ``seq_type`` may be 'auto', 'dna', 'rna' or 'protein'. Auto-detection uses | |
| the shared alphabet classifier (a pure ACGTN/U string is treated as DNA). | |
| """ | |
| seq_type = (seq_type or "auto").lower() | |
| if seq_type not in ("auto", "dna", "rna", "protein"): | |
| raise SequenceUtilitiesError("seq_type must be auto, dna, rna or protein") | |
| raw = _strip_fasta(sequence) | |
| detected = detect_sequence_type(raw) if raw else "unknown" | |
| effective = seq_type if seq_type != "auto" else detected | |
| if effective == "unknown": | |
| raise SequenceUtilitiesError( | |
| "Could not detect sequence type — expected nucleotide or protein characters" | |
| ) | |
| seq = clean_sequence(raw, effective) | |
| issues: list[str] = [] | |
| report: dict = { | |
| "sequence_type": effective, | |
| "detected_type": detected, | |
| "length": len(seq), | |
| "gc_content": None, | |
| "molecular_weight": None, | |
| "reverse_complement": None, | |
| "translation": None, | |
| "aa_composition": None, | |
| "restriction_sites": None, | |
| "issues": issues, | |
| } | |
| if effective in ("dna", "rna"): | |
| report["gc_content"] = round((seq.count("G") + seq.count("C")) / len(seq) * 100.0, 1) | |
| report["molecular_weight"] = _nucleotide_mw(seq, effective) | |
| if effective == "dna": | |
| comp = {"A": "T", "T": "A", "G": "C", "C": "G", "N": "N"} | |
| else: | |
| comp = {"A": "U", "U": "A", "G": "C", "C": "G", "N": "N"} | |
| report["reverse_complement"] = "".join(comp.get(c, "N") for c in reversed(seq)) | |
| dna_seq = seq.translate(_RNA_TO_DNA) | |
| if len(dna_seq) < 3: | |
| issues.append("Sequence too short for translation (<3 nt)") | |
| else: | |
| frames = {} | |
| best = None | |
| for frame in (0, 1, 2): | |
| translated = _translate_frame(dna_seq, frame) | |
| frames[str(frame + 1)] = translated | |
| orf = _best_orf(dna_seq, frame, translated) | |
| if orf and (best is None or orf["length"] > best["length"]): | |
| best = orf | |
| report["translation"] = {"frames": frames, "best": best} | |
| if best is None: | |
| issues.append("No in-frame methionine (ATG) found — no ORF to report") | |
| else: | |
| report["aa_composition"] = _aa_composition(best["protein"]) | |
| if effective == "dna": | |
| report["restriction_sites"] = _restriction_scan(seq) | |
| else: | |
| issues.append("Restriction-site scan is DNA-only") | |
| else: | |
| report["molecular_weight"] = _protein_mw(seq) | |
| report["aa_composition"] = _aa_composition(seq) | |
| if len(seq) < 2: | |
| issues.append("Protein sequence very short — composition may be uninformative") | |
| return report | |