CoolFace
Apppublic

GENOMICS-CDDBRG/Genome_Visualization_Tool

sourceHugging Faceupdated 25d agoView on Hugging Face
0likes
app.py727 linesDownload Raw Back to root
1"""2app.py - Gulumba Genomics Suite v1.03 4A single Streamlit application combining, on a shared, tested scientific core5(genomics_core.py), the functionality previously spread across five separate6tools: GenomeScope Pro (composition/codon-bias viewer), the Prokka annotation7workbench, the Pan-Genome analysis app, and the Comparative Genomics app.8 9Each tool is its own tab with its own "Run" button and its own inputs, exactly10as the separate apps worked before, so nothing about how you use any one tool11changes. What changes underneath:12 13  - All GC%, codon usage, ENC, CAI, and assembly-statistics calculations are14    computed once, in genomics_core.py, and reused by every tool. The15    original tools each had their own, occasionally disagreeing, copy of16    these formulas (e.g. GC% over unambiguous bases only vs. over full17    length including N). That disagreement is resolved by having one18    implementation.19  - Comparative Genomics gained an alignment-free mode (MinHash/Mash distance20    and ANI, plus a neighbour-joining tree) for genomes too large for the21    original pairwise Needleman-Wunsch approach, which does not scale past a22    few Mb. The original pairwise mode is kept for what it was always good23    at: genes, plasmids, amplicons, and other short, closely related24    sequences.25  - Pan-Genome analysis gained Heaps' law openness estimation and Tettelin-26    style gene-accumulation curves (with permutation resampling), so you can27    report whether the pan-genome is open or closed, not just the core/28    accessory/unique counts.29  - Every figure is publication-styled (600 dpi raster + vector PDF/SVG,30    colour-blind-safe palettes, Type-42 embedded fonts) and every heuristic31    call (origin/terminus prediction, self-derived CAI weights, alignment-32    free ANI) carries an honest confidence/provenance label in the figure or33    table itself, not just in a caption you might not export.34 35Requirements36    Core (always required): streamlit, pandas, numpy, scipy, matplotlib37    GenBank/EMBL parsing:    biopython38    Annotation workbench:    prokka (external tool on PATH)39    Pan-genome tool:         pyrodigal (pip), cd-hit (external tool on PATH)40 41Run with:  streamlit run app.py42"""43 44import io45import os46import shutil47import tempfile48import zipfile49from pathlib import Path50from typing import Dict, List, Optional51 52import numpy as np53import pandas as pd54import matplotlib.pyplot as plt55import streamlit as st56 57import genomics_core as gc58import genomics_figures as gf59import genomics_parsing as gp60import genomics_external as ge61 62st.set_page_config(page_title="Gulumba Genomics Suite v1.0", page_icon="\U0001F9EC", layout="wide")63gc.set_publication_style()64 65WORK_ROOT = Path(tempfile.gettempdir()) / "unified_genomics_suite"66WORK_ROOT.mkdir(parents=True, exist_ok=True)67 68 69# ============================================================================70# Shared helpers71# ============================================================================72 73def fig_to_png_bytes(fig, dpi=600) -> bytes:74    buf = io.BytesIO()75    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")76    buf.seek(0)77    return buf.getvalue()78 79 80def fig_to_vector_bytes(fig, fmt="pdf") -> bytes:81    buf = io.BytesIO()82    fig.savefig(buf, format=fmt, bbox_inches="tight")83    buf.seek(0)84    return buf.getvalue()85 86 87def build_zip(figures: Dict[str, bytes], tables: Dict[str, pd.DataFrame], readme: str = "") -> bytes:88    mem = io.BytesIO()89    with zipfile.ZipFile(mem, "w", compression=zipfile.ZIP_DEFLATED) as zf:90        for name, data in figures.items():91            zf.writestr(f"figures/{name}", data)92        for name, df in tables.items():93            zf.writestr(f"tables/{name}", df.to_csv(index=False))94        if readme:95            zf.writestr("README.txt", readme)96    mem.seek(0)97    return mem.getvalue()98 99 100METHODS_NOTE = (101    "Methods note: GC/AT content is reported over unambiguous (A/C/G/T) bases only, "102    "so ambiguity codes and gaps do not bias the percentage. The effective number of "103    "codons follows Wright (1990). RSCU and the codon adaptation index follow Sharp "104    "and Li (1986, 1987). Origin/terminus prediction from cumulative GC skew follows "105    "Lobry (1996) and Grigoriev (1998), and is reported with a signal-to-noise "106    "confidence flag rather than as a certainty - always confirm with dedicated tools "107    "for a final assembly. Alignment-free genome distance uses MinHash/Mash sketching "108    "(Ondov et al. 2016); ANI is estimated from the Mash Jaccard-containment distance, "109    "not from a base-level alignment, and should be read as an estimate at high "110    "similarity (>90% ANI) where the underlying approximation is most accurate."111)112 113 114def genetic_code_selector(key_prefix: str) -> gc.CodonContext:115    options = {116        "11 Bacterial, Archaeal and Plant Plastid": 11,117        "1 Standard": 1,118        "2 Vertebrate Mitochondrial": 2,119        "4 Mold, Protozoan and Coelenterate Mitochondrial": 4,120        "5 Invertebrate Mitochondrial": 5,121        "9 Echinoderm and Flatworm Mitochondrial": 9,122    }123    label = st.selectbox("Genetic code table", list(options.keys()), key=f"{key_prefix}_gcode")124    return gc.build_codon_context(options[label])125 126 127def theme_selector(key_prefix: str) -> str:128    return st.selectbox("Figure colour theme", ["Ocean", "Ember", "Forest", "Royal"], key=f"{key_prefix}_theme")129 130 131def collect_cds_sequences(records: List[gp.GenomeRecord], tables: Dict[str, pd.DataFrame],132                          min_len_bp: int = 90) -> List[str]:133    """Extract CDS sequences from parsed feature tables (any input format)."""134    seqs = []135    for rec in records:136        fdf = tables.get(rec.id)137        if fdf is None or fdf.empty:138            continue139        cds = fdf[fdf["feature_type"].str.lower().isin(["cds"])]140        for _, row in cds.iterrows():141            start, end = int(row["start"]), int(row["end"])142            seq = rec.seq[start:end]143            if int(row.get("strand", 0)) == -1:144                seq = gp.reverse_complement(seq)145            seq = gc.valid_cds(seq)146            if len(seq) >= min_len_bp:147                seqs.append(seq)148    return seqs149 150 151# ============================================================================152# Page header153# ============================================================================154 155st.markdown("## \U0001F9EC Unified Genomics Suite")156st.caption(157    "Gulumba Genome composition and codon usage \u00b7 Prokka annotation workbench \u00b7 "158    "Pan-genome analysis \u00b7 Comparative genomics. One shared, tested scientific "159    "core; publication-ready figures and tables throughout."160)161 162tab_names = [163    "1. Genome Composition & Codon Usage",164    "2. Annotation Workbench (Prokka)",165    "3. Pan-Genome Analysis",166    "4. Comparative Genomics",167]168tab1, tab2, tab3, tab4 = st.tabs(tab_names)169 170 171# ============================================================================172# TOOL 1 - Gulumba Genome composition & codon usage173# (replaces GenomeScope Pro 2.1 + genome_analytics.py)174# ============================================================================175 176with tab1:177    st.markdown("#### Single-genome structure, GC content, and codon usage bias")178    st.caption(179        "Accepts GenBank, EMBL, FASTA, GFF/GTF+FASTA, BED+FASTA, an annotation "180        "table+FASTA, or a ZIP bundle of any of these."181    )182 183    c1, c2 = st.columns(2)184    with c1:185        primary_file = st.file_uploader(186            "Genome file (or ZIP bundle)", key="t1_primary",187            type=["gb", "gbk", "genbank", "embl", "fa", "fasta", "fna", "fas",188                  "gff", "gff3", "gtf", "bed", "csv", "tsv", "zip", "gz"],189        )190    with c2:191        secondary_file = st.file_uploader(192            "Matching FASTA (only needed for GFF/BED/table input)", key="t1_secondary",193            type=["fa", "fasta", "fna", "fas", "gz"],194        )195 196    ctx1 = genetic_code_selector("t1")197    theme1 = theme_selector("t1")198    window_size = st.number_input("Window size for GC/skew (bp)", min_value=100, max_value=200000, value=1000, step=100, key="t1_window")199    min_coding_bp = st.number_input("Minimum CDS length for codon analysis (bp)", min_value=30, max_value=3000, value=90, step=30, key="t1_mincds")200 201    run1 = st.button("Run genome composition & codon usage analysis", type="primary", key="t1_run")202 203    if run1:204        if primary_file is None:205            st.error("Please upload a genome file.")206        else:207            try:208                with st.spinner("Parsing input..."):209                    secondary_bytes = secondary_file.getvalue() if secondary_file else None210                    secondary_name = secondary_file.name if secondary_file else None211                    parsed = gp.parse_any(primary_file.name, primary_file.getvalue(), secondary_name, secondary_bytes)212 213                if not parsed.records:214                    st.error("No sequence records were parsed from the uploaded file.")215                else:216                    main_rec = max(parsed.records, key=lambda r: len(r.seq))217                    feature_df = parsed.tables.get(main_rec.id, gp.empty_feature_table())218 219                    with st.spinner("Computing composition, skew, and codon metrics..."):220                        window_df = gc.window_table(main_rec.seq, window=int(window_size))221                        ori_ter = gc.predict_ori_ter(window_df)222                        nuc_counts = gc.nucleotide_counts(main_rec.seq)223                        dinuc_df = gc.dinucleotide_oe(main_rec.seq)224 225                        lengths = [len(r.seq) for r in parsed.records]226                        assembly = gc.assembly_statistics(lengths)227 228                        cds_seqs = collect_cds_sequences(parsed.records, parsed.tables, int(min_coding_bp))229                        counts = gc.aggregate_codon_counts(cds_seqs, ctx1)230                        stops = gc.stop_codon_counts(cds_seqs, ctx1)231                        weights, weight_source, weight_note = gc.build_cai_weights(ctx1, all_seqs=cds_seqs)232                        usage = gc.codon_usage_table(counts, ctx1, stops)233                        per_gene = gc.per_gene_codon_table(cds_seqs, ctx1, weights, min_codons=int(min_coding_bp) // 3)234                        aa_df = gc.amino_acid_usage(cds_seqs, ctx1)235                        neutrality = (gc.neutrality_regression(per_gene["GC3"].values, per_gene["GC12"].values)236                                     if not per_gene.empty else {})237 238                    with st.spinner("Rendering figures..."):239                        composition_records = pd.DataFrame([{240                            "record_id": r.id, "length_bp": len(r.seq),241                            "gc_percent": gc.gc_percent(r.seq),242                            "entropy_bits": gc.shannon_entropy(r.seq),243                            **{f"count_{b}": gc.nucleotide_counts(r.seq).get(b, 0) for b in "ATGCN"},244                        } for r in parsed.records])245                        figs = {246                            "01_composition_overview": gf.fig_composition_overview(composition_records, theme1),247                            "02_skew_and_origin": gf.fig_skew_and_ori(window_df, ori_ter, main_rec.id, theme1),248                            "03_dinucleotide_oe": gf.fig_dinucleotide_oe(dinuc_df, theme1),249                            "04_assembly_curve": gf.fig_assembly_curve(lengths, assembly, theme1),250                            "05_rscu_heatmap": gf.fig_rscu_heatmap(usage),251                            "06_codon_bar": gf.fig_codon_bar(usage, theme1),252                            "07_amino_acid_usage": gf.fig_amino_acid_usage(aa_df, theme1),253                            "08_enc_plot": gf.fig_enc_plot(per_gene, theme1),254                            "09_neutrality_plot": gf.fig_neutrality_plot(per_gene, neutrality, theme1),255                            "10_pr2_plot": gf.fig_pr2_plot(per_gene, theme1),256                        }257                        if not feature_df.empty:258                            figs["11_feature_map"] = gf.fig_feature_map(feature_df, len(main_rec.seq), main_rec.id)259 260                    st.success(f"Analysis complete: {main_rec.id} ({len(main_rec.seq):,} bp, {len(parsed.records)} record(s))")261                    if parsed.notes:262                        st.info(" \u00b7 ".join(parsed.notes))263 264                    m1, m2, m3, m4 = st.columns(4)265                    m1.metric("GC%", f"{gc.gc_percent(main_rec.seq):.2f}")266                    m2.metric("CDS analysed", f"{len(cds_seqs):,}")267                    m3.metric("ENC (genome)", f"{gc.enc_from_counts(counts, ctx1):.2f}" if counts else "n/a")268                    m4.metric("N50 (bp)", f"{assembly.get('N50_bp', 0):,}")269 270                    fig_tabs = st.tabs(["Structure", "GC/skew", "Codon usage", "ENC/neutrality/PR2", "Tables"])271                    with fig_tabs[0]:272                        if "11_feature_map" in figs:273                            st.pyplot(figs["11_feature_map"])274                        st.pyplot(figs["04_assembly_curve"])275                    with fig_tabs[1]:276                        st.pyplot(figs["02_skew_and_origin"])277                        st.pyplot(figs["01_composition_overview"])278                        st.pyplot(figs["03_dinucleotide_oe"])279                        st.caption(280                            f"Predicted origin confidence: {ori_ter.get('ori_ter_confidence', 'n/a')} "281                            f"(signal-to-noise {ori_ter.get('ori_ter_signal_to_noise', float('nan')):.2f})"282                        )283                    with fig_tabs[2]:284                        st.pyplot(figs["05_rscu_heatmap"])285                        st.pyplot(figs["06_codon_bar"])286                        st.pyplot(figs["07_amino_acid_usage"])287                        st.caption(f"CAI weight source: {weight_source}. {weight_note}")288                    with fig_tabs[3]:289                        cc1, cc2, cc3 = st.columns(3)290                        cc1.pyplot(figs["08_enc_plot"])291                        cc2.pyplot(figs["09_neutrality_plot"])292                        cc3.pyplot(figs["10_pr2_plot"])293                    with fig_tabs[4]:294                        st.markdown("**Codon usage table**")295                        st.dataframe(usage, use_container_width=True, height=280)296                        st.markdown("**Per-gene codon metrics**")297                        st.dataframe(per_gene, use_container_width=True, height=280)298                        st.markdown("**Feature table**")299                        st.dataframe(feature_df, use_container_width=True, height=200)300 301                    png_figs = {f"{name}.png": fig_to_png_bytes(fig) for name, fig in figs.items()}302                    pdf_figs = {f"{name}.pdf": fig_to_vector_bytes(fig) for name, fig in figs.items()}303                    all_figs = {**png_figs, **pdf_figs}304                    tables_out = {305                        "codon_usage.csv": usage, "per_gene_metrics.csv": per_gene,306                        "amino_acid_usage.csv": aa_df, "feature_table.csv": feature_df,307                        "window_composition.csv": window_df, "dinucleotide_oe.csv": dinuc_df,308                        "per_record_composition.csv": composition_records,309                    }310                    bundle = build_zip(all_figs, tables_out, METHODS_NOTE)311                    st.download_button("Download complete results bundle (figures + tables + methods note)",312                                       data=bundle, file_name=f"{main_rec.id}_genomics_suite_results.zip",313                                       mime="application/zip", key="t1_download")314                    for fig in figs.values():315                        plt.close(fig)316            except Exception as exc:317                st.error(f"Analysis failed: {exc}")318 319 320# ============================================================================321# TOOL 2 - Gulumba Prokka annotation workbench322# (replaces app.py + genome_analysis.py + annotation_core.py)323# ============================================================================324 325with tab2:326    st.markdown("#### Prokka annotation workbench")327    st.caption(328        "Runs Prokka on an uploaded bacterial genome FASTA, then computes the same "329        "composition and codon-usage metrics as Tool 1 from the Prokka output, so "330        "results are directly comparable across tools."331    )332 333    if not ge.prokka_available():334        st.warning(335            "Prokka was not found on PATH in this environment. This tab will not run "336            "until Prokka is installed (e.g. `conda install -c bioconda -c conda-forge prokka`). "337            "Every other tool in this suite works without it."338        )339 340    genome_upload = st.file_uploader("Bacterial genome FASTA", type=["fa", "fasta", "fna", "fas"], key="t2_upload")341    cc1, cc2, cc3, cc4 = st.columns(4)342    with cc1:343        prefix = st.text_input("Sample prefix", value="sample_genome", key="t2_prefix")344    with cc2:345        genus = st.text_input("Genus", value="Escherichia", key="t2_genus")346    with cc3:347        species = st.text_input("Species", value="coli", key="t2_species")348    with cc4:349        cpus = st.number_input("CPUs", min_value=1, max_value=8, value=2, key="t2_cpus")350 351    ctx2 = genetic_code_selector("t2")352    theme2 = theme_selector("t2")353    run2 = st.button("Run Prokka annotation & analysis", type="primary", key="t2_run")354 355    if run2:356        if genome_upload is None:357            st.error("Please upload a genome FASTA file.")358        else:359            safe_prefix = "".join(c if c.isalnum() or c in "-_" else "_" for c in prefix).strip("_") or "sample_genome"360            job_dir = WORK_ROOT / f"prokka_{safe_prefix}"361            shutil.rmtree(job_dir, ignore_errors=True)362            job_dir.mkdir(parents=True, exist_ok=True)363            fasta_path = job_dir / f"{safe_prefix}.fasta"364            fasta_path.write_bytes(genome_upload.getvalue())365 366            with st.spinner("Running Prokka (this can take several minutes)..."):367                result = ge.run_prokka(fasta_path, job_dir / "prokka_out", safe_prefix, genus, species, int(cpus))368 369            with st.expander("Prokka log"):370                st.text(result["log"])371 372            if not result["success"]:373                st.error("Prokka did not complete successfully. See the log above for details.")374            else:375                if result["mode"] == "noanno":376                    st.warning(377                        "Full product-search annotation did not complete, so Prokka ran in "378                        "--noanno mode. Gene calls, coordinates, and every metric below are "379                        "still valid; CDS product names are simply unavailable."380                    )381                try:382                    with st.spinner("Loading Prokka output and computing metrics..."):383                        parsed = gp.read_prokka_outputs(result["outdir"], safe_prefix)384                        main_rec = max(parsed.records, key=lambda r: len(r.seq))385                        feature_df = parsed.tables.get(main_rec.id, gp.empty_feature_table())386                        lengths = [len(r.seq) for r in parsed.records]387                        assembly = gc.assembly_statistics(lengths)388                        window_df = gc.window_table(main_rec.seq, window=1000)389                        ori_ter = gc.predict_ori_ter(window_df)390                        cds_seqs = collect_cds_sequences(parsed.records, parsed.tables, 90)391                        counts = gc.aggregate_codon_counts(cds_seqs, ctx2)392                        stops = gc.stop_codon_counts(cds_seqs, ctx2)393                        weights, weight_source, weight_note = gc.build_cai_weights(ctx2, all_seqs=cds_seqs)394                        usage = gc.codon_usage_table(counts, ctx2, stops)395                        per_gene = gc.per_gene_codon_table(cds_seqs, ctx2, weights, min_codons=30)396                        aa_df = gc.amino_acid_usage(cds_seqs, ctx2)397 398                    st.success(f"Loaded {len(parsed.records)} contig(s), {len(feature_df)} annotated features on the largest contig.")399                    m1, m2, m3, m4 = st.columns(4)400                    m1.metric("Total length (bp)", f"{assembly.get('total_length_bp', 0):,}")401                    m2.metric("GC%", f"{gc.gc_percent(main_rec.seq):.2f}")402                    m3.metric("N50 (bp)", f"{assembly.get('N50_bp', 0):,}")403                    m4.metric("CDS analysed", f"{len(cds_seqs):,}")404 405                    figs2 = {406                        "01_feature_map": (gf.fig_feature_map(feature_df, len(main_rec.seq), main_rec.id)407                                          if not feature_df.empty else None),408                        "02_assembly_curve": gf.fig_assembly_curve(lengths, assembly, theme2),409                        "03_skew_and_origin": gf.fig_skew_and_ori(window_df, ori_ter, main_rec.id, theme2),410                        "04_rscu_heatmap": gf.fig_rscu_heatmap(usage),411                        "05_amino_acid_usage": gf.fig_amino_acid_usage(aa_df, theme2),412                    }413                    figs2 = {k: v for k, v in figs2.items() if v is not None}414                    for name, fig in figs2.items():415                        st.pyplot(fig)416 417                    st.markdown("**Feature table**")418                    st.dataframe(feature_df, use_container_width=True, height=240)419                    st.markdown("**Codon usage table**")420                    st.dataframe(usage, use_container_width=True, height=240)421 422                    png_figs = {f"{n}.png": fig_to_png_bytes(f) for n, f in figs2.items()}423                    bundle2 = build_zip(png_figs, {424                        "feature_table.csv": feature_df, "codon_usage.csv": usage,425                        "per_gene_metrics.csv": per_gene, "amino_acid_usage.csv": aa_df,426                    }, METHODS_NOTE)427                    st.download_button("Download annotation workbench results bundle", data=bundle2,428                                       file_name=f"{safe_prefix}_prokka_workbench_results.zip",429                                       mime="application/zip", key="t2_download")430                    for fig in figs2.values():431                        plt.close(fig)432                except Exception as exc:433                    st.error(f"Post-annotation analysis failed: {exc}")434 435 436# ============================================================================437# TOOL 3 - Gulumba Pan-genome analysis438# (replaces pangenome_core.py + the two Pan-Genome Gradio apps)439# ============================================================================440 441with tab3:442    st.markdown("#### Pan-genome analysis")443    st.caption(444        "Upload two or more genome FASTA files (or a ZIP of them, or one multi-FASTA "445        "file where each record is a separate genome). Genes are called with "446        "pyrodigal and clustered with CD-HIT into core, accessory, and unique sets, "447        "then the pan-genome's openness is estimated with Heaps' law and Tettelin-"448        "style accumulation curves."449    )450 451    if not ge.cd_hit_available():452        st.warning("cd-hit was not found on PATH. This tab needs CD-HIT installed to run.")453 454    pg_upload = st.file_uploader(455        "Multi-FASTA, or ZIP of genome FASTA files", type=["fa", "fasta", "fna", "fas", "zip"], key="t3_upload"456    )457    pc1, pc2, pc3 = st.columns(3)458    with pc1:459        identity = st.slider("Clustering identity threshold", 0.70, 1.00, 0.90, 0.01, key="t3_identity")460    with pc2:461        coverage = st.slider("Clustering coverage threshold", 0.50, 1.00, 0.80, 0.01, key="t3_coverage")462    with pc3:463        core_threshold = st.slider("Core gene threshold (fraction of genomes)", 0.80, 1.00, 1.00, 0.01, key="t3_core")464 465    theme3 = theme_selector("t3")466    accumulation_perms = st.number_input("Permutations for accumulation-curve resampling", min_value=10, max_value=1000, value=100, step=10, key="t3_perms")467    run3 = st.button("Run pan-genome analysis", type="primary", key="t3_run")468 469    if run3:470        if pg_upload is None:471            st.error("Please upload a multi-FASTA file or a ZIP of genome FASTA files.")472        else:473            job_dir = WORK_ROOT / "pangenome_job"474            shutil.rmtree(job_dir, ignore_errors=True)475            job_dir.mkdir(parents=True, exist_ok=True)476            genome_dir = job_dir / "genomes"477            genome_dir.mkdir(parents=True, exist_ok=True)478 479            try:480                # Split into one FASTA file per genome, from either a ZIP or a multi-FASTA481                genome_files = []482                if pg_upload.name.lower().endswith(".zip"):483                    with zipfile.ZipFile(io.BytesIO(pg_upload.getvalue())) as zf:484                        for info in zf.infolist():485                            if info.is_dir():486                                continue487                            name = os.path.basename(info.filename)488                            if os.path.splitext(name)[1].lower() not in {".fa", ".fasta", ".fna", ".fas"}:489                                continue490                            out_path = genome_dir / name491                            out_path.write_bytes(zf.read(info.filename))492                            genome_files.append(out_path)493                else:494                    records = gp.parse_fasta(pg_upload.getvalue())495                    for rec in records:496                        safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in rec.id)497                        out_path = genome_dir / f"{safe}.fasta"498                        out_path.write_text(f">{rec.id}\n{rec.seq}\n")499                        genome_files.append(out_path)500 501                if len(genome_files) < 2:502                    st.error("At least two genomes are required for pan-genome analysis.")503                else:504                    genome_names = [f.stem for f in genome_files]505                    proteins_dir = job_dir / "predicted_proteins"506                    proteins_dir.mkdir(exist_ok=True)507 508                    with st.spinner(f"Calling genes in {len(genome_files)} genomes with pyrodigal..."):509                        protein_fastas = []510                        for genome_file, gname in zip(genome_files, genome_names):511                            out_faa = proteins_dir / f"{gname}.faa"512                            ge.predict_proteins(str(genome_file), str(out_faa), gname)513                            protein_fastas.append(out_faa)514 515                    pooled = job_dir / "all_proteins.faa"516                    with open(pooled, "w") as out:517                        for pf in protein_fastas:518                            out.write(pf.read_text())519 520                    with st.spinner("Clustering proteins with CD-HIT..."):521                        clstr_prefix = job_dir / "clusters"522                        clstr_file = ge.run_cd_hit(pooled, clstr_prefix, identity, coverage)523                        clusters = ge.parse_cd_hit_clusters(clstr_file)524 525                    presence, cluster_ids = ge.presence_absence_matrix(clusters, genome_names)526                    class_df = ge.classify_clusters(presence, cluster_ids, len(genome_names), core_threshold)527                    counts_cat = class_df["category"].value_counts().to_dict()528 529                    with st.spinner("Estimating pan-genome openness (Heaps' law + accumulation curves)..."):530                        accumulation = gc.pangenome_accumulation(presence, permutations=int(accumulation_perms))531                        heaps = gc.heaps_law_fit(accumulation)532 533                    st.success(534                        f"{len(genome_names)} genomes, {len(cluster_ids)} gene clusters: "535                        f"{counts_cat.get('core', 0)} core, {counts_cat.get('accessory', 0)} accessory, "536                        f"{counts_cat.get('unique', 0)} unique."537                    )538                    st.info(f"Pan-genome openness: {heaps.get('interpretation', 'n/a')} (Heaps' gamma = {heaps.get('gamma', float('nan')):.3f})")539 540                    fig_pie = gf.fig_pangenome_pie(counts_cat, theme3)541                    fig_curves = gf.fig_pangenome_curves(accumulation, heaps, theme3)542                    fig_pa = gf.fig_presence_absence(presence, genome_names)543 544                    pt1, pt2, pt3 = st.columns(3)545                    pt1.pyplot(fig_pie)546                    pt2.pyplot(fig_curves)547                    st.pyplot(fig_pa)548 549                    st.markdown("**Cluster classification**")550                    st.dataframe(class_df, use_container_width=True, height=280)551 552                    presence_df = pd.DataFrame(presence, columns=genome_names)553                    presence_df.insert(0, "cluster_id", cluster_ids)554 555                    figs3 = {"pangenome_pie.png": fig_to_png_bytes(fig_pie),556                            "pangenome_accumulation_curves.png": fig_to_png_bytes(fig_curves),557                            "presence_absence_matrix.png": fig_to_png_bytes(fig_pa)}558                    tables3 = {"cluster_classification.csv": class_df,559                              "presence_absence_matrix.csv": presence_df,560                              "accumulation_curve.csv": accumulation,561                              "heaps_law_fit.csv": pd.DataFrame([heaps])}562                    bundle3 = build_zip(figs3, tables3, METHODS_NOTE)563                    st.download_button("Download pan-genome results bundle", data=bundle3,564                                       file_name="pangenome_results.zip", mime="application/zip", key="t3_download")565                    for fig in (fig_pie, fig_curves, fig_pa):566                        plt.close(fig)567            except Exception as exc:568                st.error(f"Pan-genome analysis failed: {exc}")569 570 571# ============================================================================572# TOOL 4 - Gulumba Comparative genomics573# (replaces comparative_core.py + its Streamlit app; adds an alignment-free574#  mode for anything too large for pairwise Needleman-Wunsch alignment)575# ============================================================================576 577with tab4:578    st.markdown("#### Comparative genomics")579    st.caption(580        "Two modes. 'Alignment-free' (recommended default) uses MinHash/Mash "581        "sketching and scales to whole bacterial genomes and beyond. 'Detailed "582        "pairwise alignment' reuses the original Needleman-Wunsch approach, which "583        "gives base-level identity and conserved-region detail but only scales to "584        "genes, plasmids, amplicons, and other short sequences (a handful of Mb at "585        "most before memory and runtime become impractical)."586    )587 588    comp_files = st.file_uploader(589        "Two or more genome FASTA files", type=["fa", "fasta", "fna", "fas"],590        accept_multiple_files=True, key="t4_upload",591    )592    mode = st.radio(593        "Comparison mode",594        ["Alignment-free (Mash distance + ANI + tree)", "Detailed pairwise alignment (small sequences only)"],595        index=0, key="t4_mode",596    )597 598    if mode.startswith("Alignment-free"):599        k = st.slider("k-mer size", 11, 31, 21, 2, key="t4_k")600        sketch_size = st.slider("Sketch size (larger = more accurate, slower)", 200, 5000, 2000, 100, key="t4_sketch")601    else:602        min_conserved = st.number_input("Minimum conserved region length (bp)", min_value=5, max_value=10000, value=20, key="t4_minconserved")603        st.warning(604            "Pairwise alignment cost grows with the product of every pair of sequence "605            "lengths. Two 5 Mb bacterial chromosomes means roughly 25 x 10^12 alignment "606            "cells, which will exhaust memory. Use this mode for genes, plasmids, "607            "amplicons, or other short sequences; use alignment-free mode for whole "608            "genomes."609        )610 611    run4 = st.button("Run comparative analysis", type="primary", key="t4_run")612 613    if run4:614        if not comp_files or len(comp_files) < 2:615            st.error("Please upload at least 2 genome FASTA files.")616        else:617            try:618                genomes = []619                for f in comp_files:620                    recs = gp.parse_fasta(f.getvalue())621                    if not recs:622                        st.warning(f"No sequences found in {f.name}; skipped.")623                        continue624                    combined = "".join(r.seq for r in recs)625                    genomes.append((Path(f.name).stem, combined))626 627                if len(genomes) < 2:628                    st.error("At least two valid genomes are required.")629                else:630                    if mode.startswith("Alignment-free"):631                        with st.spinner(f"Sketching {len(genomes)} genomes (k={k})..."):632                            sketches = {name: gc.kmer_sketch(seq, k=int(k), sketch_size=int(sketch_size))[0]633                                       for name, seq in genomes}634                        with st.spinner("Computing pairwise Mash distance and ANI..."):635                            dist_df, ani_df = gc.distance_matrix_from_sketches(sketches, k=int(k))636                        with st.spinner("Building neighbour-joining tree..."):637                            newick = gc.neighbor_joining_newick(dist_df)638 639                        st.success(f"Compared {len(genomes)} genomes using alignment-free Mash sketching.")640                        fig_ani = gf.fig_distance_heatmap(ani_df, "Estimated ANI (%)", "ANI (%)", cmap="viridis")641                        st.pyplot(fig_ani)642                        st.markdown("**Estimated ANI matrix**")643                        st.dataframe(ani_df.round(3), use_container_width=True)644                        st.markdown("**Mash distance matrix**")645                        st.dataframe(dist_df.round(5), use_container_width=True)646                        st.markdown("**Neighbour-joining tree (Newick)**")647                        st.code(newick, language="text")648                        st.caption(649                            "ANI here is estimated from Mash distance (Ondov et al. 2016), not from "650                            "a base-level alignment. Treat values below ~90% ANI as a coarse ranking "651                            "rather than a precise identity - the approximation degrades as sequences "652                            "diverge further."653                        )654 655                        figs4 = {"ani_heatmap.png": fig_to_png_bytes(fig_ani)}656                        tables4 = {"ani_matrix.csv": ani_df, "mash_distance_matrix.csv": dist_df}657                        readme4 = METHODS_NOTE + f"\n\nNeighbour-joining tree (Newick format):\n{newick}\n"658                        bundle4 = build_zip(figs4, tables4, readme4)659                        st.download_button("Download comparative genomics results bundle", data=bundle4,660                                           file_name="comparative_genomics_alignment_free_results.zip",661                                           mime="application/zip", key="t4_download_af")662                        plt.close(fig_ani)663                    else:664                        # Detailed pairwise mode: only sound for short sequences. Guard against665                        # accidentally running it on whole genomes, which would hang or exhaust666                        # memory rather than failing with a clear message.667                        total_cells = 0668                        n = len(genomes)669                        for i in range(n):670                            for j in range(i + 1, n):671                                total_cells += len(genomes[i][1]) * len(genomes[j][1])672                        if total_cells > 200_000_000:673                            st.error(674                                f"This comparison would require on the order of {total_cells:,} "675                                "alignment cells, which is too large for pairwise alignment in this "676                                "environment. Switch to alignment-free mode for genomes this size."677                            )678                        else:679                            from Bio import pairwise2  # only needed for this specific mode680 681                            def compute_identity(a, b):682                                comparable = matches = 0683                                for x, y in zip(a, b):684                                    if x == "-" and y == "-":685                                        continue686                                    comparable += 1687                                    if x == y:688                                        matches += 1689                                return round(100.0 * matches / comparable, 2) if comparable else 0.0690 691                            with st.spinner("Running pairwise alignments..."):692                                names = [g[0] for g in genomes]693                                mat = np.zeros((n, n))694                                for i in range(n):695                                    mat[i, i] = 100.0696                                for i in range(n):697                                    for j in range(i + 1, n):698                                        aln = pairwise2.align.globalms(699                                            genomes[i][1], genomes[j][1], 2, -1, -5, -0.5, one_alignment_only=True700                                        )[0]701                                        ident = compute_identity(aln.seqA, aln.seqB)702                                        mat[i, j] = mat[j, i] = ident703                                identity_df = pd.DataFrame(mat, index=names, columns=names)704 705                            st.success(f"Pairwise alignment complete for {n} sequences.")706                            fig_id = gf.fig_distance_heatmap(identity_df, "Pairwise % identity", "% identity", cmap="viridis")707                            st.pyplot(fig_id)708                            st.dataframe(identity_df.round(2), use_container_width=True)709 710                            figs4b = {"pairwise_identity_heatmap.png": fig_to_png_bytes(fig_id)}711                            tables4b = {"pairwise_identity_matrix.csv": identity_df}712                            bundle4b = build_zip(figs4b, tables4b, METHODS_NOTE)713                            st.download_button("Download comparative genomics results bundle", data=bundle4b,714                                               file_name="comparative_genomics_pairwise_results.zip",715                                               mime="application/zip", key="t4_download_pw")716                            plt.close(fig_id)717            except Exception as exc:718                st.error(f"Comparative analysis failed: {exc}")719 720 721st.markdown("---")722st.caption(723    "Gulumba Genomics Suite v1.0. All statistics are computed once in genomics_core.py "724    "and shared across every tool above, so a GC% or ENC value means the same thing "725    "everywhere it appears in this app."726)727