Tsimech2000/Codon_Translator
0
1"""Sequence validation and codon translation utilities.2 3Internally, sequences are represented as DNA (T rather than U). This keeps4reverse-complement and genetic-code logic consistent while accepting either5DNA or RNA from the user interface.6"""7 8from __future__ import annotations9 10from dataclasses import dataclass11from itertools import product12import re13 14 15IUPAC_BASES = {16 "A": "A", "C": "C", "G": "G", "T": "T", "U": "T",17 "R": "AG", "Y": "CT", "S": "GC", "W": "AT", "K": "GT",18 "M": "AC", "B": "CGT", "D": "AGT", "H": "ACT", "V": "ACG",19 "N": "ACGT",20}21 22COMPLEMENT = str.maketrans(23 "ACGTRYSWKMBDHVN", "TGCAYRSWMKVHDBN"24)25 26AA_NAMES = {27 "A": "Alanine", "R": "Arginine", "N": "Asparagine",28 "D": "Aspartic acid", "C": "Cysteine", "E": "Glutamic acid",29 "Q": "Glutamine", "G": "Glycine", "H": "Histidine",30 "I": "Isoleucine", "L": "Leucine", "K": "Lysine",31 "M": "Methionine", "F": "Phenylalanine", "P": "Proline",32 "S": "Serine", "T": "Threonine", "W": "Tryptophan",33 "Y": "Tyrosine", "V": "Valine", "*": "Stop", "X": "Ambiguous",34}35 36# Average residue masses (Da); a water molecule is added for an intact peptide.37AA_RESIDUE_MASS = {38 "A": 71.0788, "R": 156.1875, "N": 114.1038, "D": 115.0886,39 "C": 103.1388, "E": 129.1155, "Q": 128.1307, "G": 57.0519,40 "H": 137.1411, "I": 113.1594, "L": 113.1594, "K": 128.1741,41 "M": 131.1926, "F": 147.1766, "P": 97.1167, "S": 87.0782,42 "T": 101.1051, "W": 186.2132, "Y": 163.1760, "V": 99.1326,43}44 45_BASES = "TCAG"46_AA_STRING = "FFLLSSSSYY**CC*WLLLLPPPPHHQQRRRRIIIMTTTTNNKKSSRRVVVVAAAADDEEGGGG"47_STANDARD = {48 "".join(codon): aa49 for codon, aa in zip(product(_BASES, repeat=3), _AA_STRING)50}51 52GENETIC_CODES = {53 "Standard": {54 "table": _STANDARD,55 "starts": {"ATG"},56 },57 "Vertebrate mitochondrial": {58 "table": {**_STANDARD, "ATA": "M", "TGA": "W", "AGA": "*", "AGG": "*"},59 "starts": {"ATT", "ATC", "ATA", "ATG", "GTG"},60 },61}62 63AMINO_ACID_SMILES = {64 "A": "N[C@@H](C)C(=O)O",65 "R": "N[C@@H](CCCNC(=N)N)C(=O)O",66 "N": "N[C@@H](CC(=O)N)C(=O)O",67 "D": "N[C@@H](CC(=O)O)C(=O)O",68 "C": "N[C@@H](CS)C(=O)O",69 "E": "N[C@@H](CCC(=O)O)C(=O)O",70 "Q": "N[C@@H](CCC(=O)N)C(=O)O",71 "G": "NCC(=O)O",72 "H": "N[C@@H](Cc1c[nH]cn1)C(=O)O",73 "I": "N[C@@H]([C@@H](C)CC)C(=O)O",74 "L": "N[C@@H](CC(C)C)C(=O)O",75 "K": "N[C@@H](CCCCN)C(=O)O",76 "M": "N[C@@H](CCSC)C(=O)O",77 "F": "N[C@@H](Cc1ccccc1)C(=O)O",78 "P": "O=C(O)[C@@H]1CCCN1",79 "S": "N[C@@H](CO)C(=O)O",80 "T": "N[C@@H]([C@H](O)C)C(=O)O",81 "W": "N[C@@H](Cc1c[nH]c2ccccc12)C(=O)O",82 "Y": "N[C@@H](Cc1ccc(O)cc1)C(=O)O",83 "V": "N[C@@H](C(C)C)C(=O)O",84}85 86 87@dataclass(frozen=True)88class TranslationResult:89 protein: str90 rows: list[dict]91 warnings: list[str]92 oriented_sequence: str93 94 95def normalize_sequence(raw: str) -> tuple[str, list[str]]:96 """Return an uppercase DNA sequence and any invalid characters.97 98 FASTA headers, whitespace, digits, and common position punctuation are99 ignored. Other characters are reported rather than silently discarded.100 """101 lines = [line for line in (raw or "").splitlines() if not line.lstrip().startswith(">")]102 joined = "".join(lines).upper()103 ignored = set(" \t\r\n0123456789-._")104 invalid = sorted({char for char in joined if char not in IUPAC_BASES and char not in ignored})105 sequence = "".join(IUPAC_BASES[char][0] if char == "U" else char for char in joined if char in IUPAC_BASES)106 return sequence.replace("U", "T"), invalid107 108 109def reverse_complement(sequence: str) -> str:110 return sequence.translate(COMPLEMENT)[::-1]111 112 113def resolve_codon(codon: str, table: dict[str, str]) -> str:114 """Translate an IUPAC codon; return X when its meanings disagree."""115 if len(codon) != 3:116 return "X"117 possibilities = product(*(IUPAC_BASES[base] for base in codon))118 meanings = {table["".join(parts)] for parts in possibilities}119 return next(iter(meanings)) if len(meanings) == 1 else "X"120 121 122def translate_sequence(123 sequence: str,124 frame: int = 1,125 code_name: str = "Standard",126 mode: str = "Selected frame",127 stop_at_first: bool = True,128) -> TranslationResult:129 if frame not in {-3, -2, -1, 1, 2, 3}:130 raise ValueError("Frame must be one of +1, +2, +3, -1, -2, or -3.")131 if code_name not in GENETIC_CODES:132 raise ValueError(f"Unknown genetic code: {code_name}")133 134 code = GENETIC_CODES[code_name]135 oriented = sequence if frame > 0 else reverse_complement(sequence)136 offset = abs(frame) - 1137 warnings: list[str] = []138 139 start_offset = offset140 if mode == "First start-to-stop ORF":141 found = None142 for position in range(offset, len(oriented) - 2, 3):143 if oriented[position:position + 3] in code["starts"]:144 found = position145 break146 if found is None:147 return TranslationResult("", [], ["No in-frame start codon was found."], oriented)148 start_offset = found149 150 rows: list[dict] = []151 protein: list[str] = []152 found_stop = False153 for position in range(start_offset, len(oriented) - 2, 3):154 codon = oriented[position:position + 3]155 aa = resolve_codon(codon, code["table"])156 # Alternative initiation codons encode methionine only when they are157 # actually used to initiate an ORF.158 if mode == "First start-to-stop ORF" and position == start_offset and codon in code["starts"]:159 aa = "M"160 rows.append({161 "Nucleotide position": position + 1,162 "Codon (DNA)": codon,163 "Codon (mRNA)": codon.replace("T", "U"),164 "1-letter": aa,165 "Amino acid": AA_NAMES[aa],166 })167 if aa == "*":168 found_stop = True169 if stop_at_first or mode == "First start-to-stop ORF":170 break171 protein.append("*")172 else:173 protein.append(aa)174 175 translated_nt = max(0, len(oriented) - start_offset)176 remainder = translated_nt % 3177 if remainder:178 warnings.append(f"{remainder} trailing nucleotide(s) do not form a complete codon.")179 if any(row["1-letter"] == "X" for row in rows):180 warnings.append("At least one ambiguous codon has multiple possible amino-acid meanings and is shown as X.")181 if mode == "First start-to-stop ORF" and not found_stop:182 warnings.append("A start codon was found, but no in-frame stop codon occurs before the sequence ends.")183 return TranslationResult("".join(protein), rows, warnings, oriented)184 185 186def gc_percent(sequence: str) -> float | None:187 canonical = [base for base in sequence if base in "ACGT"]188 if not canonical:189 return None190 return 100.0 * sum(base in "GC" for base in canonical) / len(canonical)191 192 193def peptide_mass(protein: str) -> float | None:194 if not protein or any(aa not in AA_RESIDUE_MASS for aa in protein):195 return None196 return 18.0153 + sum(AA_RESIDUE_MASS[aa] for aa in protein)197 198 199def safe_filename(value: str) -> str:200 cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip())201 return cleaned.strip("._") or "translation"202 