File size: 11,427 Bytes
5dd994d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3ed7f4
 
 
 
 
 
 
 
 
 
 
5dd994d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
"""
Shared helpers for precompute-cache assemble_*.py scripts.

These mirror the live GEO-loading code paths in src/workflows/geo.py and
src/tools/rna.py (decoupler_load_geo_series_matrix,
decoupler_annotate_probes_with_gpl) so precomputed h5ads have the same
shape/obs/var as a live load -- just without the per-query reasoning steps.
"""

import sys
from datetime import datetime, timezone
from pathlib import Path

import anndata as ad
import pandas as pd
from huggingface_hub import HfApi

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from src.workflows.geo import (
    decode_geo_numeric_codes,
    load_geo_series_matrix_lines,
    parse_geo_series_matrix_lines,
)

HF_REPO = "anne-voigt/pdac-research-data"


def load_series_matrix_to_anndata(url_or_path: str) -> ad.AnnData:
    """
    Load a GEO series matrix into an AnnData with the same obs columns
    (including decoded numeric-code companions) that
    decoupler_load_geo_series_matrix produces for the same URL.
    """
    lines = load_geo_series_matrix_lines(url_or_path)
    parsed = parse_geo_series_matrix_lines(lines)
    sample_ids = parsed["sample_ids"]
    probe_ids = parsed["probe_ids"]
    X = parsed["X"]
    sample_characteristics = parsed["sample_characteristics"]

    obs_df = pd.DataFrame(index=sample_ids)
    for key, values in sample_characteristics.items():
        col_name = key.lower().replace(" ", "_").replace("-", "_")
        if len(values) == len(sample_ids):
            obs_df[col_name] = values

    for col in list(obs_df.columns):
        result = decode_geo_numeric_codes(col, obs_df[col])
        if result is not None:
            base_name, decoded = result
            if base_name not in obs_df.columns:
                obs_df[base_name] = decoded

    adata = ad.AnnData(X=X, obs=obs_df)
    adata.var.index = pd.Index(probe_ids, name="probe_id")
    return adata


def _gse_range_subdir(gse_accession: str) -> str:
    """e.g. 'GSE62165' -> 'GSE62nnn', 'GSE71989' -> 'GSE71nnn'."""
    digits = gse_accession[len("GSE"):]
    return f"GSE{digits[:-3]}nnn"


def _ensure_gse_family_soft(gse_accession: str, out_dir: Path) -> Path:
    """
    Pre-place <out_dir>/<gse_accession>_family.soft.gz via HTTPS so
    GEOparse.get_GEO's FTP download is skipped entirely.

    GEOparse.get_GEO_file's skip-logic is: "if not path.isfile(filepath):
    download via FTP; else: use local version". The FTP download
    (ftp://ftp.ncbi.nlm.nih.gov/geo/series/.../soft/{gse}_family.soft.gz) is
    unreliable in this environment for files beyond a few MB (intermittent
    "Downloaded size do not match the expected size" -- confirmed for both a
    19MB and a 96MB file). The HTTPS mirror of the same path is reliable
    (confirmed via curl -sIL: 200 + correct Content-Length for all of
    GSE71989/GSE62165/GSE16515/GSE28735), so fetch it that way first.
    """
    import requests

    filename = f"{gse_accession}_family.soft.gz"
    filepath = out_dir / filename
    if filepath.is_file():
        return filepath

    range_subdir = _gse_range_subdir(gse_accession)
    url = f"https://ftp.ncbi.nlm.nih.gov/geo/series/{range_subdir}/{gse_accession}/soft/{filename}"
    print(f"  Pre-fetching {url} ...")
    resp = requests.get(url, stream=True, timeout=180)
    resp.raise_for_status()
    tmp_path = filepath.with_suffix(filepath.suffix + ".tmp")
    with open(tmp_path, "wb") as f:
        for chunk in resp.iter_content(chunk_size=1024 * 1024):
            f.write(chunk)
    tmp_path.rename(filepath)
    print(f"  Saved {filepath} ({filepath.stat().st_size / 1e6:.1f} MB)")
    return filepath


