File size: 9,169 Bytes
8f9b089
3e0e21b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9b089
 
 
8bd6618
8f9b089
984a525
3e0e21b
8f9b089
 
3e0e21b
8f9b089
 
984a525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e0e21b
8f9b089
3e0e21b
 
 
 
 
 
 
 
8f9b089
 
3e0e21b
8f9b089
3e0e21b
 
8f9b089
 
3e0e21b
 
 
 
 
 
 
 
 
 
 
 
8f9b089
 
3e0e21b
 
 
 
 
 
 
 
 
8f9b089
 
3e0e21b
 
8f9b089
 
3e0e21b
8f9b089
3e0e21b
8bd6618
 
 
 
8f9b089
3e0e21b
 
 
 
 
 
 
8bd6618
 
3e0e21b
 
 
 
 
 
 
 
 
 
8bd6618
 
3e0e21b
 
 
 
 
 
8f9b089
 
 
3e0e21b
 
 
8f9b089
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e0e21b
8f9b089
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e0e21b
8f9b089
 
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
"""
Disk-based dataset cache for DecoupleRpy.

WHY DISK INSTEAD OF MEMORY
---------------------------
The MCP server runs as a short-lived subprocess: every tool call spawns a new
`python server.py` process and exits after returning the result.  A module-level
memory dict is destroyed at the end of each call.

/tmp on HuggingFace Spaces persists for the lifetime of the container (hours to
days), so caching parsed h5ad files there eliminates the ~50MB GEO download on
all calls after the first.

Cache layout
------------
  /tmp/decoupleRpy/cache/
      GSE71729.h5ad          ← parsed AnnData (fast to read_h5ad, no download)
      GSE71729.gpl_cache/
          GPL14951.pkl       ← probeβ†’gene mapping dict
"""
from __future__ import annotations

import json
import re
import threading
from pathlib import Path
from typing import Any

CACHE_DIR = Path("/tmp/decoupleRpy/cache")


# ── In-memory AnnData cache (process-lifetime) ───────────────────────────────
# When server.py runs as a persistent HTTP process (`--transport http`), the
# same h5ad path is read on nearly every tool call of a multi-step run. Parsing
# a 50MB h5ad from disk each time is pure waste once the process is long-lived.
# This module-level cache holds the parsed AnnData for the process lifetime,
# keyed by (abspath, mtime, size) so a rewritten file is transparently
# re-read. Each read returns a `.copy()`, so callers may freely mutate (and
# write back) without corrupting the shared, read-mostly cache β€” this is what
# makes it safe to share one resident server across concurrent Gradio sessions.
# Under stdio transport (one process per call) it simply never gets a second
# hit, so it's a no-op there rather than a correctness risk.
_MEM_LOCK = threading.Lock()
_MEM_CACHE: dict[str, Any] = {}
_MEM_MAX_ENTRIES = 8  # bound memory; bulk h5ads are large


def _mem_key(path: str) -> str | None:
    try:
        st = Path(path).stat()
    except OSError:
        return None
    return f"{Path(path).resolve()}::{int(st.st_mtime)}::{st.st_size}"


def read_h5ad_cached(path: str) -> Any:
    """Read an h5ad, reusing a process-lifetime in-memory parse when possible.

    Returns a fresh `.copy()` every call, so the caller owns the object and may
    mutate or write it back without affecting the cache. Falls back to a plain
    `sc.read_h5ad` if the path can't be stat'd (e.g. a URL or missing file) so
    the caller sees the normal error.
    """
    import scanpy as sc

    key = _mem_key(path)
    if key is None:
        return sc.read_h5ad(path)

    with _MEM_LOCK:
        cached = _MEM_CACHE.get(key)
    if cached is not None:
        return cached.copy()

    adata = sc.read_h5ad(path)
    with _MEM_LOCK:
        # Drop stale entries for the same resolved path (older mtime/size).
        resolved = str(Path(path).resolve())
        for k in [k for k in _MEM_CACHE if k.startswith(resolved + "::") and k != key]:
            _MEM_CACHE.pop(k, None)
        _MEM_CACHE[key] = adata
        # Evict oldest if over the bound (dict preserves insertion order).
        while len(_MEM_CACHE) > _MEM_MAX_ENTRIES:
            _MEM_CACHE.pop(next(iter(_MEM_CACHE)))
    return adata.copy()


# ── Cache key helpers ────────────────────────────────────────────────────────

