File size: 15,038 Bytes
97d5f73
 
f0190da
386fb8f
97d5f73
1dadabb
97d5f73
f0190da
97d5f73
 
 
 
 
 
a312d10
97d5f73
35d4af4
648cd8a
97d5f73
f0190da
97d5f73
 
 
f0190da
 
 
 
 
7034da5
 
97d5f73
 
f0190da
 
 
97d5f73
 
 
 
 
7034da5
97d5f73
f0190da
97d5f73
 
a312d10
 
 
 
648cd8a
ceaecab
a312d10
 
 
648cd8a
 
a312d10
648cd8a
a312d10
ceaecab
a312d10
648cd8a
 
 
 
 
 
 
 
a312d10
 
 
 
97d5f73
 
 
 
26b65f6
f0190da
 
a312d10
 
 
f0190da
a312d10
97d5f73
a312d10
f0190da
a312d10
ceaecab
 
 
 
a312d10
 
 
97d5f73
a312d10
f0190da
 
 
97d5f73
a312d10
97d5f73
a312d10
97d5f73
f0190da
a312d10
 
97d5f73
a312d10
 
97d5f73
 
 
 
f0190da
 
 
 
1dadabb
 
f0190da
 
 
1dadabb
 
 
97d5f73
 
 
53c8150
7d9eeed
53c8150
447bf4f
 
 
53c8150
 
 
 
 
7034da5
53c8150
 
 
 
 
386fb8f
53c8150
7034da5
 
 
386fb8f
 
 
 
 
 
 
 
 
 
53c8150
 
 
648cd8a
53c8150
 
 
 
 
 
 
 
 
 
 
 
 
648cd8a
53c8150
 
 
 
 
 
 
 
 
 
7034da5
53c8150
 
 
648cd8a
 
97d5f73
 
a312d10
 
 
 
 
 