def annotate_with_gpl(adata, gse_accession, gpl_accession, gene_symbol_column="Gene Symbol"):
    """
    Annotate adata.var with a gene-symbol column via GEOparse, using the
    platform table embedded in the GSE-level family SOFT file
    (gse.gpls[gpl_accession].table) -- the same source as
    scripts/assemble_gse15471.py / assemble_gse17891.py.

    NOTE: this deliberately does NOT use src.workflows.geo's
    gpl_accession_to_url/load_gpl_soft_lines. As of 2026-06,
    gpl_accession_to_url targets the platform-level ".annot.gz" file
    (small, 1-10MB) rather than "_family.soft.gz" (multi-gigabyte for
    heavily-reused platforms -- confirmed: GPL570_family.soft.gz=71.9GB,
    GPL6244_family.soft.gz=8.7GB; GPL13667_family.soft.gz=6GB).
    decoupler_annotate_probes_with_gpl now works directly for GPL570/GPL6244
    via .annot.gz (confirmed: 45118/54675 and 22195/28869 probes annotated
    respectively), but GPL13667 has no .annot.gz (404), so it still needs a
    fallback. The GSE-level family file (tens of MB) embeds a scoped copy of
    the platform table and remains the proven-working source for this script
    (and for GPL13667-style platforms generally).

    Unlike the live tool, this does NOT raise if gene_symbol_column is
    missing -- it returns sym_col_used=None plus gpl_columns_available so
    the caller can inspect available columns and fall back.

    Returns (adata, sym_col_used_or_None, stats_dict).
    """
    import GEOparse

    out_dir = ROOT / "tmp" / "datasets"
    out_dir.mkdir(parents=True, exist_ok=True)
    _ensure_gse_family_soft(gse_accession, out_dir)
    gse = GEOparse.get_GEO(geo=gse_accession, destdir=str(out_dir), silent=True)

    source_used = f"GEOparse:{gse_accession}:{gpl_accession}"
    gpl = gse.gpls.get(gpl_accession)
    if gpl is None or gpl.table is None or gpl.table.empty:
        return adata, None, {
            "source_used": source_used,
            "gpl_columns_available": [],
            "requested_column_found": False,
            "error": f"{gpl_accession} table not found/empty in {gse_accession} "
                     f"(available GPLs: {list(gse.gpls.keys())})",
        }

    gpl_columns_available = list(gpl.table.columns)
    if gene_symbol_column not in gpl_columns_available:
        return adata, None, {
            "source_used": source_used,
            "gpl_columns_available": gpl_columns_available,
            "requested_column_found": False,
        }

    probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]
    gpl_map = gpl.table.set_index(probe_col)[gene_symbol_column]
    gpl_map = gpl_map[~gpl_map.index.duplicated()]

    probe_ids = adata.var.index.astype(str)
    mapped = gpl_map.reindex(probe_ids).fillna("")
    adata.var[gene_symbol_column] = mapped.values

    n_total = len(probe_ids)
    n_annotated = int((mapped != "").sum())
    return adata, gene_symbol_column, {
        "source_used": source_used,
        "gpl_columns_available": gpl_columns_available,
        "n_total": n_total,
        "n_annotated": n_annotated,
        "n_unannotated": n_total - n_annotated,
        "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
        "requested_column_found": True,
    }