def url_to_cache_key(url_or_path: str) -> str:
    """Return a stable GSE accession key from any URL or path string.
    Picks the match with the most digits to avoid partial matches like
    GSE71 from GSE71nnn/GSE71729."""
    matches = re.findall(r"GSE(\d+)", url_or_path, re.IGNORECASE)
    if not matches:
        return url_or_path
    return "GSE" + max(matches, key=len)


# ── AnnData (dataset) cache ──────────────────────────────────────────────────

def _dataset_cache_path(cache_key: str) -> Path:
    return CACHE_DIR / f"{cache_key}.h5ad"


def get_dataset(cache_key: str) -> Any | None:
    """Return a cached AnnData, or None if not cached."""
    path = _dataset_cache_path(cache_key)
    if path.exists() and path.stat().st_size > 0:
        try:
            import scanpy as sc
            print(f"[cache] Disk hit for {cache_key} β€” loading from {path}")
            return sc.read_h5ad(path)
        except Exception as exc:
            print(f"[cache] Disk read failed for {cache_key}: {exc} β€” will re-download")
            path.unlink(missing_ok=True)
    return None


def set_dataset(cache_key: str, adata: Any) -> None:
    """Write an AnnData to the disk cache."""
    try:
        CACHE_DIR.mkdir(parents=True, exist_ok=True)
        path = _dataset_cache_path(cache_key)
        adata.write_h5ad(path)
        print(f"[cache] Wrote {cache_key} to disk cache ({path.stat().st_size // 1024} KB)")
    except Exception as exc:
        print(f"[cache] Failed to write {cache_key} to disk cache: {exc}")


def is_loaded(cache_key: str) -> bool:
    return _dataset_cache_path(cache_key).exists()


# ── GPL probe mapping cache ──────────────────────────────────────────────────

def _gpl_cache_path(accession: str) -> Path:
    # JSON (not pickle): the mapping is a plain dict[str, str], so JSON is safe
    # to deserialize and human-inspectable. Old .pkl files are simply ignored
    # (cache miss β†’ regenerated).
    return CACHE_DIR / "gpl" / f"{accession}.json"


def get_gpl(accession: str) -> dict[str, str] | None:
    """Return a cached probe→gene mapping, or None if not cached."""
    path = _gpl_cache_path(accession)
    if path.exists() and path.stat().st_size > 0:
        try:
            print(f"[cache] Disk hit for GPL {accession}")
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception as exc:
            print(f"[cache] Disk read failed for GPL {accession}: {exc}")
            path.unlink(missing_ok=True)
    return None


def set_gpl(accession: str, mapping: dict[str, str]) -> None:
    """Write a probe→gene mapping to the disk cache."""
    try:
        _gpl_cache_path(accession).parent.mkdir(parents=True, exist_ok=True)
        with open(_gpl_cache_path(accession), "w", encoding="utf-8") as f:
            json.dump(mapping, f)
        print(f"[cache] Wrote GPL {accession} to disk cache")
    except Exception as exc:
        print(f"[cache] Failed to write GPL {accession} to disk cache: {exc}")


# ── Preloader (optional warm-up, still useful for first-query speed) ─────────

def preload_datasets() -> None:
    """
    Pre-populate the disk cache at startup by downloading all registered
    geo_series_matrix datasets.  Runs in a background thread so the server
    stays responsive.  No-ops if the cache file already exists.
    """
    import pandas as pd
    import scanpy as sc

    from src.datasets.registry import get_registry
    from src.workflows.geo import load_geo_series_matrix_lines, parse_geo_series_matrix_lines
    from src.tools.rna import decode_geo_numeric_codes

    registry = get_registry()

    for dataset_id in registry.list():
        raw = registry.get(dataset_id)
        if raw is None:
            continue
        expr_src = raw.get("expression_source", {})
        if expr_src.get("type") != "geo_series_matrix":
            continue
        url = expr_src.get("url")
        if not url:
            continue

        cache_key = url_to_cache_key(url)
        if is_loaded(cache_key):
            print(f"[cache] {dataset_id} already on disk β€” skipping preload")
            continue

        print(f"[cache] Preloading {dataset_id} from {url} …")
        try:
            lines = load_geo_series_matrix_lines(url)
            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 = sc.AnnData(X=X, obs=obs_df)
            adata.var.index = pd.Index(probe_ids, name="probe_id")

            set_dataset(cache_key, adata)
            print(f"[cache] {dataset_id} preloaded: {adata.n_obs} Γ— {adata.n_vars}")
        except Exception as exc:
            print(f"[cache] Failed to preload {dataset_id}: {exc}")