| """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) |
| 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] |
|
|
| |
| |
| 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] |
|
|
| |
| 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) |
| 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 |
|
|