97d5f73
 
 
 
 
648cd8a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""A small Hugging Face Space for exploring genome annotations by accession."""
from pathlib import Path
from contextlib import closing
import json
import os
import re
import tempfile
import time

# Shared compute hosts can have /tmp/gradio owned by a different user.
os.environ.setdefault("GRADIO_TEMP_DIR", str(Path(tempfile.gettempdir()) / f"genbank-explorer-{os.getuid()}"))

import gradio as gr
import pyarrow.parquet as pq
import plotly.graph_objects as go

from taxonomy import build_taxonomy_tab
from style import APP_CSS, atlas_theme, section_header
from catalog import Catalog
from remote_catalog import RemoteCatalog, RemoteReadError


def build_app(catalog=None):
    if catalog is None:
        catalog = Catalog() if os.environ.get("GENBANK_DATA_MODE") == "sample" else RemoteCatalog()
    remote_mode = isinstance(catalog, RemoteCatalog)
    all_ids = catalog.browse_ids()
    first = catalog.records[all_ids[0]]
    full_snapshot = remote_mode and catalog.manifest.get("full_snapshot", False)
    scope = "published annotation snapshot" if full_snapshot else "indexed subset" if remote_mode else "sample"

    def search(accession):
        began = time.perf_counter()
        ids, total = catalog.find(accession)
        elapsed = time.perf_counter() - began
        choices = [(f"{catalog.records[i]['record_name']} · {catalog.records[i]['assembly_accession']} · "
                    f"[{catalog.records[i]['segment_start_bp']:,}, {catalog.records[i]['segment_end_bp']:,})", str(i)) for i in ids]
        if not str(accession or "").strip():
            message = "Enter an assembly or contig accession. Try an example below."
        elif not ids:
            message = f"No match in this {scope}. Newer bucket publications may not be indexed yet." if full_snapshot else f"No match in this {scope}. This does not mean the accession is absent from the full bucket."
        else:
            message = f"Found **{total:,} indexed segment(s)** in {elapsed * 1000:.1f} ms. Showing {len(ids):,}. Assembly coverage may be partial."
        return message, catalog.table(ids), gr.Dropdown(choices=choices, value=str(ids[0]) if ids else None), None

    def make_plot(frame, mode, threshold):
        binary = mode == "Binary labels"
        column = "Predicted CDS" if binary else "P(CDS)"
        figure = go.Figure()
        tracks = [("CDS (either strand)", "#287557")] if binary else [("+ strand", "#287557"), ("− strand", "#ae754b")]
        for strand, color in tracks:
            rows = frame[frame["Strand"] == strand]
            figure.add_trace(go.Scatter(x=rows["Position (bp)"].tolist(), y=rows[column].tolist(),
                                       name=strand, mode="lines",
                                       line=dict(color=color, width=2, dash="dash" if strand == "− strand" else "solid",
                                                 shape="hv" if binary else "linear")))
        if not binary:
            figure.add_hline(y=threshold, line_dash="dot", line_color="#8b9b7b",
                            annotation_text=f"Threshold {threshold:g}")
        figure.update_layout(title="Predicted CDS — either strand" if binary else "CDS probability by strand",
                             xaxis_title="Position (bp; 0-based)", yaxis_title=column,
                             height=380, margin=dict(l=60, r=25, t=75, b=50), template="plotly_white",
                             paper_bgcolor="#fffefa", plot_bgcolor="#fffefa",
                             font=dict(family="Arial, Helvetica, sans-serif", color="#315641", size=12),
                             title_font=dict(family="Georgia, Times New Roman, serif", size=22, color="#173c30"),
                             hoverlabel=dict(bgcolor="#173c30", font_color="#ffffff", bordercolor="#173c30"),
                             hovermode="x unified", legend=dict(orientation="h", y=1.12, x=1, xanchor="right"))
        figure.update_xaxes(gridcolor="#e9edde", zerolinecolor="#dbe3d3", linecolor="#dbe3d3")
        figure.update_yaxes(gridcolor="#e9edde", zerolinecolor="#dbe3d3", linecolor="#dbe3d3")
        figure.update_yaxes(range=[-0.05, 1.05], tickvals=[0, 1] if binary else None)
        return figure

    def select_segment(index, mode="Probabilities", threshold=0.5):
        if index is None:
            return {}, None, None, None, "Choose a matching segment.", None
        record = catalog.records[int(index)]
        start = record["segment_start_bp"]
        end = record["segment_end_bp"]
        try:
            table, stats = catalog.fetch(index)
            frame, step = catalog.window(index, start, end, table=table, mode=mode, threshold=threshold)
            plot = make_plot(frame, mode, threshold)
        except (ValueError, TypeError, OverflowError) as exc:
            return record, start, end, None, str(exc), None
        return record, start, end, plot, plot_note(step, stats, mode, threshold), None

    def plot_note(step, stats, mode="Probabilities", threshold=0.5):
        origin = "local sample" if stats.get("local") else "cache" if stats["cache_hit"] else "bucket"
        if mode == "Binary labels":
            resolution = (f"**1 = max(P_positive, P_negative) > {threshold:g}; 0 = otherwise.** "
                          "This is one CDS/background label per base, combining both strands. "
                          + ("The stepped track preserves every base label in this region." if step == 1 else
                             f"**Binned overview:** each interval spans up to {step:,} bases and is 1 if **any** base exceeds the threshold. "
                             "This does not mean every base in that interval is CDS. Narrow the region for exact labels."))
        else:
            resolution = "Each point is one base." if step == 1 else f"Each point is the mean of up to **{step:,} bases**; short peaks can be smoothed."
        return ("Coordinates are **0-based, end-exclusive**. "
                + resolution
                + " Download the segment for the original per-base probabilities.\n\n"
                + f"Loaded from **{origin}** in **{stats['seconds']:.2f} s**"
                + (f" · {stats['bytes_read'] / 1_000_000:.2f} MB fetched." if origin == "bucket" else "."))

    def update_window(index, start, end, mode="Probabilities", threshold=0.5):
        if index is None:
            return None, "Look up an accession and choose a segment first."
        try:
            table, stats = catalog.fetch(index)
            frame, step = catalog.window(index, start, end, table=table, mode=mode, threshold=threshold)
            plot = make_plot(frame, mode, threshold)
        except (ValueError, TypeError, OverflowError) as exc:
            return None, str(exc)
        return plot, plot_note(step, stats, mode, threshold)

    def export(index):
        if index is None:
            raise gr.Error("Choose a segment first.")
        try:
            table = catalog.segment_table(index)
        except RemoteReadError as exc:
            raise gr.Error(str(exc)) from exc
        assembly = re.sub(r"[^A-Za-z0-9._-]", "_", table["assembly_accession"][0].as_py())
        record = re.sub(r"[^A-Za-z0-9._-]", "_", table["record_name"][0].as_py())
        metadata = catalog.records[int(index)]
        start = metadata["segment_start_bp"]
        end = metadata["segment_end_bp"]
        filename = f"{assembly}__{record}__{start}-{end}.parquet"
        target = Path(tempfile.mkdtemp(prefix="genbank-export-")) / filename
        pq.write_table(table, target, compression="zstd")
        return str(target)

    with gr.Blocks(title="GenBank Annotation Explorer", delete_cache=(3600, 3600)) as demo:
        with gr.Tabs(selected="atlas", elem_id="atlas-navigation"):
            with gr.Tab("Genome Atlas", id="atlas", elem_id="atlas-overview"):
                build_taxonomy_tab()
            with gr.Tab("Database", id="database", elem_id="atlas-database"):
                gr.HTML('<header class="database-heading"><p class="database-eyebrow">THE ANNOTATION COLLECTION</p>'
                        '<h1>Explore the database</h1><p>Find an accession, explore its coding landscape, and download the annotations.</p></header>',
                        apply_default_css=False)
                with gr.Column(elem_classes="atlas-panel"):
                    gr.HTML(section_header("01", "Find an accession", "Start with an assembly or contig to explore its predicted coding regions."), apply_default_css=False)
                    gr.Markdown(f"**{len(catalog.records):,} indexed segments** · **{len(catalog.manifest['assemblies']):,} assemblies** · "
                                f"{catalog.manifest['bases']:,} bases\n\n"
                                f"Search covers the {scope}. Results are model predictions and assembly coverage may be partial.",
                                elem_classes=["quiet-note", "scope-note"])
                    with gr.Row():
                        accession = gr.Textbox(label="Accession ID", placeholder="Assembly (GCA_…) or contig accession", scale=5)
                        search_button = gr.Button("Find annotations", variant="primary", scale=1, elem_classes="action-button")
                    examples = [first["assembly_accession"], first["record_name"]]
                    example_labels = None
                    if remote_mode:
                        examples += catalog.manifest.get("example_record_names", [])[:6]
                        if not full_snapshot:
                            examples += ["JBPJTW010000350.1"]
                    suggestions_path = Path(__file__).parent / "data/suggested_accessions.json"
                    if full_snapshot and suggestions_path.exists():
                        suggestions = json.loads(suggestions_path.read_text())
                        if suggestions["inventory_sha256"] == catalog.manifest.get("inventory_sha256"):
                            examples = [entry["accession"] for entry in suggestions["examples"]]
                            example_labels = [f"{entry['organism']} · {entry['segments']:,} segments" for entry in suggestions["examples"]]
                    gr.Examples(examples=[[e] for e in dict.fromkeys(examples)], inputs=accession,
                                example_labels=example_labels,
                                label="Explore an assembly with many segments" if example_labels else "Try an accession",
                                elem_id="annotation-examples")
                    status = gr.Markdown("Enter an accession or select an example. IDs are case-insensitive; version suffixes are optional.", elem_classes="quiet-note")
                    results = gr.Dataframe(value=catalog.table([]), interactive=False, label="Matching segments", elem_id="annotation-results")
                    segment = gr.Dropdown(choices=[], label="Segment to explore", interactive=True)

                with gr.Column(elem_classes="atlas-panel"):
                    gr.HTML(section_header("02", "Explore the coding landscape", "View CDS probabilities or apply a threshold to see one label per base."), apply_default_css=False)
                    with gr.Row():
                        start = gr.Number(label="Start (0-based, inclusive)", precision=0)
                        end = gr.Number(label="End (exclusive)", precision=0)
                        view = gr.Button("Update region", elem_classes="action-button")
                    with gr.Row():
                        mode = gr.Radio(["Probabilities", "Binary labels"], value="Probabilities", label="Viewer mode")
                        threshold = gr.Slider(0, 1, value=0.5, step=0.01, label="CDS threshold (max strand P > threshold)")
                    plot = gr.Plot(label="CDS tracks", elem_id="annotation-plot")
                    note = gr.Markdown("Choose a segment above to bring its coding landscape into view.", elem_classes="quiet-note")
                    with gr.Accordion("Segment metadata and provenance", open=False, elem_classes="atlas-disclosure"):
                        metadata = gr.JSON(label="Source metadata")

                with gr.Column(elem_classes="atlas-panel"):
                    gr.HTML(section_header("03", "Take the annotations with you", "Download the original segment, with its accession and record name in the filename."), apply_default_css=False)
                    with gr.Row():
                        download = gr.Button("Prepare segment download", variant="primary", scale=1, elem_classes="action-button")
                        file = gr.File(label="Original segment annotations (Parquet)", interactive=False, scale=3)
                    gr.Markdown("Full per-base probabilities are preserved in the download, including when the viewer shows a binned overview.", elem_classes="quiet-note")
                    with gr.Accordion("Browse the index and its sources", open=False, elem_classes="atlas-disclosure"):
                        gr.Markdown(f"Showing the first {len(all_ids):,} indexed segments. Search an accession to find other indexed records. "
                                    "An indexed file does not imply complete coverage of its assembly.\n\n"
                                    "Source: [HuggingFaceBio/genbank-annotations](https://huggingface.co/buckets/HuggingFaceBio/genbank-annotations). "
                                    + (f"Annotations load on demand from {catalog.manifest.get('source_count', len(catalog.manifest['sources']))} bucket files. "
                                       f"Index updated {catalog.manifest['created_at'][:10]}." if remote_mode else "Offline sample."), elem_classes="quiet-note")
                        gr.Dataframe(value=catalog.table(all_ids), interactive=False)
                        gr.JSON(value=catalog.manifest, label="Index provenance" if remote_mode else "Sample provenance")
        gr.HTML('<footer class="workspace-footer"><span>Hugging Face Bio · Genome Atlas</span>'
                '<a href="https://huggingface.co/buckets/HuggingFaceBio/genbank-annotations" target="_blank" rel="noopener noreferrer">Explore the annotation collection ↗</a></footer>', apply_default_css=False)
        outputs = [status, results, segment, file]
        for event in (search_button.click, accession.submit):
            event(search, accession, outputs).then(select_segment, [segment, mode, threshold], [metadata, start, end, plot, note, file])
        segment.input(select_segment, [segment, mode, threshold], [metadata, start, end, plot, note, file])
        region_inputs = [segment, start, end, mode, threshold]
        view.click(update_window, region_inputs, [plot, note])
        mode.input(update_window, region_inputs, [plot, note])
        threshold.release(update_window, region_inputs, [plot, note])
        download.click(export, segment, file)
    return demo


if __name__ == "__main__":
    build_app().queue(default_concurrency_limit=2).launch(server_name="0.0.0.0", theme=atlas_theme(), css=APP_CSS)