def annotate_with_gpl_gene_assignment(adata, gse_accession, gpl_accession, assignment_column="gene_assignment"):
    """
    Annotate adata.var with a "gene_symbol" column parsed from a GPL
    'gene_assignment'-style column (Affymetrix Gene/Exon ST arrays, e.g.
    GPL6244), via the same GSE-level family SOFT file as annotate_with_gpl.

    GPL6244's platform table has no "Gene Symbol" column. Instead its
    "gene_assignment" column is a '///'-delimited list of groups, each
    '<transcript_id> // <gene_symbol> // <description> // <chr_location> //
    <entrez_id>' (or '---' if the probe has no annotation). This takes the
    gene symbol (2nd field) from the FIRST group only.

    Returns (adata, "gene_symbol", stats_dict) -- same shape as
    annotate_with_gpl's return (sym_col_used is always "gene_symbol" on
    success, or None if assignment_column is missing).
    """
    import GEOparse

    out_dir = ROOT / "tmp" / "datasets"
    out_dir.mkdir(parents=True, exist_ok=True)
    _ensure_gse_family_soft(gse_accession, out_dir)
    gse = GEOparse.get_GEO(geo=gse_accession, destdir=str(out_dir), silent=True)

    source_used = f"GEOparse:{gse_accession}:{gpl_accession}:{assignment_column}"
    gpl = gse.gpls.get(gpl_accession)
    if gpl is None or gpl.table is None or gpl.table.empty:
        return adata, None, {
            "source_used": source_used,
            "gpl_columns_available": [],
            "requested_column_found": False,
            "error": f"{gpl_accession} table not found/empty in {gse_accession} "
                     f"(available GPLs: {list(gse.gpls.keys())})",
        }

    gpl_columns_available = list(gpl.table.columns)
    if assignment_column not in gpl_columns_available:
        return adata, None, {
            "source_used": source_used,
            "gpl_columns_available": gpl_columns_available,
            "requested_column_found": False,
        }

    probe_col = "ID" if "ID" in gpl.table.columns else gpl.table.columns[0]

    def _first_gene_symbol(value):
        if not isinstance(value, str):
            return ""
        first_group = value.split(" /// ")[0].strip()
        if first_group == "---" or not first_group:
            return ""
        fields = first_group.split(" // ")
        if len(fields) < 2:
            return ""
        return fields[1].strip()

    symbols = gpl.table[assignment_column].map(_first_gene_symbol)
    gpl_map = pd.Series(symbols.values, index=gpl.table[probe_col].astype(str))
    gpl_map = gpl_map[~gpl_map.index.duplicated()]

    probe_ids = adata.var.index.astype(str)
    mapped = gpl_map.reindex(probe_ids).fillna("")
    adata.var["gene_symbol"] = mapped.values

    n_total = len(probe_ids)
    n_annotated = int((mapped != "").sum())
    return adata, "gene_symbol", {
        "source_used": source_used,
        "gpl_columns_available": gpl_columns_available,
        "n_total": n_total,
        "n_annotated": n_annotated,
        "n_unannotated": n_total - n_annotated,
        "coverage_pct": round(100 * n_annotated / n_total, 1) if n_total else 0.0,
        "requested_column_found": True,
    }


def stamp_provenance(adata, *, source_url, dataset_id, biodata_registry_commit, script_name, extra=None):
    """Write plain-string .uns provenance keys (h5ad-safe)."""
    adata.uns["precompute_source_url"] = source_url
    adata.uns["precompute_built_at"] = datetime.now(timezone.utc).isoformat()
    adata.uns["precompute_biodata_registry_commit"] = biodata_registry_commit
    adata.uns["precompute_dataset_id"] = dataset_id
    adata.uns["precompute_script"] = script_name
    if extra:
        for k, v in extra.items():
            adata.uns[k] = str(v)
    return adata


def write_and_upload(adata, dataset_id, dry_run=False):
    """Write to tmp/datasets/{dataset_id}.h5ad and upload to HF_REPO."""
    out_dir = ROOT / "tmp" / "datasets"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"{dataset_id}.h5ad"
    adata.write_h5ad(out_path)
    print(f"Wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB)")

    if dry_run:
        print(f"[dry-run] Skipping upload to {HF_REPO}/{dataset_id}.h5ad")
        return out_path

    api = HfApi()
    api.upload_file(
        path_or_fileobj=str(out_path),
        path_in_repo=f"{dataset_id}.h5ad",
        repo_id=HF_REPO,
        repo_type="dataset",
        commit_message=f"Add precomputed {dataset_id} h5ad (precompute cache phase 1)",
    )
    print(f"Uploaded to {HF_REPO}/{dataset_id}.h5ad")
    return out_path