File size: 12,204 Bytes
1644d2e | 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 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | """Assemble a counts/TPM/metadata delivery into an analysis-ready h5ad.
This is the library form of what `scripts/assemble_myc_kd_kmc_mouse.py` used to
do entirely inline: turn a Novogene-style delivery
gene_level_counts.tsv genes x samples, ENSMUSG ids, R write.table
header (first column name missing)
gene_level_abundances.tsv same shape, TPM (optional; stored as a layer)
sample metadata .xlsx / .csv / .tsv, must yield the obs columns
clone / arm / site / mouse_id
into
X int32 rounded counts, samples x genes (Path A / DESeq2)
layers['tpm'] float32 TPM
var.index MGI mouse symbol (duplicates summed)
obs clone, arm, site, mouse_id (categoricals) + extras
uns organism='mouse', analysis_space='mouse'
Nothing here calls ``sys.exit`` — every input problem raises
:class:`AssemblyError`, so the same code can back a CLI *and* the ADR-0011
upload path (where a bad sheet must become a message in the UI, not a dead
process). The CLI wrapper converts the exception back into an exit.
Safety note: these readers are the vetted-loader equivalent for the upload gate
— pandas/anndata parsing only. Nothing is ``exec``'d, and the caller is expected
to hand over paths that have already been staged and content-scanned.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SYMBOL_MAP_PATH = REPO_ROOT / "resources" / "mouse_ensembl_symbol_map.tsv.gz"
REQUIRED_OBS = ["clone", "arm", "site", "mouse_id"]
EXCEL_SUFFIXES = (".xlsx", ".xls")
class AssemblyError(Exception):
"""A user-fixable problem with the supplied files (bad column, no join)."""
def load_mouse_symbol_map(path: Path = SYMBOL_MAP_PATH) -> pd.Series:
"""ENSMUSG (unversioned) -> MGI symbol, as a Series."""
df = pd.read_csv(path, sep="\t", comment="#")
return pd.Series(df["mouse_symbol"].values, index=df["ensembl_gene_id"].values)
def map_mouse_ensembl_to_symbols(matrix: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
"""
Map a genes x samples matrix from ENSMUSG ids to MGI symbols.
Strips Ensembl version suffixes, drops unmapped genes, and SUMS rows that
collapse to the same symbol (correct for counts; acceptable for TPM since
multi-locus duplicates are a handful of rows). Returns (mapped matrix
indexed by symbol with an 'ensembl_gene_id' representative kept in
.attrs['ensembl_of_symbol'], stats dict).
"""
symap = load_mouse_symbol_map()
stripped = matrix.index.astype(str).str.split(".").str[0]
symbols = stripped.map(symap) # Index of symbols (NaN where unmapped)
mask = pd.notna(symbols)
mapped = matrix.loc[mask].copy()
mapped.index = symbols[mask]
n_dup = int(mapped.index.duplicated().sum())
ensembl_of_symbol = pd.Series(stripped[mask], index=mapped.index).groupby(level=0).first()
collapsed = mapped.groupby(level=0).sum()
collapsed.attrs["ensembl_of_symbol"] = ensembl_of_symbol
stats = {
"n_input_genes": int(matrix.shape[0]),
"n_unmapped_dropped": int(symbols.isna().sum()),
"n_duplicate_rows_summed": n_dup,
"n_output_symbols": int(collapsed.shape[0]),
}
return collapsed, stats
def parse_column_map(spec: str | None) -> dict[str, str]:
"""'mouse_id=Mouse,site=Type' -> {target obs col: source sheet col}."""
if not spec:
return {}
out: dict[str, str] = {}
for pair in spec.split(","):
if not pair.strip():
continue
if "=" not in pair:
raise AssemblyError(f"Bad column-map entry {pair!r}; expected target=Source.")
target, source = pair.split("=", 1)
out[target.strip()] = source.strip()
return out
def parse_value_maps(specs: list[str] | str | None) -> dict[str, dict[str, str]]:
"""['site=Tumor:tumor', 'site=Met:liver_met'] -> {'site': {'Tumor': 'tumor', ...}}.
A single string is accepted too (newline- or semicolon-separated), which is
what a one-line UI text box produces.
"""
if isinstance(specs, str):
specs = [s for s in specs.replace(";", "\n").splitlines() if s.strip()]
out: dict[str, dict[str, str]] = {}
for spec in specs or []:
if "=" not in spec or ":" not in spec.split("=", 1)[1]:
raise AssemblyError(f"Bad value-map entry {spec!r}; expected column=old:new.")
col, mapping = spec.split("=", 1)
old, new = mapping.split(":", 1)
out.setdefault(col.strip(), {})[old.strip()] = new.strip()
return out
def detect_header_row(raw: pd.DataFrame, max_scan: int = 20) -> int:
"""First row (within max_scan) with no empty cells — Novogene sheets carry
a short free-text preamble above the real header. Falls back to 0."""
for i in range(min(max_scan, len(raw))):
row = raw.iloc[i]
if row.notna().all() and not row.astype(str).str.strip().eq("").any():
return i
return 0
def load_metadata(
path: Path,
sample_column: str | None,
skip_rows: int | None = None,
column_map: dict[str, str] | None = None,
value_maps: dict[str, dict[str, str]] | None = None,
group_column: str | None = None,
control_label: str | None = None,
treatment_label: str = "shMyc",
) -> pd.DataFrame:
path = Path(path)
is_excel = path.suffix.lower() in EXCEL_SUFFIXES
if is_excel:
try:
if skip_rows is None:
raw = pd.read_excel(path, header=None)
skip_rows = detect_header_row(raw)
meta = pd.read_excel(path, skiprows=skip_rows)
except ImportError as e:
raise AssemblyError(
f"Reading {path.name} needs openpyxl ({e}). Export the sheet to CSV and retry."
) from e
else:
meta = pd.read_csv(
path,
sep="\t" if path.suffix.lower() in (".tsv", ".txt") else ",",
skiprows=skip_rows or 0,
)
meta.columns = [str(c).strip() for c in meta.columns]
# Rename mapped source columns to their target obs names (before the index
# is set, so the sample column itself may be remapped too).
for target, source in (column_map or {}).items():
if source not in meta.columns:
raise AssemblyError(
f"Column-map source column {source!r} not in sheet. Present: {list(meta.columns)}."
)
meta[target] = meta[source]
# Derive arm + clone from a single group column (control rows: clone=none).
if group_column:
if control_label is None:
raise AssemblyError("A group column requires a control label.")
if group_column not in meta.columns:
raise AssemblyError(
f"Group column {group_column!r} not in sheet. Present: {list(meta.columns)}."
)
group = meta[group_column].astype(str).str.strip()
is_control = group == control_label
if not is_control.any():
raise AssemblyError(
f"Control label {control_label!r} matches no rows of "
f"{group_column!r} (values: {sorted(group.unique())})."
)
meta["arm"] = np.where(is_control, control_label, treatment_label)
meta["clone"] = np.where(is_control, "none", group)
id_col = sample_column or meta.columns[0]
if id_col not in meta.columns:
raise AssemblyError(
f"Sample column {id_col!r} not in sheet. Present: {list(meta.columns)}."
)
meta = meta.set_index(meta[id_col].astype(str).str.strip()).drop(columns=[id_col])
meta.columns = [c.strip().lower().replace(" ", "_") for c in meta.columns]
for col, mapping in (value_maps or {}).items():
if col not in meta.columns:
raise AssemblyError(
f"Value-map column {col!r} not in metadata. Present: {list(meta.columns)}."
)
meta[col] = meta[col].astype(str).str.strip().replace(mapping)
missing = [c for c in REQUIRED_OBS if c not in meta.columns]
if missing:
raise AssemblyError(
f"Metadata is missing required column(s) {missing}. Present: "
f"{list(meta.columns)}. Rename them in the sheet, or use the column-map "
f"/ group-column options."
)
return meta
def assemble_h5ad(
counts_path: str | Path,
metadata_path: str | Path,
*,
tpm_path: str | Path | None = None,
out_path: str | Path | None = None,
sample_column: str | None = None,
skip_rows: int | None = None,
column_map: str | dict[str, str] | None = None,
value_maps: str | list[str] | dict[str, dict[str, str]] | None = None,
group_column: str | None = None,
control_label: str | None = None,
treatment_label: str = "shMyc",
staging_script: str = "src/uploads/assembly.py",
) -> tuple[Any, dict]:
"""Build the analysis h5ad from counts (+ optional TPM) and a metadata sheet.
Returns ``(adata, report)``. ``report`` carries the per-matrix mapping stats,
the obs value counts, and any unmatched sample ids — the same facts the CLI
used to print, so a UI can show them instead.
Writes to ``out_path`` when given; otherwise the AnnData is returned only.
"""
import anndata as ad
if isinstance(column_map, str) or column_map is None:
column_map = parse_column_map(column_map)
if not isinstance(value_maps, dict):
value_maps = parse_value_maps(value_maps)
counts = pd.read_csv(counts_path, sep="\t", index_col=0) # absorbs the R header
if counts.empty:
raise AssemblyError(f"{Path(counts_path).name} has no data rows.")
counts_sym, counts_stats = map_mouse_ensembl_to_symbols(counts)
if counts_sym.empty:
raise AssemblyError(
f"No gene id in {Path(counts_path).name} mapped to a mouse symbol — "
"the matrix does not look like unversioned/versioned ENSMUSG ids."
)
meta = load_metadata(
Path(metadata_path),
sample_column,
skip_rows=skip_rows,
column_map=column_map,
value_maps=value_maps,
group_column=group_column,
control_label=control_label,
treatment_label=treatment_label,
)
samples = [s for s in counts_sym.columns if s in meta.index]
unmatched = [s for s in counts_sym.columns if s not in meta.index]
if not samples:
raise AssemblyError(
"No matrix sample id matches the metadata index — check the sample "
f"column. Matrix ids: {list(counts_sym.columns)[:5]}…; "
f"metadata ids: {list(meta.index)[:5]}…"
)
X = counts_sym[samples].T
adata = ad.AnnData(
X=np.rint(X.values).astype(np.int32),
obs=meta.loc[samples].copy(),
var=pd.DataFrame(
{"ensembl_gene_id": counts_sym.attrs["ensembl_of_symbol"].reindex(X.columns).values},
index=pd.Index(X.columns, name="mouse_symbol"),
),
)
for col in REQUIRED_OBS:
adata.obs[col] = adata.obs[col].astype(str).str.strip().astype("category")
tpm_stats = None
if tpm_path is not None:
tpm = pd.read_csv(tpm_path, sep="\t", index_col=0)
tpm_sym, tpm_stats = map_mouse_ensembl_to_symbols(tpm)
adata.layers["tpm"] = (
tpm_sym.reindex(index=X.columns, columns=samples)
.fillna(0.0)
.T.values.astype(np.float32)
)
adata.uns["organism"] = "mouse"
adata.uns["analysis_space"] = "mouse"
adata.uns["staging_script"] = staging_script
report = {
"counts_stats": counts_stats,
"tpm_stats": tpm_stats,
"n_samples": int(adata.n_obs),
"n_genes": int(adata.n_vars),
"unmatched_samples": unmatched,
"obs_columns": list(adata.obs.columns),
"obs_counts": {c: dict(adata.obs[c].value_counts()) for c in REQUIRED_OBS},
}
if out_path is not None:
adata.write_h5ad(out_path)
report["out_path"] = str(out_path)
return adata, report
|