CoolFace
Apppublic

Tsimech2000/Protein_Synthesizer_Simulator

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
app.py632 linesDownload Raw Back to root
1import streamlit as st2from Bio.Seq import Seq3from Bio import Entrez, SeqIO4from Bio.SeqUtils.ProtParam import ProteinAnalysis5import re6import requests7from textwrap import wrap8 9# ----------------------------10# App Config11# ----------------------------12st.set_page_config(page_title="Protein Synthesizer Simulator v2", page_icon="🧬", layout="wide")13st.title("🧬 Protein Synthesizer Simulator β€” v2")14 15# ----------------------------16# Settings (sidebar)17# ----------------------------18st.sidebar.header("βš™οΈ Settings")19Entrez.email = st.sidebar.text_input("NCBI Email (required for E-utilities)", value="your.email@example.com")20ENTREZ_API_KEY = st.sidebar.text_input("NCBI API key (optional)", type="password")21if ENTREZ_API_KEY:22    Entrez.api_key = ENTREZ_API_KEY23 24min_orf_len = st.sidebar.number_input("Minimum ORF length (aa)", min_value=10, max_value=3000, value=30, step=10)25show_one_letter = st.sidebar.checkbox("Show protein in 1-letter code", value=True)26wrap_width = st.sidebar.slider("Wrap width for sequence display", 30, 120, 60)27 28MAX_INPUT_LEN = 200_000  # guard against pasting whole chromosomes into the ORF scanner29 30# ----------------------------31# Genetic Code (Standard RNA)32# ----------------------------33CODON_TO_AA = {34    'AUG': 'M', 'UUU': 'F', 'UUC': 'F', 'UUA': 'L', 'UUG': 'L',35    'UCU': 'S', 'UCC': 'S', 'UCA': 'S', 'UCG': 'S',36    'UAU': 'Y', 'UAC': 'Y', 'UAA': '*', 'UAG': '*', 'UGA': '*',37    'UGU': 'C', 'UGC': 'C', 'UGG': 'W',38    'CUU': 'L', 'CUC': 'L', 'CUA': 'L', 'CUG': 'L',39    'CCU': 'P', 'CCC': 'P', 'CCA': 'P', 'CCG': 'P',40    'CAU': 'H', 'CAC': 'H', 'CAA': 'Q', 'CAG': 'Q',41    'CGU': 'R', 'CGC': 'R', 'CGA': 'R', 'CGG': 'R',42    'AUU': 'I', 'AUC': 'I', 'AUA': 'I', 'ACU': 'T', 'ACC': 'T', 'ACA': 'T', 'ACG': 'T',43    'AAU': 'N', 'AAC': 'N', 'AAA': 'K', 'AAG': 'K',44    'AGU': 'S', 'AGC': 'S', 'AGA': 'R', 'AGG': 'R',45    'GUU': 'V', 'GUC': 'V', 'GUA': 'V', 'GUG': 'V',46    'GCU': 'A', 'GCC': 'A', 'GCA': 'A', 'GCG': 'A',47    'GAU': 'D', 'GAC': 'D', 'GAA': 'E', 'GAG': 'E',48    'GGU': 'G', 'GGC': 'G', 'GGA': 'G', 'GGG': 'G'49}50 51AA1_TO_AA3 = {52    'A': 'Ala', 'R': 'Arg', 'N': 'Asn', 'D': 'Asp', 'C': 'Cys', 'Q': 'Gln', 'E': 'Glu', 'G': 'Gly', 'H': 'His', 'I': 'Ile',53    'L': 'Leu', 'K': 'Lys', 'M': 'Met', 'F': 'Phe', 'P': 'Pro', 'S': 'Ser', 'T': 'Thr', 'W': 'Trp', 'Y': 'Tyr', 'V': 'Val', '*': 'Stop'54}55AA3_TO_AA1 = {v: k for k, v in AA1_TO_AA3.items()}56 57AA_TO_CODONS = {}58for c, a in CODON_TO_AA.items():59    AA_TO_CODONS.setdefault(a, []).append(c)60 61# Simple codon usage (most frequent codon per AA for H. sapiens; compact, illustrative)62HUMAN_TOP_CODON = {63    'A': 'GCC', 'R': 'CGC', 'N': 'AAC', 'D': 'GAC', 'C': 'UGC', 'Q': 'CAG', 'E': 'GAG', 'G': 'GGC', 'H': 'CAC', 'I': 'AUC',64    'L': 'CUG', 'K': 'AAG', 'M': 'AUG', 'F': 'UUC', 'P': 'CCC', 'S': 'AGC', 'T': 'ACC', 'W': 'UGG', 'Y': 'UAC', 'V': 'GUG', '*': 'UAA'65}66 67# Kyte-Doolittle hydropathy index68KD = {'I': 4.5, 'V': 4.2, 'L': 3.8, 'F': 2.8, 'C': 2.5, 'M': 1.9, 'A': 1.8, 'G': -0.4, 'T': -0.7, 'S': -0.8, 'W': -0.9, 'Y': -1.3,69      'P': -1.6, 'H': -3.2, 'E': -3.5, 'Q': -3.5, 'D': -3.5, 'N': -3.5, 'K': -3.9, 'R': -4.5, '*': 0, 'X': 0}70 71# ----------------------------72# Helpers73# ----------------------------74@st.cache_data(show_spinner=False)75def esearch_nuccore(term: str, retmax: int = 5):76    with Entrez.esearch(db="nuccore", term=term, retmax=retmax) as h:77        return Entrez.read(h)78 79 80@st.cache_data(show_spinner=False)81def efetch_fasta(nuccore_id: str):82    with Entrez.efetch(db="nuccore", id=nuccore_id, rettype="fasta", retmode="text") as h:83        rec = SeqIO.read(h, "fasta")84        return str(rec.seq), rec.description85 86 87@st.cache_data(show_spinner=False)88def search_rcsb_by_name(term: str, max_results: int = 8):89    """90    Free-text search against RCSB's Search API (search.rcsb.org), returning a91    list of {id, title} dicts for the top matches. Used when the user types a92    common/organism name (e.g. "sars-cov-2") instead of a literal PDB ID.93    """94    query = {95        "query": {96            "type": "terminal",97            "service": "full_text",98            "parameters": {"value": term},99        },100        "return_type": "entry",101        "request_options": {102            "results_content_type": ["experimental"],103            "paginate": {"start": 0, "rows": max_results},104            "sort": [{"sort_by": "score", "direction": "desc"}],105        },106    }107    resp = requests.post("https://search.rcsb.org/rcsbsearch/v2/query", json=query, timeout=15)108    resp.raise_for_status()109    data = resp.json()110    ids = [r["identifier"] for r in data.get("result_set", [])]111    if not ids:112        return []113 114    # Fetch a short title for each hit via the Data API's core entry endpoint115    # so results are recognizable rather than a bare list of codes.116    results = []117    for pdb_id in ids:118        title = pdb_id119        try:120            entry_resp = requests.get(f"https://data.rcsb.org/rest/v1/core/entry/{pdb_id}", timeout=10)121            if entry_resp.status_code == 200:122                struct = entry_resp.json().get("struct", {})123                title = struct.get("title", pdb_id)124        except requests.exceptions.RequestException:125            pass126        results.append({"id": pdb_id, "title": title})127    return results128 129 130def clean_dna(s: str) -> str:131    s = ''.join(s.upper().split())132    return re.sub(r"[^ATCG]", "", s)133 134 135def transcribe_dna(dna: str) -> str:136    return str(Seq(dna).transcribe())137 138 139def translate_from_first_aug(mrna: str, stop_symbol: str = '*') -> str:140    start = mrna.find('AUG')141    if start == -1:142        return ''143    prot = []144    for i in range(start, len(mrna), 3):145        codon = mrna[i:i + 3]146        if len(codon) < 3:147            break148        aa = CODON_TO_AA.get(codon, 'X')149        if aa == '*':150            break151        prot.append(aa)152    return ''.join(prot)153 154 155def translate_all(mrna: str) -> str:156    prot = []157    for i in range(0, len(mrna), 3):158        codon = mrna[i:i + 3]159        if len(codon) < 3:160            break161        prot.append(CODON_TO_AA.get(codon, 'X'))162    return ''.join(prot)163 164 165def format_seq(seq: str, width: int = 60) -> str:166    return "\n".join(wrap(seq, width))167 168 169def find_orfs(dna: str, min_len_aa: int = 30):170    """171    Scans all 6 reading frames (3 forward, 3 reverse-complement) for AUG->Stop172    ORFs of at least min_len_aa amino acids.173 174    Coordinate note: nt_start/nt_end are always reported relative to the175    ORIGINAL input sequence (5'->3', position 0 = first base of `dna` as176    passed in), regardless of which strand the ORF was found on. This is177    independent from any strand/frame selection made elsewhere in the app178    (e.g. Tab 1's manual strand toggle), so ORF coordinates here should not179    be assumed to line up with Tab 1's separately-chosen frame/strand view.180    """181    results = []182    seq = dna183    rc = str(Seq(dna).reverse_complement())184    n = len(dna)185 186    def scan(one_dna: str, strand: str):187        for frame in range(3):188            mrna = transcribe_dna(one_dna[frame:])189            i = 0190            while True:191                start = mrna.find('AUG', i)192                if start == -1:193                    break194                j = start195                pep = []196                while j + 3 <= len(mrna):197                    codon = mrna[j:j + 3]198                    aa = CODON_TO_AA.get(codon, 'X')199                    if aa == '*':200                        break201                    pep.append(aa)202                    j += 3203                if pep and len(pep) >= min_len_aa:204                    nt_start = frame + start205                    nt_end = frame + j - 1206                    if strand == '+':207                        results.append({208                            'strand': strand,209                            'frame': frame,210                            'nt_start': nt_start,211                            'nt_end': nt_end,212                            'length_aa': len(pep),213                            'protein': ''.join(pep)214                        })215                    else:216                        # Map coordinates on the reverse-complement string217                        # back to positions on the original (+) strand.218                        results.append({219                            'strand': strand,220                            'frame': frame,221                            'nt_start': n - nt_end - 1,222                            'nt_end': n - nt_start - 1,223                            'length_aa': len(pep),224                            'protein': ''.join(pep)225                        })226                i = start + 3227 228    scan(seq, '+')229    scan(rc, '-')230 231    results.sort(key=lambda x: x['length_aa'], reverse=True)232    return results233 234 235def simple_features(protein: str):236    # Filter out non-standard letters for analyses237    clean = re.sub(r"[^ACDEFGHIKLMNPQRSTVWY]", "", protein)238    stats = {}239    if clean:240        pa = ProteinAnalysis(clean)241        stats['length'] = len(protein)242        stats['mw'] = round(pa.molecular_weight(), 2)243        stats['pi'] = round(pa.isoelectric_point(), 2)244        stats['aromaticity'] = round(pa.aromaticity(), 3)245        stats['instability_index'] = round(pa.instability_index(), 2)246        stats['gravy'] = round(pa.gravy(), 3)247    else:248        stats = {'length': len(protein), 'mw': 0, 'pi': None, 'aromaticity': None, 'instability_index': None, 'gravy': None}249 250    # N-glycosylation motifs: N-X-[ST] (X != P)251    glyco_sites = [m.start() + 1 for m in re.finditer(r"N[^P][ST]", protein)]252 253    # Very simple TM prediction by KD>1.6 over window 19254    win = 19255    kd_vals = []256    for i in range(len(protein)):257        window = protein[i:i + win]258        if len(window) < win:259            kd_vals.append(0)260            continue261        kd_vals.append(sum(KD.get(a, 0) for a in window) / win)262    tms = []263    thr = 1.6264    i = 0265    while i < len(kd_vals):266        if kd_vals[i] >= thr:267            start = i268            while i < len(kd_vals) and kd_vals[i] >= thr:269                i += 1270            end = i + win - 1271            tms.append((start + 1, min(end, len(protein))))272        else:273            i += 1274 275    return stats, glyco_sites, tms276 277 278def back_translate(protein: str, strategy: str = 'human_top') -> str:279    mrna = []280    for aa in protein:281        if aa == '*':282            codon = 'UAA'283        elif strategy == 'human_top' and aa in HUMAN_TOP_CODON:284            codon = HUMAN_TOP_CODON[aa]285        else:286            # default to the first codon listed for that AA287            codon = AA_TO_CODONS.get(aa, ['NNN'])[0]288        mrna.append(codon)289    return '-'.join(mrna)290 291 292# ----------------------------293# Tabs294# ----------------------------295tab1, tab2, tab3, tab4, tab5 = st.tabs(["πŸ”¬ Synthesis", "πŸ“ˆ ORF Finder", "πŸ”„ Reverse Translation", "πŸ” 3D Structure", "ℹ️ About"])296 297# ----------------------------298# Tab 1 β€” Synthesis299# ----------------------------300with tab1:301    st.subheader("πŸ”Ž Fetch Gene from NCBI")302    colq1, colq2 = st.columns([2, 1])303    with colq1:304        gene_query = st.text_input("Enter gene name and organism (e.g., BRCA1 Homo sapiens)")305    with colq2:306        st.write("")307        st.write("")308 309    if st.button("πŸ” Fetch Sequence") and gene_query:310        try:311            # Uses the cached helpers so repeated searches/fetches don't312            # re-hit NCBI on every click or rerun.313            search_result = esearch_nuccore(gene_query, retmax=1)314            id_list = search_result.get("IdList", [])315            if id_list:316                seq_id = id_list[0]317                dna_seq, description = efetch_fasta(seq_id)318                st.session_state['fetched_dna'] = dna_seq319                st.success(f"Fetched sequence: {description}")320                st.code(format_seq(st.session_state['fetched_dna'], wrap_width), language='text')321            else:322                st.warning("No sequences found for this query.")323        except Exception as e:324            st.error(f"Error: {e}")325 326    st.divider()327    st.subheader("1️⃣ Enter or Upload DNA (coding strand)")328    up = st.file_uploader("Upload FASTA (optional)", type=["fa", "fasta", "txt"])329    if up is not None:330        try:331            record = SeqIO.read(up, "fasta")332            dna_input = str(record.seq)333            st.info(f"Loaded FASTA: {record.description}")334        except Exception:335            up.seek(0)336            dna_input = up.read().decode("utf-8")337    else:338        dna_input = st.text_area("Paste DNA (A/T/C/G)", height=160, value=st.session_state.get('fetched_dna', ''))339 340    frame = st.selectbox("Reading frame (0, 1, 2)", [0, 1, 2], index=0)341    strand = st.radio("Strand", ["+ (as given)", "- (reverse complement)"])342 343    colA, colB, colC = st.columns([1, 1, 1])344    with colA:345        do_transcribe = st.button("🧬 Transcribe")346    with colB:347        do_translate_from_aug = st.button("🧫 Translate (AUG β†’ Stop)")348    with colC:349        do_translate_all = st.button("πŸ§ͺ Translate (full, no start)")350 351    if dna_input:352        dna_clean = clean_dna(dna_input)353        if not dna_clean:354            st.error("No valid nucleotides found. Please provide A/T/C/G.")355        elif len(dna_clean) > MAX_INPUT_LEN:356            st.error(357                f"Input is {len(dna_clean):,} bp, which exceeds the {MAX_INPUT_LEN:,} bp limit for this tool. "358                "Please provide a shorter sequence (e.g. a single gene/transcript rather than a whole chromosome)."359            )360        else:361            if strand.startswith('-'):362                dna_clean = str(Seq(dna_clean).reverse_complement())363            if frame in (0, 1, 2):364                dna_frame = dna_clean[frame:]365            else:366                dna_frame = dna_clean367 368            mrna = transcribe_dna(dna_frame)369 370            if do_transcribe:371                st.subheader("2️⃣ mRNA")372                st.code(format_seq(mrna, wrap_width))373 374            if do_translate_from_aug:375                prot = translate_from_first_aug(mrna)376                if not prot:377                    st.warning("No AUG found in-frame; cannot start translation.")378                else:379                    st.subheader("3️⃣ Protein (AUG β†’ Stop)")380                    seq_show = prot if show_one_letter else '-'.join(AA1_TO_AA3.get(a, 'Xxx') for a in prot)381                    st.code(format_seq(seq_show, wrap_width))382 383                    stats, glyco, tms = simple_features(prot)384                    c1, c2, c3 = st.columns(3)385                    with c1:386                        st.metric("Length (aa)", stats['length'])387                        st.metric("MW (Da)", stats['mw'])388                    with c2:389                        st.metric("pI (theoretical)", stats['pi'])390                        st.metric("Aromaticity", stats['aromaticity'])391                    with c3:392                        st.metric("Instability Index", stats['instability_index'])393                        st.metric("GRAVY", stats['gravy'])394 395                    st.markdown("**Predicted features**")396                    st.write(f"β€’ N-glycosylation N-X-[ST] sites at positions: {glyco if glyco else 'None'}")397                    st.write(f"β€’ Putative transmembrane helices (start–end): {tms if tms else 'None'}")398 399                    fasta_header = ">translated_protein|frame={}|strand={}".format(frame, '+' if strand.startswith('+') else '-')400                    aa_for_fasta = prot401                    st.download_button(402                        "πŸ“₯ Download protein (FASTA)",403                        data=f"{fasta_header}\n{format_seq(aa_for_fasta, 60)}\n",404                        file_name="protein.fasta",405                        mime="text/plain",406                    )407 408            if do_translate_all:409                prot_full = translate_all(mrna)410                st.subheader("3️⃣ Protein (full translation, includes * for stops)")411                st.code(format_seq(prot_full, wrap_width))412 413                fasta_header = ">translated_full|frame={}|strand={}".format(frame, '+' if strand.startswith('+') else '-')414                st.download_button(415                    "πŸ“₯ Download full translation (FASTA)",416                    data=f"{fasta_header}\n{format_seq(prot_full, 60)}\n",417                    file_name="protein_full.fasta",418                    mime="text/plain",419                )420 421# ----------------------------422# Tab 2 β€” ORF Finder (6 frames)423# ----------------------------424with tab2:425    st.subheader("πŸ“ˆ ORF Finder (6 frames, AUGβ†’Stop)")426    st.caption("Coordinates are reported relative to the sequence exactly as pasted below (5'β†’3', position 0 = first base), independent of any strand/frame choice made in the Synthesis tab.")427    orf_input = st.text_area("DNA sequence to scan", height=160)428    if st.button("πŸ”Ž Find ORFs") and orf_input:429        dna = clean_dna(orf_input)430        if not dna:431            st.error("No valid nucleotides found. Please provide A/T/C/G.")432        elif len(dna) > MAX_INPUT_LEN:433            st.error(434                f"Input is {len(dna):,} bp, which exceeds the {MAX_INPUT_LEN:,} bp limit for this tool. "435                "Please provide a shorter sequence."436            )437        else:438            orfs = find_orfs(dna, min_len_aa=min_orf_len)439            if not orfs:440                st.info("No ORFs β‰₯ minimum length found.")441            else:442                # Summary table443                rows = []444                for i, o in enumerate(orfs, start=1):445                    rows.append({446                        'Rank': i,447                        'Strand': o['strand'],448                        'Frame': o['frame'],449                        'NT start': o['nt_start'],450                        'NT end': o['nt_end'],451                        'Length (aa)': o['length_aa']452                    })453                st.dataframe(rows, use_container_width=True, hide_index=True)454 455                sel = st.number_input("Select ORF rank to inspect", min_value=1, max_value=len(orfs), value=1, step=1)456                chosen = orfs[int(sel) - 1]457                st.markdown(f"**Chosen ORF:** strand {chosen['strand']} β€’ frame {chosen['frame']} β€’ {chosen['length_aa']} aa")458                prot = chosen['protein']459                seq_show = prot if show_one_letter else '-'.join(AA1_TO_AA3.get(a, 'Xxx') for a in prot)460                st.code(format_seq(seq_show, wrap_width))461 462                stats, glyco, tms = simple_features(prot)463                c1, c2, c3 = st.columns(3)464                with c1:465                    st.metric("Length (aa)", stats['length'])466                    st.metric("MW (Da)", stats['mw'])467                with c2:468                    st.metric("pI (theoretical)", stats['pi'])469                    st.metric("Aromaticity", stats['aromaticity'])470                with c3:471                    st.metric("Instability Index", stats['instability_index'])472                    st.metric("GRAVY", stats['gravy'])473 474                fasta_header = ">orf_rank{}|strand={}|frame={}|len={}".format(int(sel), chosen['strand'], chosen['frame'], chosen['length_aa'])475                st.download_button(476                    "πŸ“₯ Download ORF protein (FASTA)",477                    data=f"{fasta_header}\n{format_seq(prot, 60)}\n",478                    file_name=f"orf_{int(sel)}.fasta",479                    mime="text/plain",480                )481 482# ----------------------------483# Tab 3 β€” Reverse Translation484# ----------------------------485with tab3:486    st.subheader("πŸ”„ Reverse Translation (Protein β†’ mRNA)")487    st.caption("Uses human-preferred codons by default; provides one codon per amino acid.")488    prot_in = st.text_input("Protein sequence (1-letter, e.g., MVLTI...) ")489    if prot_in:490        prot_in = prot_in.strip().upper()491        if re.fullmatch(r"[ACDEFGHIKLMNPQRSTVWY\*]+", prot_in):492            mrna_bt = back_translate(prot_in, strategy='human_top')493            st.code(mrna_bt, language='text')494            st.download_button(495                "πŸ“₯ Download back-translated mRNA (codon list)",496                data=mrna_bt,497                file_name="back_translation.txt",498                mime="text/plain",499            )500 501            # Show degeneracy helper for the first few residues502            st.markdown("**Codon options (first 10 aa)**")503            first10 = prot_in[:10]504            for i, aa in enumerate(first10, start=1):505                opts = AA_TO_CODONS.get(aa, [])506                st.write(f"{i:>2}. {aa} β†’ {', '.join(opts) if opts else 'β€”'}")507        else:508            st.error("Invalid characters. Use 20 canonical amino acids and '*' for stop.")509 510# ----------------------------511# Tab 4 β€” 3D Structure512# ----------------------------513def _is_pdb_id(text: str) -> bool:514    # PDB IDs are always exactly 4 characters: 1 digit + 3 alphanumerics.515    return bool(re.fullmatch(r"[0-9][A-Za-z0-9]{3}", text)) and len(text) == 4516 517 518def _is_uniprot_id(text: str) -> bool:519    # UniProt accessions: 6 or 10 characters, start with a letter, alphanumeric.520    # Not a full validation of the UniProt spec, just enough to distinguish521    # from a free-text name/search term.522    return bool(re.fullmatch(r"[A-Za-z][A-Za-z0-9]{5}([A-Za-z0-9]{4})?", text)) and len(text) in (6, 10)523 524 525def _render_structure(pid: str):526    pid = pid.strip()527    if _is_pdb_id(pid):528        source_label = "PDB"529        fetch_url = f"https://files.rcsb.org/download/{pid.upper()}.pdb"530        entry_url = f"https://www.rcsb.org/structure/{pid.upper()}"531    else:532        source_label = "AlphaFold"533        up = pid.upper()534        fetch_url = f"https://alphafold.ebi.ac.uk/files/AF-{up}-F1-model_v4.pdb"535        entry_url = f"https://alphafold.ebi.ac.uk/entry/{up}"536 537    try:538        import py3Dmol539 540        # Fetch the PDB file server-side (in Python) rather than letting541        # py3Dmol's JS fetch it client-side via addModelFromUri. The542        # client-side fetch is frequently blocked by CORS depending on543        # the browser/host, which causes a silently blank viewer with no544        # Python-visible error. Fetching here and passing raw text into545        # addModel() avoids that failure mode entirely.546        resp = requests.get(fetch_url, timeout=15)547        if resp.status_code == 200 and resp.text.strip():548            view = py3Dmol.view(width=600, height=450)549            view.addModel(resp.text, 'pdb')550            view.setStyle({'cartoon': {'color': 'spectrum'}})551            view.zoomTo()552            view.render()553            st.components.v1.html(view._make_html(), height=470, width=650, scrolling=True)554            st.caption(f"Source: [{source_label} entry]({entry_url})")555        else:556            st.error(f"Could not fetch structure from {source_label} (HTTP {resp.status_code}). Check the ID and try again.")557            st.info("Direct link to try in your browser:")558            st.code(fetch_url)559    except requests.exceptions.RequestException as e:560        st.error(f"Network error fetching structure: {e}")561        st.info("If this environment blocks outbound requests, try opening the model link directly:")562        st.code(fetch_url)563    except Exception as e:564        st.error(f"3D viewer error: {e}")565        st.info("If this environment blocks external URLs, try opening the model links directly:")566        st.code(fetch_url)567 568 569with tab4:570    st.subheader("πŸ” AlphaFold/PDB 3D Structure Viewer")571    st.caption(572        "Enter a 4-character PDB ID (e.g., 1A3N), a UniProt ID (e.g., P69905), "573        "or a common name (e.g., \"sars-cov-2 spike\", \"hemoglobin\") to search PDB entries by name."574    )575    pid = st.text_input("PDB ID, UniProt ID, or structure name")576 577    if pid:578        pid_stripped = pid.strip()579 580        if _is_pdb_id(pid_stripped) or _is_uniprot_id(pid_stripped):581            # Looks like a literal accession β€” fetch and render directly.582            _render_structure(pid_stripped)583        else:584            # Treat as a free-text search term against RCSB PDB.585            with st.spinner(f"Searching PDB for \"{pid_stripped}\"..."):586                try:587                    matches = search_rcsb_by_name(pid_stripped)588                except requests.exceptions.RequestException as e:589                    matches = None590                    st.error(f"Search error: {e}")591 592            if matches is not None:593                if not matches:594                    st.warning(595                        f"No PDB entries found for \"{pid_stripped}\". "596                        "Try a different term, or enter a literal PDB/UniProt ID."597                    )598                else:599                    st.markdown(f"**{len(matches)} match(es) found β€” pick one to view:**")600                    labels = [f"{m['id']} β€” {m['title']}" for m in matches]601                    chosen_label = st.radio("Search results", labels, label_visibility="collapsed")602                    chosen_id = matches[labels.index(chosen_label)]["id"]603                    _render_structure(chosen_id)604 605# ----------------------------606# Tab 5 β€” About607# ----------------------------608with tab5:609    st.markdown(610        """611        ### ℹ️ About v2612        **What's new/improved:**613        - Robust **NCBI search & fetch** with selectable results and caching614        - Clean DNA handling, **strand & frame controls**615        - Clear **AUGβ†’Stop** translation and full translation (with `*` stops)616        - **Protein stats** (MW, pI, aromaticity, instability index, GRAVY)617        - Simple **feature prediction**: NXS/T glycosites and KD-based TM helices618        - Compact **back-translation** using human-preferred codons619        - **6-frame ORF finder** with sortable table and downloads620        - **AlphaFold/PDB** 3D viewer with graceful fallback621        - Better downloads (FASTA formatting), wider layout & wrapped sequence display622        - Input length guard to prevent pasting whole chromosomes into the scanner623        - NCBI fetch/search now goes through cached helpers (fewer redundant API calls)624 625        **Tips**626        - Provide a valid NCBI email (and optional API key) to be a good E-utilities citizen.627        - Use the ORF Finder to verify intronless sequences before translating.628        - Back-translation is heuristic; for cloning, tailor codon usage to your host.629        - ORF Finder coordinates are relative to the sequence as pasted in that tab, not the Synthesis tab's strand/frame selection.630        """631    )632