Commit ·
abc16da
1
Parent(s): 6cda24b
fix(rna_sc): harden load/visualize UMAP against missing embedding/leiden (#1)
Browse files- fix(rna_sc): harden load/visualize UMAP against missing embedding/leiden (9d23b390a1d1f41be152d4f7e8d5db68f3cfd0ac)
- TODO.md +14 -4
- memory.md +36 -0
- src/tools/rna_sc.py +135 -17
- tests/test_rna_sc_load.py +113 -0
TODO.md
CHANGED
|
@@ -332,11 +332,21 @@ correct (single-dataset, Path B, ULM, 98.3% coverage); these are the gaps.
|
|
| 332 |
`dataset_score_signature` scores it end-to-end (66.6% coverage on a synthetic
|
| 333 |
cohort); `load_signature_net` + `resolve_to_local_path` now fetch a private
|
| 334 |
signature URL with auth (env token OR cached `huggingface-cli login`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
- **Still open:** FACTORY rebuild of the Space on the 0.1.8 re-pin + e2e verify
|
| 336 |
-
on `hf-dev`; confirm prod RAM holds the subset live;
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
sample-level DE contrast; derive Werba signatures when needed. ADR-0006 items 7/8/9.
|
| 340 |
Companion to the biodata-registry Loveless ingestion (provenance/scope/gate plan
|
| 341 |
there). Design goal: keep user-facing runtime bulk-like.
|
| 342 |
- Two roles. The **Steele-subset h5ad** is an analyzable sc dataset — loads once
|
|
|
|
| 332 |
`dataset_score_signature` scores it end-to-end (66.6% coverage on a synthetic
|
| 333 |
cohort); `load_signature_net` + `resolve_to_local_path` now fetch a private
|
| 334 |
signature URL with auth (env token OR cached `huggingface-cli login`).
|
| 335 |
+
- **Done (2026-07-01) — `decoupler_load_and_visualize_data` hardened for subsets
|
| 336 |
+
lacking a precomputed UMAP/leiden** (was spun off → `task_8b2a1bdc`; branch
|
| 337 |
+
`claude/friendly-mendel-02443d`, off current `main`, pushed to `origin`, NOT
|
| 338 |
+
deployed). `_ensure_umap` reuses an existing embedding (pbmc3k unchanged) or
|
| 339 |
+
computes a bounded `normalize+log1p→pca→neighbors→leiden(igraph)→umap` pipeline
|
| 340 |
+
on the loaded copy, and NEVER hard-fails — on any failure the plot is skipped
|
| 341 |
+
and the loaded AnnData + metadata are still returned with a note. Grouping
|
| 342 |
+
detection covers R `make.names` atlas cols (`Clusters`, …), not just `leiden`;
|
| 343 |
+
leiden uses `flavor="igraph"` (no `leidenalg` dep on the Space). Sits on top of
|
| 344 |
+
the `_load_adata` seam, so the Loveless subsets now load AND visualize
|
| 345 |
+
end-to-end. Tests: `tests/test_rna_sc_load.py` (4 green).
|
| 346 |
- **Still open:** FACTORY rebuild of the Space on the 0.1.8 re-pin + e2e verify
|
| 347 |
+
on `hf-dev`; confirm prod RAM holds the subset live; pseudobulk-aggregation tool
|
| 348 |
+
for the sample-level DE contrast; derive Werba signatures when needed. ADR-0006
|
| 349 |
+
items 7/8/9.
|
|
|
|
| 350 |
Companion to the biodata-registry Loveless ingestion (provenance/scope/gate plan
|
| 351 |
there). Design goal: keep user-facing runtime bulk-like.
|
| 352 |
- Two roles. The **Steele-subset h5ad** is an analyzable sc dataset — loads once
|
memory.md
CHANGED
|
@@ -8,6 +8,42 @@ Last updated: 2026-07-01
|
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
## 2026-07-01 (later) — Role-2 signature PUBLISHED + merged main + resolver auth (branch `feat/loveless-sc-serving`)
|
| 12 |
|
| 13 |
Three things this session:
|
|
|
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
+
## 2026-07-01 (later 2) — Harden sc load/visualize against missing UMAP/leiden (branch `claude/friendly-mendel-02443d`, off current `main`, pushed to `origin`, NOT deployed)
|
| 12 |
+
|
| 13 |
+
`decoupler_load_and_visualize_data` (rna_sc.py) called `sc.pl.umap(color="leiden")`
|
| 14 |
+
unconditionally after load and crashed on any h5ad lacking `obsm['X_umap']` /
|
| 15 |
+
`obs['leiden']` — exactly the Loveless raw-count subsets (`gse155698_steele`,
|
| 16 |
+
`gse205013_werba`; biodata-registry 0.1.8): extracted as RAW COUNTS with the
|
| 17 |
+
atlas's corrected/scaled/integrated layers dropped → no embedding, no leiden. The
|
| 18 |
+
load tool is the Path-P entry point, so it would crash on first real use. (Was the
|
| 19 |
+
`task_8b2a1bdc` spin-off + the "Still open" harden bullet under the Loveless item.)
|
| 20 |
+
|
| 21 |
+
- New `_ensure_umap(adata)` → `(color_key, note)`, mutates in place (caller holds
|
| 22 |
+
a `read_h5ad_cached` copy). Reuses a precomputed embedding+grouping unchanged
|
| 23 |
+
(pbmc3k demo path preserved); else runs a bounded
|
| 24 |
+
`normalize_total(1e4)+log1p` (only if the matrix looks like raw counts) →
|
| 25 |
+
`pca → neighbors → leiden → umap`. **Never raises** — on failure the UMAP plot
|
| 26 |
+
is skipped and the loaded AnnData + metadata are still returned, with the reason
|
| 27 |
+
in `message`. leiden uses `flavor="igraph"` (no `leidenalg` dep on the Space).
|
| 28 |
+
- New `_looks_like_raw_counts` — bounded, sparse-aware (`scipy.sparse.issparse`; a
|
| 29 |
+
plain numpy `.data` is a memoryview, not counts — the bug that failed the first
|
| 30 |
+
cut).
|
| 31 |
+
- Grouping detection `_UMAP_GROUPING_CANDIDATES` covers R `make.names` atlas cols
|
| 32 |
+
(`Clusters`, …), not just `leiden`.
|
| 33 |
+
- Sits ON TOP of the `_load_adata` seam already on `main`, so the hardening + the
|
| 34 |
+
hosted-URL loader ship together: the Loveless subsets now load AND visualize
|
| 35 |
+
end-to-end. Tests: `tests/test_rna_sc_load.py` (4 green; full rna_sc + loading-plan
|
| 36 |
+
suite 26 green).
|
| 37 |
+
- **Reconciliation note:** this branch was originally cut from an older `main`
|
| 38 |
+
(the executor-seam commit `b187b88`, which is NOT in `main`) and lacked the
|
| 39 |
+
Loveless loader. Reconciled by resetting the branch onto current `origin/main`
|
| 40 |
+
(`6cda24b`, loveless) and re-applying ONLY the UMAP hardening — the executor seam
|
| 41 |
+
was dropped (it lives on `feat/executor-seam`, unaffected). Old pre-reset commits
|
| 42 |
+
recoverable via reflog (`d46b0e9`).
|
| 43 |
+
- **NOT merged to `main`/deployed.** Folds into the pending 0.1.8 factory-rebuild.
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
## 2026-07-01 (later) — Role-2 signature PUBLISHED + merged main + resolver auth (branch `feat/loveless-sc-serving`)
|
| 48 |
|
| 49 |
Three things this session:
|
src/tools/rna_sc.py
CHANGED
|
@@ -21,6 +21,7 @@ from typing import Annotated, Literal
|
|
| 21 |
|
| 22 |
import decoupler as dc
|
| 23 |
import matplotlib
|
|
|
|
| 24 |
import pandas as pd
|
| 25 |
# Analysis-specific imports
|
| 26 |
import scanpy as sc
|
|
@@ -115,6 +116,105 @@ def _load_adata(adata_path: str):
|
|
| 115 |
Path(local_path).unlink(missing_ok=True)
|
| 116 |
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
@rna_sc_mcp.tool
|
| 119 |
def decoupler_load_and_visualize_data(
|
| 120 |
# Primary data inputs
|
|
@@ -139,34 +239,52 @@ def decoupler_load_and_visualize_data(
|
|
| 139 |
# Use demo dataset as in tutorial
|
| 140 |
adata = dc.ds.pbmc3k()
|
| 141 |
|
| 142 |
-
#
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
#
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# Save processed data
|
| 152 |
data_file = OUTPUT_DIR / f"{out_prefix}_processed_data.h5ad"
|
| 153 |
adata.write_h5ad(data_file)
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
# Save metadata
|
| 156 |
metadata_file = OUTPUT_DIR / f"{out_prefix}_metadata.csv"
|
| 157 |
adata.obs.to_csv(metadata_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
return {
|
| 160 |
-
"message":
|
| 161 |
"reference": "https://github.com/scverse/decoupler-tutorials/blob/main/scell/rna_sc.ipynb",
|
| 162 |
-
"artifacts":
|
| 163 |
-
{
|
| 164 |
-
"description": "UMAP visualization with leiden clusters",
|
| 165 |
-
"path": str(umap_file.resolve()),
|
| 166 |
-
},
|
| 167 |
-
{"description": "Processed AnnData file", "path": str(data_file.resolve())},
|
| 168 |
-
{"description": "Cell metadata", "path": str(metadata_file.resolve())},
|
| 169 |
-
],
|
| 170 |
}
|
| 171 |
|
| 172 |
|
|
|
|
| 21 |
|
| 22 |
import decoupler as dc
|
| 23 |
import matplotlib
|
| 24 |
+
import numpy as np
|
| 25 |
import pandas as pd
|
| 26 |
# Analysis-specific imports
|
| 27 |
import scanpy as sc
|
|
|
|
| 116 |
Path(local_path).unlink(missing_ok=True)
|
| 117 |
|
| 118 |
|
| 119 |
+
# Groupings to color the initial UMAP by, in priority order. `leiden` is the
|
| 120 |
+
# tutorial default (decoupler's pbmc3k demo carries it); the rest cover atlas
|
| 121 |
+
# exports that use R `make.names` obs naming (e.g. `Clusters` holds cell-type
|
| 122 |
+
# labels) — so we never assume `leiden` exists as the grouping.
|
| 123 |
+
_UMAP_GROUPING_CANDIDATES = [
|
| 124 |
+
"leiden",
|
| 125 |
+
"louvain",
|
| 126 |
+
"Clusters",
|
| 127 |
+
"clusters",
|
| 128 |
+
"cluster",
|
| 129 |
+
"seurat_clusters",
|
| 130 |
+
"cell_type",
|
| 131 |
+
"celltype",
|
| 132 |
+
"CellType",
|
| 133 |
+
"cell_types",
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _looks_like_raw_counts(adata) -> bool:
|
| 138 |
+
"""Heuristic: raw UMI counts are non-negative and integer-valued.
|
| 139 |
+
|
| 140 |
+
Samples a bounded slice of ``adata.X`` so it stays cheap on a cpu-basic
|
| 141 |
+
Space regardless of dataset size.
|
| 142 |
+
"""
|
| 143 |
+
import scipy.sparse as sp
|
| 144 |
+
|
| 145 |
+
X = adata.X
|
| 146 |
+
data = X.data if sp.issparse(X) else np.asarray(X).ravel()
|
| 147 |
+
if data.size == 0:
|
| 148 |
+
return False
|
| 149 |
+
sample = np.asarray(data[:100_000], dtype=float)
|
| 150 |
+
if (sample < 0).any():
|
| 151 |
+
return False
|
| 152 |
+
return bool(np.allclose(sample, np.round(sample)))
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _ensure_umap(adata) -> tuple[str | None, str | None]:
|
| 156 |
+
"""Ensure ``adata`` can produce an initial UMAP, degrading gracefully.
|
| 157 |
+
|
| 158 |
+
Returns ``(color_key, note)`` and mutates ``adata`` in place (the caller
|
| 159 |
+
holds a fresh ``read_h5ad_cached`` copy, so the source h5ad is untouched).
|
| 160 |
+
|
| 161 |
+
- If a UMAP embedding and a known grouping are already present (e.g.
|
| 162 |
+
decoupler's pbmc3k demo), returns immediately — existing behavior is
|
| 163 |
+
preserved and nothing is recomputed.
|
| 164 |
+
- Otherwise runs a minimal, bounded pipeline (normalize+log1p if the matrix
|
| 165 |
+
looks like raw counts, then pca → neighbors → leiden → umap) so datasets
|
| 166 |
+
exported without embeddings (e.g. the Loveless raw-count subsets) are
|
| 167 |
+
still visualizable.
|
| 168 |
+
- Never raises: on any failure it returns ``(color_key_or_None, note)`` so
|
| 169 |
+
the caller can skip the plot but still return the loaded data + metadata.
|
| 170 |
+
"""
|
| 171 |
+
grouping = _find_column(adata.obs, _UMAP_GROUPING_CANDIDATES)
|
| 172 |
+
has_umap = "X_umap" in adata.obsm
|
| 173 |
+
|
| 174 |
+
if has_umap and grouping is not None:
|
| 175 |
+
return grouping, None
|
| 176 |
+
|
| 177 |
+
notes: list[str] = []
|
| 178 |
+
try:
|
| 179 |
+
if not has_umap and _looks_like_raw_counts(adata):
|
| 180 |
+
sc.pp.normalize_total(adata, target_sum=1e4)
|
| 181 |
+
sc.pp.log1p(adata)
|
| 182 |
+
notes.append("normalized_total(1e4)+log1p (input looked like raw counts)")
|
| 183 |
+
|
| 184 |
+
if not has_umap:
|
| 185 |
+
n_comps = min(50, adata.n_vars - 1, adata.n_obs - 1)
|
| 186 |
+
if n_comps < 2:
|
| 187 |
+
return grouping, (
|
| 188 |
+
"too few cells/genes to compute a UMAP; returning loaded "
|
| 189 |
+
"data without a plot"
|
| 190 |
+
)
|
| 191 |
+
sc.pp.pca(adata, n_comps=n_comps)
|
| 192 |
+
sc.pp.neighbors(adata, n_neighbors=min(15, adata.n_obs - 1))
|
| 193 |
+
|
| 194 |
+
if grouping is None:
|
| 195 |
+
try:
|
| 196 |
+
# flavor="igraph" uses igraph's built-in Leiden (no leidenalg
|
| 197 |
+
# dependency), which is what is available on the Space.
|
| 198 |
+
sc.tl.leiden(
|
| 199 |
+
adata, flavor="igraph", n_iterations=2, directed=False
|
| 200 |
+
)
|
| 201 |
+
grouping = "leiden"
|
| 202 |
+
notes.append("computed leiden clustering")
|
| 203 |
+
except Exception as e: # clustering is best-effort; UMAP still useful
|
| 204 |
+
notes.append(f"leiden clustering skipped ({e})")
|
| 205 |
+
|
| 206 |
+
if "X_umap" not in adata.obsm:
|
| 207 |
+
sc.tl.umap(adata)
|
| 208 |
+
notes.append("computed UMAP embedding")
|
| 209 |
+
|
| 210 |
+
return grouping, ("; ".join(notes) if notes else None)
|
| 211 |
+
except Exception as e:
|
| 212 |
+
return grouping, (
|
| 213 |
+
f"could not compute a UMAP embedding ({e}); returning loaded data "
|
| 214 |
+
"without a plot"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
@rna_sc_mcp.tool
|
| 219 |
def decoupler_load_and_visualize_data(
|
| 220 |
# Primary data inputs
|
|
|
|
| 239 |
# Use demo dataset as in tutorial
|
| 240 |
adata = dc.ds.pbmc3k()
|
| 241 |
|
| 242 |
+
# Ensure the data is visualizable. Precomputed embeddings (e.g. decoupler's
|
| 243 |
+
# pbmc3k demo) are used as-is; datasets exported without a UMAP/leiden (e.g.
|
| 244 |
+
# the Loveless raw-count subsets) get a bounded pipeline computed here. This
|
| 245 |
+
# never hard-fails — if no embedding can be produced, the plot is skipped and
|
| 246 |
+
# the loaded data + metadata are still returned.
|
| 247 |
+
color_key, viz_note = _ensure_umap(adata)
|
| 248 |
+
|
| 249 |
+
artifacts = []
|
| 250 |
+
umap_file = None
|
| 251 |
+
if "X_umap" in adata.obsm:
|
| 252 |
+
fig, ax = plt.subplots(figsize=(5, 4))
|
| 253 |
+
sc.pl.umap(adata=adata, color=color_key, ax=ax, show=False)
|
| 254 |
+
umap_file = OUTPUT_DIR / f"{out_prefix}_leiden_umap.png"
|
| 255 |
+
fig.savefig(umap_file, dpi=300, bbox_inches="tight")
|
| 256 |
+
plt.close(fig)
|
| 257 |
+
desc = (
|
| 258 |
+
f"UMAP visualization colored by {color_key}"
|
| 259 |
+
if color_key
|
| 260 |
+
else "UMAP visualization"
|
| 261 |
+
)
|
| 262 |
+
artifacts.append({"description": desc, "path": str(umap_file.resolve())})
|
| 263 |
|
| 264 |
# Save processed data
|
| 265 |
data_file = OUTPUT_DIR / f"{out_prefix}_processed_data.h5ad"
|
| 266 |
adata.write_h5ad(data_file)
|
| 267 |
+
artifacts.append(
|
| 268 |
+
{"description": "Processed AnnData file", "path": str(data_file.resolve())}
|
| 269 |
+
)
|
| 270 |
|
| 271 |
# Save metadata
|
| 272 |
metadata_file = OUTPUT_DIR / f"{out_prefix}_metadata.csv"
|
| 273 |
adata.obs.to_csv(metadata_file)
|
| 274 |
+
artifacts.append(
|
| 275 |
+
{"description": "Cell metadata", "path": str(metadata_file.resolve())}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
message = f"Loaded data with {adata.n_obs} cells and {adata.n_vars} genes"
|
| 279 |
+
if umap_file is None:
|
| 280 |
+
message += " (no UMAP plot produced)"
|
| 281 |
+
if viz_note:
|
| 282 |
+
message += f". Visualization note: {viz_note}"
|
| 283 |
|
| 284 |
return {
|
| 285 |
+
"message": message,
|
| 286 |
"reference": "https://github.com/scverse/decoupler-tutorials/blob/main/scell/rna_sc.ipynb",
|
| 287 |
+
"artifacts": artifacts,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
}
|
| 289 |
|
| 290 |
|
tests/test_rna_sc_load.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for src/tools/rna_sc.py::decoupler_load_and_visualize_data.
|
| 3 |
+
|
| 4 |
+
Regression guard for the Loveless single-cell subsets (biodata-registry 0.1.8,
|
| 5 |
+
ADR-0006 Role 1 serving): those h5ads are raw counts extracted from the Loveless
|
| 6 |
+
integrated atlas with the corrected/scaled/integrated layers dropped, so they
|
| 7 |
+
carry no `X_umap` embedding and no `leiden` clustering. The tool used to call
|
| 8 |
+
`sc.pl.umap(color="leiden")` unconditionally and crash on first real use. These
|
| 9 |
+
tests assert it now degrades gracefully on such data while preserving the
|
| 10 |
+
original behavior when an embedding + grouping are already present.
|
| 11 |
+
|
| 12 |
+
`_load_adata` (the shared local/HF-URL resolver, covered by test_rna_sc_loader)
|
| 13 |
+
handles a local path here; these tests focus only on the post-load UMAP/leiden
|
| 14 |
+
assumptions. Uses tiny synthetic AnnData (no network, no downloads).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import anndata as ad
|
| 23 |
+
import numpy as np
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 27 |
+
|
| 28 |
+
import src.tools.rna_sc as rna_sc # noqa: E402
|
| 29 |
+
from src.tools.rna_sc import decoupler_load_and_visualize_data # noqa: E402
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _write_raw_counts_adata(path: Path, n_obs: int = 60, n_vars: int = 80):
|
| 33 |
+
"""Synthetic raw-count AnnData with no UMAP and no leiden — mimics a
|
| 34 |
+
Loveless subset. `Name` uses the atlas's R make.names obs naming."""
|
| 35 |
+
rng = np.random.default_rng(0)
|
| 36 |
+
X = rng.poisson(2.0, size=(n_obs, n_vars)).astype("float32")
|
| 37 |
+
adata = ad.AnnData(X)
|
| 38 |
+
adata.obs_names = [f"cell{i}" for i in range(n_obs)]
|
| 39 |
+
adata.var_names = [f"gene{i}" for i in range(n_vars)]
|
| 40 |
+
adata.obs["Name"] = "sampleA"
|
| 41 |
+
adata.write_h5ad(path)
|
| 42 |
+
return adata
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.fixture(autouse=True)
|
| 46 |
+
def _redirect_output(tmp_path, monkeypatch):
|
| 47 |
+
"""Keep all artifacts out of the repo's tmp/outputs."""
|
| 48 |
+
monkeypatch.setattr(rna_sc, "OUTPUT_DIR", tmp_path)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_raw_counts_without_umap_or_leiden_returns(tmp_path):
|
| 52 |
+
"""The core regression: a raw-count h5ad with no X_umap and no leiden must
|
| 53 |
+
load successfully rather than crashing on sc.pl.umap(color='leiden')."""
|
| 54 |
+
h5ad = tmp_path / "loveless_like.h5ad"
|
| 55 |
+
_write_raw_counts_adata(h5ad)
|
| 56 |
+
|
| 57 |
+
out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
|
| 58 |
+
|
| 59 |
+
assert isinstance(out, dict)
|
| 60 |
+
assert "60 cells and 80 genes" in out["message"]
|
| 61 |
+
# A processed AnnData artifact is always returned, whether or not a plot was.
|
| 62 |
+
descs = [a["description"] for a in out["artifacts"]]
|
| 63 |
+
assert any("Processed AnnData" in d for d in descs)
|
| 64 |
+
assert any("Cell metadata" in d for d in descs)
|
| 65 |
+
# Every returned artifact path exists on disk.
|
| 66 |
+
for art in out["artifacts"]:
|
| 67 |
+
assert Path(art["path"]).exists()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_bounded_pipeline_computes_umap(tmp_path):
|
| 71 |
+
"""On raw counts the tool should compute a minimal pipeline and emit a UMAP
|
| 72 |
+
plot (leiden via igraph flavor, no leidenalg dependency)."""
|
| 73 |
+
h5ad = tmp_path / "loveless_like.h5ad"
|
| 74 |
+
_write_raw_counts_adata(h5ad)
|
| 75 |
+
|
| 76 |
+
out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
|
| 77 |
+
|
| 78 |
+
descs = [a["description"] for a in out["artifacts"]]
|
| 79 |
+
assert any("UMAP" in d for d in descs)
|
| 80 |
+
assert "note" in out["message"].lower()
|
| 81 |
+
|
| 82 |
+
# The processed h5ad carries the computed embedding.
|
| 83 |
+
import scanpy as sc
|
| 84 |
+
|
| 85 |
+
proc = next(a["path"] for a in out["artifacts"] if "Processed" in a["description"])
|
| 86 |
+
reloaded = sc.read_h5ad(proc)
|
| 87 |
+
assert "X_umap" in reloaded.obsm
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_precomputed_umap_and_leiden_preserved(tmp_path):
|
| 91 |
+
"""When X_umap + leiden already exist (pbmc3k-like), behavior is preserved:
|
| 92 |
+
a UMAP plot is emitted and no recompute note appears."""
|
| 93 |
+
rng = np.random.default_rng(1)
|
| 94 |
+
n_obs, n_vars = 40, 30
|
| 95 |
+
adata = ad.AnnData(rng.normal(size=(n_obs, n_vars)).astype("float32"))
|
| 96 |
+
adata.obs_names = [f"cell{i}" for i in range(n_obs)]
|
| 97 |
+
adata.var_names = [f"gene{i}" for i in range(n_vars)]
|
| 98 |
+
adata.obs["leiden"] = np.array(["0", "1"] * (n_obs // 2))
|
| 99 |
+
adata.obs["leiden"] = adata.obs["leiden"].astype("category")
|
| 100 |
+
adata.obsm["X_umap"] = rng.normal(size=(n_obs, 2))
|
| 101 |
+
h5ad = tmp_path / "pbmc_like.h5ad"
|
| 102 |
+
adata.write_h5ad(h5ad)
|
| 103 |
+
|
| 104 |
+
out = decoupler_load_and_visualize_data(adata_path=str(h5ad))
|
| 105 |
+
|
| 106 |
+
descs = [a["description"] for a in out["artifacts"]]
|
| 107 |
+
assert any("UMAP" in d and "leiden" in d for d in descs)
|
| 108 |
+
assert "note" not in out["message"].lower()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_missing_file_raises(tmp_path):
|
| 112 |
+
with pytest.raises(FileNotFoundError):
|
| 113 |
+
decoupler_load_and_visualize_data(adata_path=str(tmp_path / "nope.h5ad"))
|