diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 53d31e8..ddf802f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ concurrency: jobs: lint-and-dryrun: # Fast smoke test: lints the workflow and builds the full DAG on a tiny synthetic - # decoupled dataset (.tests/integration). No Conda envs or Space Ranger needed — a + # decoupled dataset (.test). No Conda envs or Space Ranger needed — a # dry-run validates the graph and (via the Snakefile) the config against the schema. runs-on: ubuntu-latest steps: @@ -41,5 +41,7 @@ jobs: # (or an example config pointing at generated paths) cannot break CI. - name: Lint workflow run: snakemake -s workflow/Snakefile -d .test --lint --workflow-profile none + - name: Catalogue rule graph + run: snakemake -s workflow/Snakefile -c 1 -d .test --forceall --rulegraph > /dev/null - name: Dry-run (decoupled smoke test) run: snakemake -s workflow/Snakefile -d .test --dry-run --workflow-profile none diff --git a/.gitignore b/.gitignore index 468aab2..046e9df 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ __pycache__/ Thumbs.db # Test outputs -.test/output/ +.test/test_output/ # Doc build site/ diff --git a/.snakemake-workflow-catalog.yml b/.snakemake-workflow-catalog.yml index d5aaa13..703629f 100644 --- a/.snakemake-workflow-catalog.yml +++ b/.snakemake-workflow-catalog.yml @@ -4,8 +4,9 @@ usage: mandatory-flags: desc: > - Configure config/config.yaml and the required sample sheets before - execution. Space Ranger must be installed separately when required. + Copy config/config.yaml.template to config/config.yaml, then configure it + and the required sample sheets. Space Ranger must be installed separately + when required. flags: [] software-stack-deployment: diff --git a/.test/config/config.yaml b/.test/config/config.yaml index 6e75510..715a1ec 100644 --- a/.test/config/config.yaml +++ b/.test/config/config.yaml @@ -1,17 +1,17 @@ # Canonical catalogue/CI fixture (mode: decoupled). Paths are relative to .test/. mode: decoupled -post_processing_outdir: output -logdir: logs -spaceranger_processing_outdir: sr -geojson_path: geojson -contract_dir: data +base_dir: test_output +post_processing_outdir: "{base_dir}/output" +logdir: "{base_dir}/logs" +spaceranger_processing_outdir: "{base_dir}/sr" +geojson_path: "{base_dir}/geojson" +contract_dir: "data" random_seed: 0 use_precomputed_clusters: false precomputed_metadata_dir: '' core_samples: config/core_samples.tsv xenium5k: - xenium_dir: /scratch/CBiB/soterino/xenium/{sample} - zarr_dir: '' + xenium_dir: "{base_dir}/xenium/{sample}" qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 @@ -106,13 +106,13 @@ spatial_niches: niche_dir: '' lambda: 0.8 num_neighbours: 18 - cluster_n_neighbours: 15 - n_iterations: 2 + cluster_n_neighbours: 50 + n_iterations: -1 max_m: 1 nbr_weight_decay: scaled_gaussian use_hvg: true n_top_genes: 2000 - niche_resolution: 0.3 + niche_resolution: 0.1 refine: false refine_num_neigh: 6 refine_iterations: 1 @@ -266,7 +266,7 @@ resources: mem_mb: 65536 runtime: 240 threads: 4 - explore_genes_spatial: + explore_genes_sample: mem_mb: 32768 runtime: 360 threads: 4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b5b9ae..1c530aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,16 +20,19 @@ The practical consequence: **to support a new platform you write a new HeadBlock ## Repository layout ``` +.test/ tiny synthetic decoupled fixture for CI +config/ config.yaml, README.md (config reference), sample sheets +demos/ public-data worked examples +docs/ the MkDocs site +images/ rulegraph and main image +profiles/ Snakemake profile configuration +tools/ scripts to extract external annotations and color the repo images/ workflow/ ├── Snakefile globals, mode selection, named targets ├── rules/*.smk one file per rule (+ common.smk for shared helpers) ├── scripts/*.py,*.R rule implementations ├── envs/*.yaml one Conda env per rule group (+ *_linux-64.lock) └── schemas/*.yaml config + sample-sheet validation -config/ config.yaml, README.md (config reference), sample sheets -docs/ the MkDocs site -.test/ tiny synthetic decoupled fixture for CI -reproduction/ public-data worked examples ``` ## Development setup @@ -41,9 +44,9 @@ standards. ```bash git clone https://github.com/cbib/SpaceBlocks && cd SpaceBlocks # Install the development environment -conda create -f workflow/envs/dev.yaml +conda env create -f workflow/envs/dev.yaml conda activate spaceblocks_dev -snakemake -n --sdm conda # dry-run: builds the DAG, validates the config, provisions envs +snakemake -n --sdm conda # dry-run: builds the DAG and validates the config snakemake -s workflow/Snakefile -d .test -n --workflow-profile none # decoupled smoke test ``` @@ -73,7 +76,7 @@ These are load-bearing, and most past bugs we experienced during development cam - **Rules pass params explicitly.** A `.smk` rule reads from `config` and passes values through `params:`; the script reads `snakemake.params`, **never `config` directly**. After any change, cross-check that every param name matches between the `.smk` and its script. -- **Resources scale with retries.** Don't hardcode resources in a rule. Every compute rule draws `mem_mb`/`runtime`/`threads` from +- **Resources scale with retries.** Don't hardcode resources in a rule. Every compute rule draws `mem_mb`/`runtime`/`threads` from `config["resources"]` (with a `default`), and `mem_mb` grows with the attempt number. - **The contract convention.** `obs["cell_id"]` must equal `obs_names` (as strings); downstream joins key on it. Head-produced contracts live at `SAMPLES_DIR/{sample}/{sample}_unfiltered.h5ad` @@ -99,30 +102,35 @@ changes. See the `xenium5k` head as a reference implementation. ## Validating a change -Run the applicable checks locally before pushing. The first two mirror CI and use the committed -`mode: decoupled` fixture under `.test/`: +Run the applicable checks locally before pushing. The lint and dry-run commands mirror CI and use +the committed `mode: decoupled` fixture under `.test/`: ```bash -# 1. Workflow parses + lints (against the committed test fixture) -snakemake -s workflow/Snakefile -d .test --lint +# 1. Formatting and file-hygiene hooks pass +pre-commit run --all-files + +# 2. Workflow parses + lints (against the committed test fixture) +snakemake -s workflow/Snakefile -d .test --lint --workflow-profile none -# 2. The decoupled CoreBlock DAG builds +# 3. The decoupled CoreBlock DAG builds snakemake -s workflow/Snakefile -d .test -n --workflow-profile none -# 3. Config still validates against the schema +# 4. Catalog fixture and user template still validate against the schema python -c "import yaml,jsonschema; jsonschema.validate(yaml.safe_load(open('config/config.yaml')), yaml.safe_load(open('workflow/schemas/config.schema.yaml'))); print('Configuration schema validation passed')" +python -c "import yaml,jsonschema; jsonschema.validate(yaml.safe_load(open('config/config.yaml.template')), yaml.safe_load(open('workflow/schemas/config.schema.yaml'))); print('Configuration template schema validation passed')" -# 4. Docs build cleanly (only if you touched docs/) +# 5. Docs build cleanly (only if you touched docs/) # Activate the development environment conda activate spaceblocks_dev # Build the docs mkdocs build --strict ``` -Before a catalogue release, generate the decoupled rule graph once manually: +Before a Catalog release, run the two checks used by the Catalog: ```bash -snakemake -s workflow/Snakefile -d .test --forceall --rulegraph --workflow-profile none > pipeline_rulegraph.dot +snakemake --lint +snakemake -s workflow/Snakefile -c 1 -d .test --forceall --rulegraph > pipeline_rulegraph.dot ``` Rendering the DOT file to SVG is optional and requires Graphviz: @@ -131,7 +139,7 @@ Rendering the DOT file to SVG is optional and requires Graphviz: dot -Tsvg pipeline_rulegraph.dot > images/rulegraph.svg ``` -If you modify a HeadBlock, also dry-run the affected mode using a complete platform-specific configuration and representative mock or real inputs. Changing only `mode` in the generic `config/config.yaml` is not sufficient because its input paths are placeholders. Supported modes are `visiumhd`, `xenium5k`, `atera`, `merscope`, and `decoupled`. +If you modify a HeadBlock, also dry-run the affected mode using a complete platform-specific configuration and representative mock or real inputs. Changing only `mode` in `config/config.yaml.template` is not sufficient because its input paths are placeholders. Supported modes are `visiumhd`, `xenium5k`, `atera`, `merscope`, and `decoupled`. Quick per-file sanity checks are cheap and worth it: `python -c "import ast; ast.parse(open('file.py').read())"` for Python, and `yaml.safe_load` for any YAML you edit. @@ -155,7 +163,7 @@ Two workflows run on every PR and must pass: ## Submitting a pull request -1. Branch from `main`, keep the change focused, and make sure the validation commands above pass. +1. Branch from `dev`, keep the change focused, and make sure the validation commands above pass. 2. Write a clear PR description: what changed and why. If behaviour changed, update the affected page under `docs/`. 3. For bug fixes, a one-line note of the root cause in the PR helps reviewers. diff --git a/README.md b/README.md index 72e1706..8abeafe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- SpaceBlocks + SpaceBlocks

Documentation @@ -16,9 +16,9 @@ ## Streamlined, reproducible, spatial transcriptomics analyses with Snakemake -SpaceBlocks is a technology-agnostic Snakemake workflow for **semi-automated analysis of single-cell-resolution spatial transcriptomics (ST)**. +SpaceBlocks is a technology-agnostic Snakemake workflow for **expert-supervised cohort-level analysis of single cell resolution spatial transcriptomics (ST)**. -It supports scalable and reproducible quality control, clustering, annotation, integration, differential expression, and spatial neighbourhood or niche analysis. +It supports scalable and reproducible quality control, clustering, annotation, integration, differential expression, and spatial neighbourhood and niche analysis. Its modular architecture supports both public and in-house ST datasets while remaining maintainable and extensible. @@ -31,21 +31,31 @@ Its modular architecture supports both public and in-house ST datasets while rem ## Overview -The workflow is divided into (optional) technology-specific **HeadBlocks** and common **CoreBlocks** that streamline pre-, post-processing, and informative exploration of results. +The workflow is divided into (optional) technology-specific **HeadBlocks** and common **CoreBlocks** that streamline preprocessing, postprocessing, and informative exploration of results. -

SpaceBlocks workflow overview

Workflow overview and partial output from Xenium 5K demo example.

+
-A full SpaceBlocks run takes three inputs: +

+ SpaceBlocks workflow overview +

-1. **ST formatted AnnData objects**. Either generated from the HeadBlocks, or manually formatted as a standardized h5ad AnnData object. For brevity, **we refer to each of these objects as THE CONTRACT**. Their structure (count matrix, spatial coordinates and optional region annotations) is validated (`validate_input`) before downstream analyses. +> **Overview of the SpaceBlocks workflow**. **(a)** Snakevision tubemap scheme showcasing the workflow rules, coloured by technology and processing step. The AnnData contract validation entry point and user-supervised steps are displayed in **bold font**. **(b)** Schematic representation of the outputs produced by the CoreBlocks subworkflow and main design features of the pipeline. **(c)** Runtime benchmarking across Visium HD, Xenium 5K and MERSCOPE datasets. β denotes the fitted scaling exponent; doubling the cell count multiplies the estimated wall-clock time by 2^β. **(d)** SpaceBlocks processing of the multiple sclerosis MERSCOPE dataset from Feng et al. (2025). Left, BANKSY niches annotated in the publication, shown on a UMAP. Right, the number of unique domain-specific upregulated genes identified by one-vs-rest Wald tests (Benjamini–Hochberg adjusted p-value < 0.05 and |log2FC| > 0.25). -2. **Region annotations**. Provided in GeoJSON format. +
+ +SpaceBlocks consumes ST data and can additionally use region and cell-type annotations: + +1. **ST formatted AnnData objects**. Either generated from the HeadBlocks, or manually formatted as a standardized h5ad AnnData object. For brevity, **we refer to each of these objects as THE CONTRACT**. Their structure (count matrix, spatial coordinates and optional region annotations) is validated (`validate_input`) before downstream analyses. -3. **Cell type annotations**. Automatically generated through externally annotated references (`ingest`), manually annotating clusters (default) or providing externally generated annotations. +2. **Region annotations (optional)**. Provided as GeoJSON files in HeadBlock modes, or already + stored in `obs["region_annotation"]` in a decoupled contract. -The only mandatory input for SpaceBlocks is the actual ST data (1). +3. **Cell type annotations**. Automatically transferred from a reference (`ingest`), + manually assigned from clusters (default), or supplied as external labels. -SpaceBlocks provides tools to ease region annotation via QuPath (ideal if collaborating with pathologists) and cell type annotation (either manual, semi-automatic or external). +SpaceBlocks provides tools to ease region annotation via QuPath (ideal if collaborating with pathologists) and cell type annotation (either manual, ingested or external). ## Quickstart @@ -54,15 +64,15 @@ SpaceBlocks requires [Snakemake](https://snakemake.readthedocs.io) and Conda/Mam If you are not familiar with Snakemake, there are three key files to configure: 1. `workflow/Snakefile` — loads the config file/s (normally, at `config/config.yaml`), and has the instructions to execute the workflow. It is normally static, so you should not modify it. -2. The `config.yaml` — defines the parameters for your workflow run (i.e. where the input and output should be found, whether to use an external reference for cell type annotation, etc.). **It needs to be configured.** The config may require other files needed for the run (in the case of SpaceBlocks, `config/core_samples.tsv`). -3. The `profiles/config.yaml` — Snakemake is a workflow manager that allows parallelization in systems with job schedulers (i.e. `slurm`), or local execution. **The profile needs to be adapted to your system.** We provide a default profile that runs locally and a slurm profile we used on our HPC. +2. The `config.yaml` — defines the parameters for your workflow run (i.e. where the input and output should be found, whether to use an external reference for cell type annotation, etc.). Copy `config/config.yaml.template` to `config/config.yaml`, then configure it for your project. The config may require other files needed for the run (in the case of SpaceBlocks, `config/core_samples.tsv`). +3. The `profiles//config.yaml` — Snakemake is a workflow manager that allows parallelization in systems with job schedulers (i.e. `Slurm`), or local execution. **The profile needs to be adapted to your system.** We provide a default profile that runs the `test` pipeline locally and a slurm profile we used on our HPC. The full [configuration reference](https://cbib.github.io/SpaceBlocks/configuration/) presents a detailed explanation on how to set `config.yaml`. We recommend reading the [configuration reference](https://cbib.github.io/SpaceBlocks/configuration/) first, and then starting with the tutorial on [how to streamline a full run](https://cbib.github.io/SpaceBlocks/getting-started), and an [example with public data](https://cbib.github.io/SpaceBlocks/demos/). > [!TIP] -> Any run can be fully reproduced end-to-end only with the experimental design and cell type annotations. +> Once the input data is available, a run can be reproduced from its configuration, sample sheet, and any manual or external annotations. > > Color palettes for sample metadata, cell type annotations and spatial niches are fully customizable from `config/config.yaml`. @@ -72,23 +82,27 @@ In short, to run the workflow: # 1. Get the workflow (or: snakedeploy deploy-workflow cbib/SpaceBlocks spaceblocks --tag ) git clone https://github.com/cbib/SpaceBlocks && cd SpaceBlocks -# 2. Configure +# 2. Configure the config/config.yaml and the Snakemake profile in profiles/ +cp config/config.yaml.template config/config.yaml # config/visiumhd_samples.csv — if running mode : visiumhd # config/core_samples.tsv — one row per sample (+ any design columns) -# config/config.yaml — mode: visiumhd | xenium5k | decoupled, and paths -# profiles/**/config.yaml — profiles/default provides an example for slurm +# config/config.yaml — mode: visiumhd | xenium5k | atera | merscope | decoupled +# profiles/**/config.yaml — local and Slurm execution examples # # Note: Invoke profile manually (e.g. --profile profiles/default) or # set environment variable (e.g. export SNAKEMAKE_PROFILE=profiles/default ) -# 3. Check the plan +# 3. Dry-run your SpaceBlocks configuration snakemake -n --sdm conda -# 4. Make the QuPath images, annotate regions in QuPath, then run the core -snakemake qupath_images qc_sweep --sdm conda # → annotate, export GeoJSON to config["geojson_path"] + sweep QC thresholds -snakemake run_preprocessing --sdm conda # validate → QC/normalise → cluster -snakemake run_postprocessing --sdm conda # annotate → integrate → DE → reports + subclusters -snakemake run_exploration --sdm conda # explore genes (integrated and per sample) +# 4. Make the QuPath images (only when using the HeadBlocks) +snakemake qupath_images --sdm conda +# 5. Annotate regions in QuPath and screen the QCs +snakemake qc_sweep_all --sdm conda # sweep QC thresholds +# 6. Commit the QCs and run the CoreBlocks +snakemake run_preprocessing --sdm conda # validate → QC/normalise → cluster +snakemake run_postprocessing --sdm conda # annotate → integrate → DE → reports + subclusters +snakemake run_exploration --sdm conda # explore genes (integrated and per sample) ``` ## Documentation @@ -118,4 +132,4 @@ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for the arc ## License -Released under the MIT License — see [LICENSE](LICENSE). +Released under the MIT License — see [LICENSE](LICENSE.md). diff --git a/config/README.md b/config/README.md index d85d143..483d08f 100644 --- a/config/README.md +++ b/config/README.md @@ -1,18 +1,18 @@ -This page explains how to fully set up the config files to run SpaceBlocks. Full documentation can be found at https://cbib.github.io/SpaceBlocks/configuration/. +This page explains how to fully set up the config files to run SpaceBlocks. Full documentation can be found at https://cbib.github.io/SpaceBlocks/configuration/. **If you are visualizing the documentation from the Snakemake catalog** we recommend visiting the SpaceBlocks GitHub, since the rendered tube map does not include the HeadBlocks and is not consistently colored. -SpaceBlocks is configured through **`config/config.yaml`**, which contains all the parameters needed for the run, plus one or two **sample sheets**. +Copy **`config/config.yaml.template`** to **`config/config.yaml`** before configuring a run. The template contains all parameters, while the committed `config/config.yaml` is only the runnable fixture used by the Snakemake Workflow Catalog. -The explanations on this page are divided by type (directory, parameter, color, and so on). In the config file, parameters are divided by Block and function. +The explanations on this page are divided by type (directory, parameter, color, etc.). In the config file, parameters are divided by Block and function. **Every key in `config/config.yaml` is validated** against `workflow/schemas/config.schema.yaml` before the run starts, so a typo or a missing required field fails immediately with a clear message. -!!! note "Snakemake-catalog page" +!!! warning "Complete list of SpaceBlocks parameters" The full, always-updated table of every parameter (type, default, required) is generated automatically from the schema and shown on the workflow's [Snakemake-catalog page](https://snakemake.github.io/snakemake-workflow-catalog/docs/workflows/cbib/SpaceBlocks.html#workflow-parameters). This page covers the *how* and the *why*; the schema is the exhaustive reference. ## 1. Choose a mode -`mode` selects which **Headblock** builds the standardized contract h5ad (or none): +The config `mode` selects which **Headblock** builds the standardized contract h5ad. In `decoupled` mode, SpaceBlocks consumes contract h5ads that already exist. !!! warning "Space Ranger installation" The **Visium HD** HeadBlock additionally needs an external Space Ranger installation. @@ -21,35 +21,76 @@ The explanations on this page are divided by type (directory, parameter, color, | --- | --- | --- | | `visiumhd` | `spaceranger_count_vhd` → `generate_qupath_vhd` → `prepare_input_vhd` | 10x Visium HD fastqs + Space Ranger | | `xenium5k` | `convert_zarr_x5k` → `generate_qupath_x5k` → `prepare_input_x5k` | Xenium output bundle | -| `atera` | `convert_zarr_ate` → `generate_qupath_ate` → `prepare_input_ate` | Atera output bundle; optional registered H&E inputs | +| `atera` | `convert_zarr_ate` → `generate_qupath_ate` → `prepare_input_ate` | Atera (alpha) output bundle; optional registered H&E inputs | | `merscope` | `generate_qupath_mer` → `prepare_input_mer` | MERSCOPE region directory | | `decoupled` | *(none)* | Pre-existing contract h5ad files you provide | -The analysis **CoreBlocks** are identical in all five cases. Atera support is currently alpha because the public preview format may change before commercial release. +The analysis **CoreBlocks** are identical in all five cases. Atera support is currently in alpha because the public preview format may change before commercial release. ## 2. Paths, files and sample sheets The SpaceBlocks `config/config.yaml` sets the input and output directories, and gets the sample information and metadata (for integration and plotting) from one or two TSV files, depending on the `config["mode"]` set. +!!! tip "Setting a base directory" + `base_dir` is the project-root prefix for generated output directories. In the shipped template, most output folders are written relative to it, but several required inputs are intentionally left as explicit filesystem paths because they point to external software or reference data. + +Paths in SpaceBlocks fall into three categories: + +- **Project output paths**: typically relative to `base_dir` or kept under the config directory for metadata files. +- **Project input paths**: configuration or pre-computed data used by the pipeline. These can be defined relative to `config/` or as absolute paths. +- **External absolute paths**: installed tools, references, and other files that must be supplied explicitly. + +### Project output paths + +These are the paths that are normally defined relative to `base_dir`. + | Path / File | Key | Condition | Meaning | | --- | --- | --- | --- | -| Path | `spaceranger_processing_outdir` | Head modes | Directory where the head writes heavy intermediates such as Space Ranger outputs or SpatialData Zarr stores. | -| File | `spaceranger` | `mode: visiumhd` | Path to the Space Ranger installation. | -| File | `probe_set` | `mode: visiumhd` | [Probe set CSV](https://www.10xgenomics.com/support/spatial-gene-expression-hd/documentation/steps/probe-sets) needed to run Space Ranger. | -| File | `transcriptome` | `mode: visiumhd` | [Reference transcriptome](https://www.10xgenomics.com/support/software/space-ranger/downloads) needed to run Space Ranger. | | Path | `post_processing_outdir` | **Mandatory** | Output directory root; it will contain the result folders for the run. | | Path | `logdir` | **Mandatory** | Directory for per-rule logs and benchmarks. | -| Path | `geojson_path` | **Optional, recommended** | Directory containing the GeoJSONs that annotate spatial regions for each sample (see the [QuPath tutorial](https://cbib.github.io/SpaceBlocks/qupath-tutorial/)). | +| Path | `spaceranger_processing_outdir` | Head modes | Directory where the head writes heavy intermediates such as Space Ranger outputs or SpatialData Zarr stores. | + +### Project input paths + +These paths contain metadata, configuration or pre-processed results that are ingested by the pipeline. We provide some defaults under the `config/config.yaml.template`. Others are defined as placeholders `/path/to/dir/` and `/path/to/file.ext`, so make sure to configure them if needed. + +| Path / File | Key | Condition | Meaning | +| --- | --- | --- | --- | +| File | `core_samples` | **Mandatory** | Sample metadata sheet. The shipped template keeps this in `config/` as project metadata. | | Path | `contract_dir` | `mode: decoupled` | Directory holding the pre-existing contract h5ads to analyse. | -| File | `per_sample_qc` | **Optional** (per-sample filters) | TSV of sample-specific QC filters. If empty, the same `analysis.*` thresholds apply to every sample (*default*). | -| File | `snakemake_cell_markers` | **Optional** (pre-annotation) | TSV of canonical marker genes drawn on the Leiden diagnostic plots (`leiden_analysis`) to guide manual annotation. | -| File | `gene_exploration.queries` (`config/gene_queries.tsv`) | Exploration Coreblock | Genes / gene sets to score (AUCell) and plot in the exploration block. | -| File | `ingest_ref` | **Optional** (auto-annotation) | Annotated scRNA-seq reference h5ad for `ingest_ref`. | +| Path | `xenium5k.xenium_dir` | `mode: xenium5k` | Directory pattern for each Xenium bundle. | +| Path | `atera.atera_dir` | `mode: atera` | `{sample}` path pattern to each Atera `outs/` bundle. | +| Path | `merscope.merscope_dir` | `mode: merscope` | `{sample}` path pattern to each MERSCOPE region directory. | +| Path | `geojson_path` | **Optional, recommended** | Directory containing the GeoJSONs that annotate spatial regions for each sample (see the [QuPath tutorial](https://cbib.github.io/SpaceBlocks/qupath-tutorial/)). In `decoupled` mode this directory is not searched; region labels must already be in the contract h5ad. | | Path | `precomputed_metadata_dir` | **Optional** (reproducibility) | Directory of precomputed per-sample metadata TSVs. The metadata can contain precomputed clusters and/or external annotations. | +| Path | `spatial_niches.niche_dir` | **Optional** (reproducibility) | Directory for precomputed niche TSVs when `use_precomputed` is enabled. | +| File | `per_sample_qc` | **Optional** (per-sample filters) | TSV of sample-specific QC filters. If empty, the same `analysis.*` thresholds apply to every sample (*default*). | +| File | `snakemake_cell_markers` | **Optional** (pre-annotation) | TSV of canonical marker genes drawn on the Leiden diagnostic plots (`leiden_analysis`) to guide manual annotation. If not set, default markers are used (`config/snakemake_cell_markers.tsv`) | +| File | `gene_exploration.queries` | Exploration Coreblock | Genes / gene sets to score (AUCell) and plot in the exploration block. If not set, test gene queries are used (`config/gene_queries.tsv`). | +| File | `cluster_annotations` | **Optional** | TSV of cluster-to-cell-type labels used during annotation. Template ships with a template of placeholder annotations (`config/cluster_annotations.tsv`). If not set, cell will be labeled `Unannotated` | + +!!! note "GeoJSON filenames" + Region annotation is optional per sample, but a sample-prefixed GeoJSON with an unsupported suffix is treated as an error. Expected suffixes are `_tissue_hires_image.geojson` for Visium HD, `_morphology.geojson` for Xenium and MERSCOPE, and either `_he_background.geojson` or `_morphology.geojson` for Atera. + +### Explicit external inputs -`core_samples.tsv` is the technology-agnostic sample sheet used by the CoreBlocks in every use case. Visium HD additionally uses `visiumhd_samples.csv` for fastq, slide, and capture-area information. +These are not derived from `base_dir` and must be set explicitly to the correct filesystem location. + +| Path / File | Key | Condition | Meaning | +| --- | --- | --- | --- | +| File | `spaceranger` | `mode: visiumhd` | Path to the externally installed Space Ranger executable. | +| File | `probe_set` | `mode: visiumhd` | [Probe set CSV](https://www.10xgenomics.com/support/spatial-gene-expression-hd/documentation/steps/probe-sets) needed to run Space Ranger. | +| File | `transcriptome` | `mode: visiumhd` | [Reference transcriptome](https://www.10xgenomics.com/support/software/space-ranger/downloads) needed to run Space Ranger. | +| File | `ingest_ref` | **Optional** (auto-annotation) | Annotated h5ad scRNA-seq reference for `ingest_ref`. | +| File | `samples` | `mode: visiumhd` | Sample sheet with fastq directories, slide, and capture area per sample. | -For `xenium5k`, `atera`, and `merscope`, per-sample input bundles are located through the corresponding `{sample}` path pattern in `xenium5k.xenium_dir`, `atera.atera_dir`, or `merscope.merscope_dir`; no second head-specific sample sheet is required. +### Sample sheets + +`core_samples.tsv` is the technology-agnostic sample sheet used by the CoreBlocks in every use case. This file must contain a column named `sample`. Every other column is stamped into each sample's `obs`, so it can be used by integration plots and pseudobulk `group_by`, `covariates`, or `paired_by` settings + +Visium HD additionally uses `visiumhd_samples.csv` for fastq, slide, and capture-area information. + +For Xenium, Atera, and MERSCOPE, the sample list comes from `core_samples`, and each input bundle is located with the corresponding `{sample}` path pattern. !!! tip "Color customization for result visualization" You can customize the color scale for any sample metadata in `core_samples.tsv` as additional columns. See [section 3](#3-parameters) and [section 4](#4-color-scale-customization) for details. @@ -59,47 +100,63 @@ For `xenium5k`, `atera`, and `merscope`, per-sample input bundles are located th | `config/core_samples.tsv` | **Mandatory** | The technology-agnostic anchor: one row per sample. The first column is the sample name; any further columns are design metadata (e.g. `patient`, `condition`, `type`) that are stamped into `obs` and can be surfaced downstream. | | `config/visiumhd_samples.csv` | `mode: visiumhd` | The Visium HD head sheet: fastq directories (plus any re-sequencing runs), slide, and area per sample. | -## 3. Parameters +## 3. Common parameters Parameters are settings that live exclusively in `config/config.yaml` and determine the details of the run. | Parameter | Condition | Role | | --- | --- | --- | | `mode` | **Mandatory** | Which headblock builds the contract (see [section 1](#1-choose-a-mode)). | -| `random_seed` | Reproducibility | Seed for stochastic steps (subsampling, sketching). Note that UMAP/Leiden are not fully deterministic across systems (see [section 6](#6-advanced-reproducibility-and-reusability)). | +| `random_seed` | Reproducibility | Seed for stochastic steps, including PCA, UMAP, Leiden, subclustering, neighbours, and sketching. Results may still differ across software stacks or systems (see [section 6](#6-advanced-reproducibility-and-reusability)). | | `use_precomputed_clusters` | **Optional** (reproducibility) | If `true`, reuse Leiden clusters/metadata from `precomputed_metadata_dir` instead of recomputing them. | -| `ingest_ref_label_key` | `ingest_ref` reference set | Column in the `ingest_ref` reference that holds the reference cell-type labels. | +| `ingest_ref_label_key` | Annotated reference | Column in the `ingest_ref` reference that holds the reference cell-type labels. | | `integration.integrate_key` | **Mandatory** | Variable Harmony corrects over during integration (e.g. `sample`). | -| `extra_annotations.columns` | **Optional** | `core_samples.tsv` columns to carry into `obs` and surface in downstream plots (e.g. `[patient, batch]`). | +| `annotation_types` | `[external_annotation]` when external annotation is enabled; otherwise `[tsv_annotation]` | Annotation variants expanded by pseudobulk and neighbourhood analyses. Allowed values are `tsv_annotation`, `external_annotation`, and `ingest_annotation`. Add `ingest_annotation` explicitly if those analyses should use transferred labels. | +| `extra_annotations.columns` | **Optional** | `core_samples.tsv` columns to be used in downstream plots (e.g. `[patient, batch]`). All sample-sheet columns are carried into `obs` and can be used by pseudobulk models whether or not they are plotted. | +| `analysis.run_leiden_analysis` | **Optional**, default to `true` | Controls the per-resolution diagnostic plot rule. Leiden clusters are still computed by `preprocess_umap`. | +| `analysis.run_pseudobulk_de` | **Optional** `false` in runtime/schema; `true` in the template | Enables DESeq2 differential expression. Pseudobulk aggregation may still run when DE is disabled. | +| `analysis.pseudobulk.analyses` | Pseudobulk | Named aggregation/model specifications containing the comparison variables, explicit contrasts, exclusions, optional pairing/covariates, one-vs-rest biomarkers, and optional LRT. | +| `analysis.umap_point_size` | **Optional** | Marker size for ordinary UMAP embedding plots (default: `2`). Highlight and split UMAPs remain at least size `10`. | +| `analysis.spatial_point_size` | **Optional** | Marker size for spatial maps (default: `20`). This is independent of the UMAP marker size. Coordinate-only spatial-niche maps are scaled proportionally. | ## 4. Color scale customization In SpaceBlocks, color scales are fully customizable and consistent across analysis plots. !!! warning "Undefined variable levels are colored in grey" - When customizing color palettes, levels not explicitly listed fall back to grey, so a value that renders grey usually means a missing key. If you wish to customize visualization, list all levels in a variable and assign a color for each of them. + When customizing color palettes, levels not explicitly listed fall back to grey `#cccccc`, so a value that renders grey usually means a missing key. If you wish to customize visualization, list all levels in a variable and assign a color for each of them. To set a given color scale, you just need to specify the HEX color code for each level in `config/config.yaml`. There are three palette families, each keyed by *column → level → hex*: ```yaml -# Sample-metadata palettes — one block per column listed in extra_annotations.columns +# core_samples.tsv columns: column -> level -> colour sample_colors: patient: "Patient 1": "#000000" "Patient 2": "#E69F00" + condition: + Control: "#0072B2" + Treatment: "#D55E00" batch: "Batch 1": "#8E44AD" -# Cell-type palettes — one block per annotation column (tsv / external / …) +# annotation obs columns: column -> level -> colour annotation_colors: cell_type_tsv: - "Tcells": "#1f77b4" - "Fibroblasts": "#2ca02c" - cell_type_external: # declare explicitly; not inherited from cell_type_tsv - "Tcells": "#1f77b4" + Tcells: "#1f77b4" + Fibroblasts: "#2ca02c" + cell_type_external: # not inherited from cell_type_tsv + Tcells: "#1f77b4" + cell_type_ingest: + Tcells: "#1f77b4" + spatial_niche: # optional override of the deterministic niche palette + "0": "#4C78A8" -# Region palette — lives under analysis, keyed by the region_levels analysis: + # Preferred display order. For a single region group in pseudobulk, + # this is also the fallback when condition_order is absent. + region_levels: ["Tumor area", "Healthy area"] + # Region level -> colour region_colors: "Tumor area": "#46337EFF" "Healthy area": "#FDE725FF" @@ -107,27 +164,211 @@ analysis: Spatial niches are coloured automatically from a deterministic palette unless you add a `spatial_niche` block under `annotation_colors`. -## 5. Key sections +`region_levels` controls ordering, but it does not filter observations. For pseudobulk analyses, you may use `exclude_levels` to remove values. -The rest of the configuration lives in nested blocks. Files and single parameters are covered in [section 2](#2-paths-files-and-sample-sheets) and [section 3](#3-parameters); colour palettes in [section 4](#4-color-scale-customization). +## 5. Key configuration sections + +The rest of the configuration lives in comment-separated config chunks. Files and single parameters are covered in [section 2](#2-paths-files-and-sample-sheets) and [section 3](#3-parameters); colour palettes in [section 4](#4-color-scale-customization). | Section | Condition | What it configures | | --- | --- | --- | -| `xenium5k` | `mode: xenium5k` | Xenium head settings: `xenium_dir`, `zarr_dir`, pyramid levels, and `pixel_size_um`. | +| `visiumhd` | `mode: visiumhd` | Visium HD head settings: `spaceranger`, `probe_set`, and `transcriptome`. | +| `xenium5k` | `mode: xenium5k` | Xenium head settings: `xenium_dir`, pyramid levels, and `pixel_size_um`. | | `atera` | `mode: atera` | Atera head settings: `atera_dir`, Zarr and pyramid options, plus optional registered H&E image, alignment, and keypoint patterns. | | `merscope` | `mode: merscope` | MERSCOPE head settings: `merscope_dir`, selected z-plane, embedded-image resolution, and image channels. | -| `contract` | **Mandatory** | Semantic keys of the hand-off object: `sample_key`, `spatial_key`, `require_region`, `require_raw_counts`, `mito_prefix`. | -| `analysis` | **Mandatory** | The bulk of the run: QC filters (`min_counts` / `min_genes` / `min_cells` / `max_counts` / `max_pct_mt`), the Leiden `resolution_scan_*`, the pseudobulk `analysis_levels` + thresholds, the `region_levels`, and the `run_*` toggles. Per-sample QC overrides come from `per_sample_qc`. | -| `resources` | **Mandatory** | Per-rule `mem_mb` / `runtime` / `threads` (with a `default`). Memory scales with the retry attempt, so an OOM-killed job is resubmitted with more RAM. | -| `external_annotation` | *optional* | Overlay labels from an external tool: `enabled`, `column`, `keep_unannotated` (see [section 6](#6-advanced-reproducibility-and-reusability)). | -| `qc_sweep` | *optional* | Candidate-threshold QC diagnostics (never filters). | -| `spatial_niches` | *optional* | BANKSY niche detection across concatenated samples. | -| `subcompartments` | *optional* | Named cell-type subsets to re-cluster in `subcluster`. | +| `contract` | **Optional**, defaults are implemented | Contract keys such as `sample_key`, `spatial_key`, `require_region`, `require_raw_counts`, and `mito_prefix`. | +| `qc_sweep` | **Optional** | Candidate-threshold QC diagnostics (never filters). | +| `external_annotation` | **Optional** | Overlay labels from an external tool: `enabled`, `column`, `keep_unannotated` (see [section 6](#6-advanced-reproducibility-and-reusability)). | +| `analysis` | **Required** | The bulk of the run: QC filters (`min_counts` / `min_genes` / `min_cells` / `max_counts` / `max_pct_mt`), the Leiden `resolution_scan_*`, named pseudobulk analyses + thresholds, and the `run_*` toggles. Per-sample QC overrides come from `per_sample_qc`. | +| `spatial_niches` | **Optional** | BANKSY niche detection across concatenated samples. | +| `subcompartments` | **Optional** | Named cell-type subsets to re-cluster in `subcluster`. | | `gene_exploration` | Exploration CoreBlock | Genes / gene sets to score (AUCell) and plot in the exploration block, plus its `niche_column` and rank fraction. | +| `annotation_colors` | Color customization | Annotations for samples, conditions, regions and other variables. | +| `resources` | **Required** | Per-rule `mem_mb` / `runtime` / `threads` (with a `default`). Memory scales with the retry attempt, so an OOM-killed job is resubmitted with more RAM. | !!! note A few one-key blocks are documented elsewhere for readability: `integration` (`integrate_key`) and `extra_annotations` (`columns`) are parameters in [section 3](#3-parameters); `cluster_annotations` is a file in [section 2](#2-paths-files-and-sample-sheets); and the colour blocks (`sample_colors`, `annotation_colors`, `analysis.region_colors`) are in [section 4](#4-color-scale-customization). +### Spatial niches + +`spatial_niches.enabled: true` runs BANKSY jointly across the preprocessed samples. The physical-neighbour and clustering-neighbour parameters are independent: + +| Key | Default | Meaning | +| --- | ---: | --- | +| `lambda` | `0.8` | Weight of spatial-neighbour features in the BANKSY matrix. | +| `num_neighbours` | `18` | Physical neighbours used to construct BANKSY features. | +| `max_m` | `1` | Highest spatial-neighbourhood harmonic used by BANKSY. | +| `nbr_weight_decay` | `scaled_gaussian` | BANKSY neighbour-weight decay. | +| `use_hvg` | `false` | Restrict BANKSY to highly variable genes. The shipped configs set this to `true`. | +| `n_top_genes` | `analysis.n_top_genes`, otherwise `2000` | **Strongly recommended** due to heavy RAM usage. Number of HVGs when `use_hvg` is enabled. | +| `cluster_n_neighbours` | `50` | Neighbours in the separate Harmony-corrected BANKSY PCA graph used for clustering. | +| `niche_resolution` | `0.1` | Leiden resolution controlling niche granularity. | +| `n_iterations` | `-1` | Leiden iterations; `-1` runs until convergence and may take longer on very large datasets. | +| `compute_umap` | `false` | Whether to compute the optional integrated BANKSY UMAP. | +| `refine` | `false` | Apply spatial majority-vote refinement to computed or reused labels. | + +The BANKSY matrix is dense and has approximately `(2 + max_m) × n_genes` features. For large datasets, use `use_hvg: true` and tune `n_top_genes` to control memory. + +With `use_precomputed: true`, SpaceBlocks skips BANKSY/Leiden and reloads a complete `niche_.tsv` for every sample. Missing files or cells will prevent the rule from running. + +### Pseudobulk experimental designs + +Pseudobulk analyses are configured under `analysis.pseudobulk.analyses`. Each analysis has a stable `name`, one `aggregation`, one or more `group_by` columns, optional `covariates` and `paired_by`, exclusions, explicit pairwise contrasts, and optional one-vs-rest and LRT tests. + +| `aggregation` | One pseudobulk observation per | Separate result set per | +| --- | --- | --- | +| `all_cells` | sample | — | +| `by_celltype` | sample and cell type | cell type | +| `by_region` | sample and region | — | +| `by_celltype_region` | sample, cell type and region | cell type | + +`cell_type` is a portable alias: SpaceBlocks resolves it to the cell-type column for +the active `annotation_types` entry, so manual TSV, external, and ingest-transferred +cell-type annotations can use the same pseudobulk configuration. + +`condition_order` optionally sets the displayed order of comparison-group labels. With multiple `group_by` columns, use the combined labels written to pseudobulk metadata, for example `condition=Control | treatment=Drug`. Unlisted observed levels are appended. + +#### Independent phenotype comparison + +Add the experimental variables to `core_samples.tsv`: + +```tsv +sample phenotype batch +Cancer_P1 Cancer Batch1 +Cancer_P2 Cancer Batch2 +Cancer_P3 Cancer Batch3 +Normal_P4 Normal Batch1 +Normal_P5 Normal Batch2 +Normal_P6 Normal Batch3 +``` + +Then compare phenotypes for each cell type while adjusting for batch: + +```yaml +analysis: + run_pseudobulk_de: true + min_replicates: 3 + pseudobulk: + analyses: + - name: phenotype_by_celltype + aggregation: by_celltype + group_by: [phenotype] + covariates: [batch] + exclude_levels: + cell_type: [Unannotated] + contrasts: + - name: cancer_vs_normal + numerator: {phenotype: Cancer} + denominator: {phenotype: Normal} + lrt: + enabled: false +``` + +The numerator determines the positive log2-fold-change direction. Levels not named +in a pairwise contrast can remain in the fitted model and help dispersion estimation. +Use `exclude_levels` only for values that should be removed from the analysis entirely. +The exclusions are also applied to one-vs-rest tests and the LRT. + +Here P1–P6 are independent samples, so `paired_by` is omitted. Use covariates only when the design supports estimating them without confounding. Use `aggregation: all_cells` for one whole-sample result rather than a result per cell type. + +#### Phenotype and treatment + +Multiple `group_by` columns form a combined categorical group. In the example below, +Cancer and Normal phenotype levels are combined with Drug and Vehicle treatment +levels. This supports clear comparisons between observed combinations without exposing +interaction formulas: + +```yaml +- name: treatment_by_celltype + aggregation: by_celltype + group_by: [phenotype, treatment] + exclude_levels: + cell_type: [Unannotated] + contrasts: + - name: drug_vs_vehicle_in_cancer + numerator: {phenotype: Cancer, treatment: Drug} + denominator: {phenotype: Cancer, treatment: Vehicle} +``` + +This tests one combination against another. It is **not** a formal interaction or difference-of-differences test. + +#### Paired regions or matched samples + +Use `paired_by: sample` when the same sample contributes both levels of a contrast, +as in tumour and healthy regions from the same section: + +```yaml +- name: regions_by_celltype + aggregation: by_celltype_region + group_by: [region_annotation] + paired_by: sample + exclude_levels: + region_annotation: [Unlabeled, Bubble] + cell_type: [Unannotated] + contrasts: + - name: tumor_vs_healthy + numerator: {region_annotation: Tumor area} + denominator: {region_annotation: Healthy area} + one_vs_rest: + enabled: true + lrt: + enabled: true +``` + +For a paired Wald contrast, SpaceBlocks retains only matched units containing both +requested levels and interprets `min_replicates` as the minimum number of complete pairs. + +For separate samples matched by patient, add the shared patient identifier to `core_samples.tsv` and set `paired_by: patient`, as in this example: + +```tsv +sample phenotype patient +Cancer_P1 Cancer P1 +Normal_P1 Normal P1 +Cancer_P2 Cancer P2 +Normal_P2 Normal P2 +Cancer_P3 Cancer P3 +Normal_P3 Normal P3 +``` + +```yaml +- name: matched_phenotype_by_celltype + aggregation: by_celltype + group_by: [phenotype] + paired_by: patient + contrasts: + - name: cancer_vs_normal + numerator: {phenotype: Cancer} + denominator: {phenotype: Normal} +``` + +Thus, use `paired_by: sample` for multiple region levels from the same spatial sample, +`paired_by: patient` for separate samples matched by patient, and omit `paired_by` for +independent Cancer-versus-Normal samples. + +#### Repeated observations without pairing + +It is generally a good idea to used paired contrasts when observations can be considered repeated measurements from the same experimental unit. Thus, if no `paired_by` is configured and a sample contributes pseudobulks to more than one comparison level, DE for that pooled or cell-type subgroup is aborted by default because those observations are not independent. The script records the reason in that subgroup's `ERROR.txt`, so it does not silently omit the repeated samples. + +For an explicitly exploratory analysis, set `allow_repeated_unpaired: true` and leave `paired_by` unset. The model then treats the repeated pseudobulks as independent, ignores within-sample correlation, and may underestimate standard errors. When repeated observations are detected, the analysis writes `WARNING_repeated_unpaired.txt`. `paired_by` and `allow_repeated_unpaired: true` cannot be combined. + +#### Multiple level designs + +Multiple level designs unlock the possibility of (1) running likelihood ratio tests (LRT) for holistic comparisons across experimental levels and (2) one-vs-rest Wald tests to identify potential gene markers for each of the levels. SpaceBlocks provides native implementation of these statistical tests, explained below. + +When `lrt.enabled` is true and at least three eligible comparison-group levels exist, SpaceBlocks runs an omnibus LRT test (using DESeq2 R implementation) followed by gene clustering using the DEGpatterns R package. This is carried out only when sufficient level and observations remain after exclusions, replicate filtering, and pairing. This is performed following the tutorial from [Harvard Chan Bioinformatics Core workshop](https://hbctraining.github.io/DGE_workshop/lessons/08_DGE_LRT.html). +With `paired_by`, the full model includes the paired unit term and the reduced model removes +only the comparison group. With fewer than three eligible levels, LRT and DEGpatterns are +skipped for that subgroup or cell type, and the reason is written to the pseudobulk rule log and +`LRT/SKIPPED_insufficient.txt`. When the LRT does run and finds enough significant genes, DEGpatterns groups their expression profiles. Note that no order or trend direction is imposed by the configuration. Results are written under `pseudobulk/.../LRT/`. + +When `one_vs_rest.enabled` is true, SpaceBlocks also tests each eligible level against +all other eligible levels combined. It uses the same exclusions explained for the LRT test. +Results are written under `pseudobulk/.../one_vs_rest/`, including a TSV, volcano plot, and heatmaps for +each level. `unique_markers.tsv` contains genes significantly upregulated for one level but +not for any other one-vs-rest comparison; the accompanying barplot and heatmap summarize +these level-specific biomarkers. This is independent of the omnibus LRT and configured +pairwise contrasts. + ## 6. Advanced: Reproducibility and reusability Even though we have ensured the highest reproducibility standards when creating SpaceBlocks, some steps are never 100% reproducible between systems (e.g. UMAP calculation, Leiden clustering). @@ -137,42 +378,61 @@ We therefore provide features for minimal file sharing/storage that tighten the SpaceBlocks allows you to input: - **Externally assembled h5ad AnnData objects** — run `mode: decoupled` and point `contract_dir` at the directory of pre-built contract h5ads. The heads are skipped; the core validates and analyses them directly. -- **Pre-computed clusters / annotations** — set `use_precomputed_clusters: true` and `precomputed_metadata_dir` to reuse Leiden clusters and metadata; for niches, set `spatial_niches.use_precomputed: true` with `spatial_niches.niche_dir`. -- **Externally annotated data** — set `external_annotation.enabled: true` with `external_annotation.column`, and choose whether to keep or discard unannotated barcodes downstream via `external_annotation.keep_unannotated` (see [section 5](#5-key-sections)). +- **Pre-computed clusters / annotations** — set `use_precomputed_clusters: true` and provide `metadata_.tsv` files in `precomputed_metadata_dir`. +- **Precomputed niches:** set `spatial_niches.enabled: true`, `spatial_niches.use_precomputed: true`, and optionally `spatial_niches.niche_dir`. +- **External cell labels:** enable `external_annotation` as described below. + +### External annotation + +```yaml +external_annotation: + enabled: true + column: manual_celltype + keep_unannotated: true +``` -These files are generated during the run, and can be shared with minimum effort to reproduce downstream results from raw data. +For every sample, provide `column` in `precomputed_metadata_dir/metadata_.tsv`. **In `decoupled` mode only**, the source column may instead be stored directly in each contract h5ad's `obs` when no external metadata directory is configured. SpaceBlocks normalizes the source to `cell_type_external` downstream. -## 7. Example use case configurations +- With `keep_unannotated: true`, normal QC is applied and unmatched cells remain as `Unannotated`. A mixed real/`Unannotated` column remains active; a placeholder-only column is omitted from annotation plots. +- With `keep_unannotated: false`, preprocessing retains only cells with real external labels and skips the configured cell-QC thresholds. Genes expressed in no retained cell are still removed. -The commented `config/config.yaml` is the full template. The mode-specific keys that differ are: +When external annotation is enabled, it becomes the primary annotation used by reports, integration summaries, and gene exploration. If `ingest_ref` is also configured, both `cell_type_external` and `cell_type_ingest` are retained. Pseudobulk and neighbourhood analyses use only the variants listed in `annotation_types`; they do not automatically add ingest labels. + +`annotate_cells` can operate without a manual cluster mapping and will set `cell_type_tsv` to `Unannotated`. However, the current default-target assembly still uses a readable, non-empty `cluster_annotations` file as the gate for annotation-dependent downstream targets. Keep such a file configured when running `rule all`, even for an external-primary analysis. + + +## 7. Mode-specific examples + +The commented `config/config.yaml.template` is the full template. Here, these snippets show only the keys that distinguish each mode, and you may combine them with the common settings from the template. + +**Visium HD** -**Visium HD** (`mode: visiumhd`) ```yaml -mode: "visiumhd" -samples: "config/visiumhd_samples.csv" # fastq dirs / slide / area per sample -spaceranger: "/path/to/spaceranger" -probe_set: "/path/to/probe_set.csv" -transcriptome: "/path/to/refdata-gex" -spaceranger_processing_outdir: "/path/to/sr_out" +mode: visiumhd +samples: config/visiumhd_samples.csv +spaceranger: /path/to/spaceranger +probe_set: /path/to/probe_set.csv +transcriptome: /path/to/refdata-gex +spaceranger_processing_outdir: "{base_dir}/spaceranger_results" ``` -**Xenium 5K** (`mode: xenium5k`) +**Xenium 5K** + ```yaml -mode: "xenium5k" +mode: xenium5k xenium5k: - xenium_dir: "/path/to/xenium/{sample}" # {sample} pattern to each bundle - zarr_dir: "" # "" → spaceranger_processing_outdir + xenium_dir: "/path/to/xenium/{sample}" qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 ``` -**Atera** (`mode: atera`, alpha) +**Atera (alpha support)** + ```yaml -mode: "atera" +mode: atera atera: atera_dir: "/path/to/atera/{sample}/outs" - zarr_dir: "" qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 @@ -181,9 +441,10 @@ atera: he_keypoints: "" # optional {sample} pattern ``` -**MERSCOPE** (`mode: merscope`) +**MERSCOPE** + ```yaml -mode: "merscope" +mode: merscope merscope: merscope_dir: "/path/to/merscope/{sample}" z_index: 3 @@ -191,25 +452,27 @@ merscope: channels: [DAPI, PolyT, Cellbound1, Cellbound2, Cellbound3] ``` -**Externally prepared data** (`mode: decoupled`) +**Pre-built contracts** + ```yaml -mode: "decoupled" -contract_dir: "/path/to/contract_h5ads" # one .h5ad file per sample +mode: decoupled +contract_dir: /path/to/contract_h5ads # one .h5ad file per sample ``` -All modes additionally set `core_samples`, `post_processing_outdir`, `logdir`, and the other required common keys in `config/config.yaml`; **`geojson_path` is optional but strongly recommended**. +## 8. Minimal decoupled example -## 8. Minimal example +The following is a schema-valid structural skeleton. Replace the paths and resource values for your environment; each sample listed in `core_samples` must have `/.h5ad`. ```yaml -mode: "visiumhd" -samples: "config/visiumhd_samples.csv" +mode: "decoupled" core_samples: "config/core_samples.tsv" +contract_dir: "/path/to/contract_h5ads" geojson_path: "/path/to/geojson" -post_processing_outdir: "/path/to/results" -# ... see config/config.yaml for the full, commented template. +precomputed_metadata_dir: "/path/to/precomputed_metadata/" +post_processing_outdir: "{base_dir}/results" +# ... see config/config.yaml.template for the full, commented template. ``` -Start from the commented `config/config.yaml` shipped with the workflow and adjust the paths and sample sheets to your data. +For a normal run, start by copying `config/config.yaml.template` to `config/config.yaml`, then adjust the paths and sample sheets to your data. You may next read the [get started](https://cbib.github.io/SpaceBlocks/getting-started/) documentation and the [public data end-to-end example runs](https://cbib.github.io/SpaceBlocks/demos/). diff --git a/config/config.yaml b/config/config.yaml index 6bc2735..048809f 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,43 +1,21 @@ -# SpaceBlocks configuration. Full explanations: config/README.md (or the online docs https://cbib.github.io/SpaceBlocks/configuration/). -# Every key is validated against workflow/schemas/config.schema.yaml before the run starts. - -# ─── Run mode — which headblock builds the contract h5ad ───────────────────── +# Canonical Catalog/CI fixture (mode: decoupled). Paths are relative to the +# repository root; users should copy config/config.yaml.template over this file. mode: decoupled - -# ─── input/output directories ──────────────────────────────────────────────── -post_processing_outdir: /scratch/CBiB/soterino/smk_visiumhd_results -logdir: /scratch/CBiB/soterino/smk_logs -spaceranger_processing_outdir: /scratch/CBiB/soterino/smk_spaceranger_results -# dir with region annotation GeoJSONs (optional, recommended) -geojson_path: /scratch/CBiB/soterino/geojson -# only when using "mode: decoupled" — dir of pre-built contract h5ads -contract_dir: '' - -# ─── Reproducibility & reuse (docs → Configuration) ──────────────────────── +base_dir: .test/test_output +post_processing_outdir: "{base_dir}/output" +logdir: "{base_dir}/logs" +spaceranger_processing_outdir: "{base_dir}/sr" +geojson_path: "{base_dir}/geojson" +contract_dir: ".test/data" random_seed: 0 -precomputed_metadata_dir: /scratch/CBiB/soterino/metadata_visiumhd/ -# reuse clusters from precomputed_metadata_dir -use_precomputed_clusters: true - -# ─── Sample sheets ─────────────────────────────────────────────────────────── -core_samples: config/core_samples.tsv -# only when using "mode: visiumhd" — fastq/slide/area per sample -samples: config/visiumhd_samples.csv - -# ─── Visium HD head (mode: visiumhd) ─────────────────────────────────────── -spaceranger: /home/soterinosogo/spaceranger-4.0.1/spaceranger # external install; path to the executable -probe_set: /scratch/CBiB/soterino/Visium_Human_Transcriptome_Probe_Set_v2.1.0_GRCh38-2024-A.csv -transcriptome: /mnt/cbib/bank/cellranger/refdata-gex-GRCh38-2024-A - -# ─── Xenium 5K head (mode: xenium5k) ─────────────────────────────────────── +use_precomputed_clusters: false +precomputed_metadata_dir: '' +core_samples: .test/config/core_samples.tsv xenium5k: - xenium_dir: /scratch/CBiB/soterino/xenium/{sample} - zarr_dir: '' + xenium_dir: "{base_dir}/xenium/{sample}" qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 - -# ─── Contract — Head → Core hand-off ───────────────────────────────────────── contract: sample_key: sample spatial_key: spatial @@ -46,244 +24,153 @@ contract: mito_prefix: - MT- - mt- - -# ─── QC & preprocessing ────────────────────────────────────────────────────── -# qc thresholds to screen in qc_sweep_all -qc_sweep: - # use ingest_ref - ingest_enabled: true - ingest_key: cell_type_ingest - cell_id_key: cell_id - dpi: 300 - # thresholds to screen - thresholds: - min_genes: - - 50 - - 100 - - 200 - min_counts: - - 1 - - 10 - - 50 - max_counts: - - 3000 - - 5000 - max_pct_mt: - - 10 - - 15 - - 20 - -# optional: per-sample QC thresholds TSV (example at config/per_sample_qc.tsv) per_sample_qc: '' -# default: global thresholds analysis: - # qc thresholds plot_dpi: 300 + umap_point_size: 2 + spatial_point_size: 20 min_counts: 1 min_cells: 3 min_genes: 100 - # umap n_neighbors: 10 n_pcs: 30 - # n marker genes; n DE genes in pseudobulk heatmaps de_n_genes: 25 - # leiden resolution_scan_min: 0.6 resolution_scan_max: 0.8 resolution_scan_step: 0.2 - min_cells_per_type: 15 - # integrate sketch_fraction: 0.25 - # pseudobulk run_pseudobulk_de: true run_leiden_analysis: true - analysis_levels: # by_niche_region also available; off by default (too many groups) - - by_region - - by_celltype_region min_cells_per_pseudobulk: 10 min_replicates: 3 min_counts_per_pseudobulk: 1000 padj_threshold: 0.05 lfc_threshold: 0.25 - # regions to consider in pseudobulk (omitted names get removed from pseudobulk DE) + pseudobulk: + analyses: + - name: regions_all_cells + aggregation: by_region + group_by: + - region_annotation + paired_by: sample + exclude_levels: + region_annotation: + - Unlabeled + contrasts: + - name: tumor_vs_healthy + numerator: + region_annotation: Tumor area + denominator: + region_annotation: Healthy area + lrt: + enabled: true + - name: regions_by_celltype + aggregation: by_celltype_region + group_by: + - region_annotation + paired_by: sample + exclude_levels: + region_annotation: + - Unlabeled + cell_type: + - Unannotated + contrasts: + - name: tumor_vs_healthy + numerator: + region_annotation: Tumor area + denominator: + region_annotation: Healthy area + lrt: + enabled: true region_levels: - Healthy area - - Healthy over tumor - - Dysplastic area - - AK area - - AK over tumor - - In situ area + - Premalignant area - Tumor area - - Invasive area - # color palette: keys must match region_levels above region_colors: Healthy area: '#FDE725FF' - Healthy over tumor: '#9FDA3AFF' - Dysplastic area: '#4AC16DFF' - AK area: '#1FA187FF' - AK over tumor: '#277F8EFF' - In situ area: '#365C8DFF' + Premalignant area: '#1FA187FF' Tumor area: '#46337EFF' - Invasive area: '#440154FF' - -# ─── Annotation ────────────────────────────────────────────────────────────── -# (optional, recommended) predefined cell type markers, screened during leiden to ease annotation -snakemake_cell_markers: config/snakemake_cell_markers.tsv -# (default) cluster → cell-type TSV (fill template from generate_annotation_template) -cluster_annotations: config/cluster_annotations.tsv -# (optional) use external tsv with annotations +qc_sweep: + ingest_enabled: false + ingest_key: cell_type_ingest + cell_id_key: cell_id + dpi: 300 + thresholds: + min_genes: + - 50 + - 100 + - 200 + min_counts: + - 1 + - 10 + - 50 + max_counts: + - 3000 + - 5000 + max_pct_mt: + - 10 + - 15 + - 20 +cluster_annotations: .test/config/cluster_annotations.tsv +snakemake_cell_markers: .test/config/snakemake_cell_markers.tsv external_annotation: enabled: false column: manual_celltype keep_unannotated: true -ingest_ref: /scratch/CBiB/soterino/ingest_ref_data/bcc_and_normal_Garnier_PNAS_2024.h5ad # optional annotated scRNA-seq reference (ingest_ref) -ingest_ref_label_key: 04_celltypes +ingest_ref: '' +ingest_ref_label_key: cell_type_annotation_column integration: integrate_key: sample -# (optional) include annotations for downstream plots from core_samples.tsv extra_annotations: columns: - patient - batch - -# ─── Spatial niches (optional) ───────────────────────────────────────────── spatial_niches: enabled: true - # use niche_dir precomputed .tsv - use_precomputed: true - niche_dir: /scratch/CBiB/soterino/niches_visiumhd/ + use_precomputed: false + niche_dir: '' lambda: 0.8 num_neighbours: 18 - cluster_n_neighbours: 15 - n_iterations: 2 + cluster_n_neighbours: 50 + n_iterations: -1 max_m: 1 nbr_weight_decay: scaled_gaussian - # (recommended) use only HVGs for niche clustering use_hvg: true n_top_genes: 2000 - niche_resolution: 0.3 - # (optional) BANKSY refine niches + niche_resolution: 0.1 refine: false refine_num_neigh: 6 refine_iterations: 1 refine_auto: false compute_umap: false - -# ─── Subcompartments (optional) ──────────────────────────────────────────── subcompartments: - # subcompartment name - Fibroblasts: - # exact-string match of cell type names to pool together - strings: - - Fibroblasts - - Fibroblasts_Macro - - Fibroblasts_Endothelial - - Fibroblasts_Keratinocytes - - Fibroblasts_Plasma - # resolution range to screen in leiden clustering - resolution_min: 0.6 - resolution_max: 1.2 - resolution_step: 0.2 Keratinocytes: strings: - Keratinocytes - - Keratinocytes_Mal - - Keratinocytes_Basal - - Keratinocytes_Suprabasal - - Fibroblasts_Keratinocytes resolution_min: 0.6 - resolution_max: 1.2 + resolution_max: 0.8 resolution_step: 0.2 - MonoMacro_Tcells: - strings: - - Mono_Macro - - Macro_Tcells - - Macro_Plasma - - Tcells - - Fibroblasts_Macro - resolution_min: 0.6 - resolution_max: 1.2 - resolution_step: 0.2 - Plasma_Bcells: - strings: - - Plasma_cells - - Macro_Plasma - - Fibroblasts_Plasma - resolution_min: 0.6 - resolution_max: 1.2 - resolution_step: 0.2 - -# ─── Gene / signature exploration ──────────────────────────────────────────── gene_exploration: - # tsv where col name is gene set name, values are genes (one or more) - queries: config/gene_queries.tsv + queries: .test/config/gene_queries.tsv aucell_max_rank_fraction: 0.05 niche_column: spatial_niche dpi: 300 - -# ─── Colour palettes (docs → Configuration) ──────────────────────────────── -# region color palettes is found under analysis: region_colors sample_colors: - # extra column in core_samples.tsv patient: - # levels and HEX code; unfound levels fall back to grey Patient 1: '#000000' Patient 2: '#E69F00' - Patient 3: '#56B4E9' - Patient 4: '#009E73' - Patient 5: '#F0E442' - Patient 6: '#CC79A7' - Patient 7: '#0072B2' - Patient 8: '#D55E00' batch: Batch 1: '#8E44AD' - Batch 2: '#C0392B' - Batch 3: '#7F7F7F' annotation_colors: - # levels in cluster_annotations.tsv cell_type_tsv: Keratinocytes: '#d62728' - Keratinocytes_Basal: '#8c564b' - Keratinocytes_Suprabasal: '#a0522d' - Keratinocytes_Mal: '#000000' Tcells: '#1f77b4' - Plasma_cells: '#9467bd' - Mono_Macro: '#08519c' - Macro_Tcells: '#756bb1' - Macro_Plasma: '#9e9ac8' Fibroblasts: '#2ca02c' - Fibroblasts_Macro: '#bcbd22' - Fibroblasts_Endothelial: '#17becf' - Fibroblasts_Keratinocytes: '#ffbb78' - Fibroblasts_Plasma: '#fdae6b' - Endothelial: '#98df8a' - Adipocytes: '#dbdb8d' - Gland_cells: '#ff7f0e' - Smooth_muscle: '#d9d9d9' - Skeletal_muscle: '#b9b9c9' - # levels when external_annotation: enabled: true cell_type_external: Keratinocytes: '#d62728' - Keratinocytes_Basal: '#8c564b' - Keratinocytes_Suprabasal: '#a0522d' - Keratinocytes_Mal: '#000000' Tcells: '#1f77b4' - Plasma_cells: '#9467bd' - Mono_Macro: '#08519c' - Macro_Tcells: '#756bb1' - Macro_Plasma: '#9e9ac8' Fibroblasts: '#2ca02c' - Fibroblasts_Macro: '#bcbd22' - Fibroblasts_Endothelial: '#17becf' - Fibroblasts_Keratinocytes: '#ffbb78' - Fibroblasts_Plasma: '#fdae6b' - Endothelial: '#98df8a' - Adipocytes: '#dbdb8d' - Gland_cells: '#ff7f0e' - Smooth_muscle: '#d9d9d9' - Skeletal_muscle: '#b9b9c9' -# ─── Per-rule resources — mem_mb scales with the retry attempt ─────────────── resources: spaceranger_count_vhd: mem_mb: 65536 @@ -306,15 +193,19 @@ resources: runtime: 60 threads: 2 prepare_input_x5k: - mem_mb: 49152 + mem_mb: 51200 runtime: 120 threads: 4 validate_input: mem_mb: 32768 runtime: 30 threads: 2 + process_geojson: + mem_mb: 4096 + runtime: 30 + threads: 2 qc_sweep: - mem_mb: 49152 + mem_mb: 51200 runtime: 180 threads: 4 preprocess_umap: @@ -330,11 +221,11 @@ resources: runtime: 120 threads: 4 pseudobulk_aggregate: - mem_mb: 124768 + mem_mb: 24576 runtime: 60 threads: 4 pseudobulk_de: - mem_mb: 24384 + mem_mb: 24576 runtime: 120 threads: 4 neighbourhood_analysis: @@ -350,11 +241,11 @@ resources: runtime: 360 threads: 8 spatial_niches: - mem_mb: 124768 + mem_mb: 131072 runtime: 720 threads: 16 subcluster: - mem_mb: 120768 + mem_mb: 65536 runtime: 1440 threads: 16 sample_report: @@ -366,7 +257,7 @@ resources: runtime: 240 threads: 4 explore_genes_sample: - mem_mb: 16192 + mem_mb: 16384 runtime: 360 threads: 4 default: diff --git a/config/config.yaml.template b/config/config.yaml.template new file mode 100644 index 0000000..896d908 --- /dev/null +++ b/config/config.yaml.template @@ -0,0 +1,376 @@ +# SpaceBlocks configuration. Full explanations: config/README.md (or the online docs https://cbib.github.io/SpaceBlocks/configuration/). +# Every key is validated against workflow/schemas/config.schema.yaml before the run starts. + +# ─── Run mode — which headblock builds the contract h5ad ───────────────────── +mode: decoupled + +# ─── input/output directories ──────────────────────────────────────────────── +## BASE DIRECTORY ############################################################# +## +## You may modify 'base_dir' to point to your project results directory. +## Paths relative to base_dir can be left as is or be overridden with absolute paths if desired. + +base_dir: /path/to/SpaceBlocks/results + +## Paths that are not relative to base_dir and must be set explicitly below: +# spaceranger, probe_set, transcriptome, ingest_ref +############################################################################### + +post_processing_outdir: "{base_dir}/output" +logdir: "{base_dir}/logs" +spaceranger_processing_outdir: "{base_dir}/spaceranger_results" +# dir with region annotation GeoJSONs (optional, recommended) +geojson_path: "/path/to/geojson" +# only when using "mode: decoupled" — dir of pre-built contract h5ads +contract_dir: '/path/to/preformatted_contract_h5ads' + +# ─── Reproducibility & reuse (docs → Configuration) ──────────────────────── +random_seed: 0 +# only when cell type annotations are readily available +# expected as metadata_{sample}.tsv +precomputed_metadata_dir: '/path/to/precomputed_metadata' +# if you wished to reuse clusters from a previous run +use_precomputed_clusters: false + +# ─── Sample sheets ─────────────────────────────────────────────────────────── +core_samples: config/core_samples.tsv +# only when using "mode: visiumhd" — fastq/slide/area per sample +samples: config/visiumhd_samples.csv + +# ─── Visium HD head (mode: visiumhd) ─────────────────────────────────────── +# (mandatory for visiumhd) path to the SpaceRanger executable +spaceranger: /path/to/spaceranger-4.0.1/spaceranger +# path to the probe set and the reference SpaceRanger transcriptome +probe_set: /path/to/Visium_Human_Transcriptome_Probe_Set_v2.1.0_GRCh38-2024-A.csv +transcriptome: /path/to/cellranger/refdata-gex-GRCh38-2024-A + +# ─── Xenium 5K head (mode: xenium5k) ─────────────────────────────────────── +xenium5k: + # xenium_dir is the directory containing the Xenium 5K samples. + # Make sure to keep the {sample} placeholder in the path. + xenium_dir: "/path/to/xenium/{sample}" + qupath_pyramid_level: 3 + hires_pyramid_level: 3 + pixel_size_um: 0.2125 + +# ─── Contract — Head → Core hand-off ───────────────────────────────────────── +contract: + sample_key: sample + spatial_key: spatial + require_region: false + require_raw_counts: true + mito_prefix: + - MT- + - mt- + +# ─── QC & preprocessing ────────────────────────────────────────────────────── +# qc thresholds to screen in qc_sweep_all +# (tip) toggle analysis.spatial_point_size if spatial data points are hard to visualize +qc_sweep: + # use ingest_ref if you have an annotated reference + ingest_enabled: false + # ingest_ref.obs column containing the cell type annotations + ingest_key: cell_type_ingest + cell_id_key: cell_id + dpi: 300 + # thresholds to screen + thresholds: + min_genes: + - 50 + - 100 + - 200 + min_counts: + - 1 + - 10 + - 50 + max_counts: + - 3000 + - 5000 + max_pct_mt: + - 10 + - 15 + - 20 + +# (optional) per-sample QC thresholds TSV (example at config/per_sample_qc.tsv) +# default behaviour applies the same thresholds across all samples +per_sample_qc: '' + +# ─── Annotation ────────────────────────────────────────────────────────────── +# (optional, recommended) predefined cell type markers, screened during leiden to ease annotation +snakemake_cell_markers: config/snakemake_cell_markers.tsv +# (default) cluster → cell-type TSV (fill template from generate_annotation_template) +cluster_annotations: config/cluster_annotations.tsv +# (optional) external labels from metadata TSVs, or from contract h5ad obs in decoupled mode +external_annotation: + enabled: false + column: manual_celltype # arbitrary source name; normalized to cell_type_external + keep_unannotated: true +ingest_ref: '/path/to/ingest_ref_data/ref.h5ad' # optional annotated scRNA-seq reference (ingest_ref) +ingest_ref_label_key: cell_type_annotation_column +integration: + integrate_key: sample +# (optional) include annotations for downstream plots from core_samples.tsv +extra_annotations: + columns: + - condition + - batch + +# ─── Analysis ────────────────────────────────────────────────────────────── +# (default) global thresholds +analysis: + # qc thresholds + plot_dpi: 300 + # marker sizes for UMAP embeddings and spatial maps, respectively + umap_point_size: 2 + spatial_point_size: 20 + min_counts: 1 + min_cells: 3 + min_genes: 100 + # umap calculation parameters + n_neighbors: 10 + n_pcs: 30 + # n marker genes; n DE genes in pseudobulk heatmaps + de_n_genes: 25 + # (tip) set to false if "use_precomputed_clusters: true", since they are not exclusive + run_leiden_analysis: true + # leiden resolutions to screen through during clustering + resolution_scan_min: 0.6 + resolution_scan_max: 0.8 + resolution_scan_step: 0.2 + # fraction of cells for geosketching + sketch_fraction: 0.25 + # pseudobulk + run_pseudobulk_de: true + min_cells_per_pseudobulk: 10 + min_replicates: 3 + min_counts_per_pseudobulk: 1000 + # reporting thresholds + padj_threshold: 0.05 + lfc_threshold: 0.25 + ## Pseudobulk contrasts ## + # Each named analysis defines one aggregation and one DE model. `cell_type` is + # resolved to the annotation selected by annotation_types. Multiple group_by + # columns form one combined comparison group; interactions are not fitted. + pseudobulk: + analyses: + - name: regions_all_cells + aggregation: by_region + group_by: + - region_annotation + paired_by: sample + exclude_levels: + region_annotation: + - Unlabeled + contrasts: + - name: tumor_vs_healthy + numerator: + region_annotation: Tumor area + denominator: + region_annotation: Healthy area + # Biomarker tests and LRT/DEGpatterns require >=3 eligible levels after + # exclusion, replication, and pairing filters. + one_vs_rest: + enabled: true + lrt: + enabled: true + + - name: regions_by_celltype + aggregation: by_celltype_region + group_by: + - region_annotation + paired_by: sample + exclude_levels: + region_annotation: + - Unlabeled + cell_type: + - Unannotated + contrasts: + - name: tumor_vs_healthy + numerator: + region_annotation: Tumor area + denominator: + region_annotation: Healthy area + one_vs_rest: + enabled: true + lrt: + enabled: true + + # Preferred region order for QC-sweep, subcluster, and region-only pseudobulk split + # heatmaps, first-to-last (typically left-to-right). This does not filter pseudobulk + # levels; use each analysis' exclude_levels for filtering. + region_levels: + - Healthy area + - Premalignant area + - Tumor area + + # Palette used when region_annotation is the single comparison variable. + region_colors: + Healthy area: '#FDE725FF' + Premalignant area: '#1FA187FF' + Tumor area: '#46337EFF' + + +# ─── Spatial niches (optional) ───────────────────────────────────────────── +spatial_niches: + enabled: true + # reuse previously calculated niches + use_precomputed: false + # expects niche_{sample}.tsv + niche_dir: '/path/to/precomputed_niches' + # BANKSY parameters + lambda: 0.8 + num_neighbours: 18 + cluster_n_neighbours: 50 + n_iterations: -1 + max_m: 1 + nbr_weight_decay: scaled_gaussian + # (recommended, high memory usage) use only HVGs for niche clustering + use_hvg: true + n_top_genes: 2000 + niche_resolution: 0.1 + # (optional) BANKSY refine niches + refine: false + refine_num_neigh: 6 + refine_iterations: 1 + refine_auto: false + compute_umap: false + +# ─── Subcompartments (optional) ──────────────────────────────────────────── +subcompartments: + # subcompartment name (example) + Fibroblasts: + # exact-string match of cell type names to pool together + strings: + - Fibroblasts + - Fibroblasts_COLA1 + - Fibroblasts_COLA2 + # resolution range to screen in leiden clustering + resolution_min: 0.6 + resolution_max: 1.2 + resolution_step: 0.2 + +# ─── Gene / signature exploration ──────────────────────────────────────────── +gene_exploration: + # tsv where col name is gene set name, column values are genes (see config/gene_queries.tsv) + queries: config/gene_queries.tsv + aucell_max_rank_fraction: 0.05 + niche_column: spatial_niche + dpi: 300 + +# ─── Colour palettes (docs → Configuration) ──────────────────────────────── +# region color palettes is found under analysis: region_colors +sample_colors: + # extra column in core_samples.tsv + condition: + # levels and HEX code; unfound levels fall back to grey + Control: '#0072B2' + Treatment: '#D55E00' + batch: + Batch 1: '#8E44AD' + Batch 2: '#C0392B' +annotation_colors: + # levels in cluster_annotations.tsv + cell_type_tsv: + Keratinocytes: '#d62728' + Tcells: '#1f77b4' + Fibroblasts: '#2ca02c' + # levels when external_annotation: enabled: true + cell_type_external: + Keratinocytes: '#d62728' + Tcells: '#1f77b4' + Fibroblasts: '#2ca02c' + +# ─── Per-rule resources — mem_mb scales with the retry attempt ─────────────── +resources: + spaceranger_count_vhd: + mem_mb: 65536 + runtime: 1200 + threads: 16 + generate_qupath_vhd: + mem_mb: 4096 + runtime: 30 + threads: 1 + prepare_input_vhd: + mem_mb: 32768 + runtime: 240 + threads: 4 + convert_zarr_x5k: + mem_mb: 65536 + runtime: 240 + threads: 4 + generate_qupath_x5k: + mem_mb: 32768 + runtime: 60 + threads: 2 + prepare_input_x5k: + mem_mb: 51200 + runtime: 120 + threads: 4 + validate_input: + mem_mb: 32768 + runtime: 30 + threads: 2 + process_geojson: + mem_mb: 4096 + runtime: 30 + threads: 2 + qc_sweep: + mem_mb: 51200 + runtime: 180 + threads: 4 + preprocess_umap: + mem_mb: 32768 + runtime: 240 + threads: 8 + ingest_ref: + mem_mb: 32768 + runtime: 240 + threads: 8 + leiden_analysis: + mem_mb: 16384 + runtime: 120 + threads: 4 + pseudobulk_aggregate: + mem_mb: 24576 + runtime: 60 + threads: 4 + pseudobulk_de: + mem_mb: 24576 + runtime: 120 + threads: 4 + neighbourhood_analysis: + mem_mb: 32768 + runtime: 120 + threads: 8 + annotate_cells: + mem_mb: 32768 + runtime: 60 + threads: 4 + integrate_samples: + mem_mb: 65536 + runtime: 360 + threads: 8 + spatial_niches: + mem_mb: 131072 + runtime: 720 + threads: 16 + subcluster: + mem_mb: 65536 + runtime: 1440 + threads: 16 + sample_report: + mem_mb: 32768 + runtime: 60 + threads: 4 + explore_genes_integrated: + mem_mb: 65536 + runtime: 240 + threads: 4 + explore_genes_sample: + mem_mb: 16384 + runtime: 360 + threads: 4 + default: + mem_mb: 8192 + runtime: 60 + threads: 1 diff --git a/config/core_samples.tsv b/config/core_samples.tsv index b72769c..32689bf 100644 --- a/config/core_samples.tsv +++ b/config/core_samples.tsv @@ -1,11 +1,9 @@ -# CORE sample sheet — technology-agnostic anchor (see config.yaml: core_samples). -# Required column: sample. Optional columns (here: patient, type) are stamped into -# obs by preprocess_umap and surfaced downstream via config.extra_annotations. -# `sample` values match config/samples.csv sample_id (visiumhd head run). -# IMPORTANT: patients 1 3 5 and 7 are shared with the HTGEdgeSeq study and match -# their patient id there; the remaining patients are not shared between studies. -# Lines starting with '#' are ignored (no commas inside so no quoting). -sample patient batch -sample1 Patient 1 Batch 1 -sample2 Patient 2 Batch 2 -sample3 Patient 1 Batch 1 +# CORE sample sheet — technology-agnostic anchor (see config.yaml: core_samples). +# Required column: sample. Optional columns (here: condition, batch) are stamped into +# obs by preprocess_umap and surfaced downstream via config.extra_annotations. +# `sample` values match config/samples.csv sample_id (visiumhd head run). +# Lines starting with '#' are ignored (no commas inside so no quoting). +sample condition batch +sample1 Control Batch 1 +sample2 Treatment Batch 1 +sample3 Treatment Batch 2 diff --git a/demos/atera/atera_config.yaml b/demos/atera/atera_config.yaml index c074c56..77d2a4f 100644 --- a/demos/atera/atera_config.yaml +++ b/demos/atera/atera_config.yaml @@ -53,7 +53,6 @@ atera: # The preview ships as ONE sample, so atera_dir carries no {sample} placeholder. # For a real multi-sample run use a pattern, e.g. /path/to/atera/{sample}/outs. atera_dir: /scratch/CBiB/soterino/atera/outs - zarr_dir: '' # '' -> spaceranger_processing_outdir # Level 2 = 0.85 um/px. Both QuPath images (fluorescence composite and registered # H&E) are exported on this morphology grid, so they overlay exactly and share one # pixel->micron scalar. Level 3 halves the resolution and the file size. @@ -119,13 +118,13 @@ spatial_niches: niche_dir: '' lambda: 0.8 num_neighbours: 18 - cluster_n_neighbours: 15 - n_iterations: 2 + cluster_n_neighbours: 50 + n_iterations: -1 max_m: 1 nbr_weight_decay: scaled_gaussian use_hvg: true n_top_genes: 2000 - niche_resolution: 0.3 + niche_resolution: 0.1 refine: false refine_num_neigh: 6 refine_iterations: 1 diff --git a/demos/visiumhd/visiumhd_config.yaml b/demos/visiumhd/visiumhd_config.yaml index 6e66d37..9cafdf2 100644 --- a/demos/visiumhd/visiumhd_config.yaml +++ b/demos/visiumhd/visiumhd_config.yaml @@ -13,7 +13,6 @@ precomputed_metadata_dir: 'demos/visiumhd/metadata' core_samples: demos/visiumhd/contracts/core_samples.tsv xenium5k: xenium_dir: /scratch/CBiB/soterino/xenium/{sample} - zarr_dir: '' qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 @@ -98,13 +97,13 @@ spatial_niches: niche_dir: '' lambda: 0.8 num_neighbours: 18 - cluster_n_neighbours: 15 - n_iterations: 2 + cluster_n_neighbours: 50 + n_iterations: -1 max_m: 1 nbr_weight_decay: scaled_gaussian use_hvg: true n_top_genes: 2000 - niche_resolution: 0.3 + niche_resolution: 0.1 refine: false refine_num_neigh: 6 refine_iterations: 1 diff --git a/demos/xenium5k/xenium5k_config.yaml b/demos/xenium5k/xenium5k_config.yaml index f2106a9..fac8bce 100644 --- a/demos/xenium5k/xenium5k_config.yaml +++ b/demos/xenium5k/xenium5k_config.yaml @@ -13,7 +13,6 @@ precomputed_metadata_dir: 'demos/xenium5k/metadata' core_samples: demos/xenium5k/contracts/core_samples.tsv xenium5k: xenium_dir: /scratch/CBiB/soterino/demos/xenium5k/results/{sample} - zarr_dir: '' qupath_pyramid_level: 3 hires_pyramid_level: 3 pixel_size_um: 0.2125 @@ -100,13 +99,13 @@ spatial_niches: niche_dir: '' lambda: 0.8 num_neighbours: 18 - cluster_n_neighbours: 15 - n_iterations: 2 + cluster_n_neighbours: 50 + n_iterations: -1 max_m: 1 nbr_weight_decay: scaled_gaussian use_hvg: true n_top_genes: 2000 - niche_resolution: 0.3 + niche_resolution: 0.1 refine: false refine_num_neigh: 6 refine_iterations: 1 diff --git a/docs/demos.md b/docs/demos.md index 8eabe6a..52cf97f 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -7,13 +7,15 @@ This page shows how to run SpaceBlocks end-to-end on **public data** for each su ## Technical notes -To keep the tutorials simple and lightweight, we use 1 sample from a public dataset and divide it into **3 artificial samples, keeping only 500 highly-variable genes (HVGs)**. +To keep the Visium HD and Xenium 5K tutorials simple and lightweight, we use 1 sample from a public dataset and divide it into **3 artificial samples, keeping only 500 highly-variable genes (HVGs)**. Atera and MERSCOPE examples consume publicly available raw data. We provide default `config` files, pre-annotated regions (GeoJSON files), clusters and cluster-to-cell type equivalences for the example public datasets to ensure reproducibility. The exact environment versions used during the generation of these tutorials can be found under `demos/lock.envs`. -Each of the examples below can run under 8Gb of RAM and 4 cores in <40 min under our `slurm` HPC system: +The small decoupled Visium HD and Xenium 5K examples run under 8 GB of RAM and four cores +in under 40 minutes on our Slurm system. The full HeadBlock examples have platform- and +dataset-dependent requirements: ``` Our HPC specifications @@ -27,7 +29,7 @@ Our HPC specifications 1 TB RAM ``` -## Visium HD uncoupled mode example (mouse brain) +## Visium HD decoupled mode example (mouse brain) SpaceBlocks is built to analyse single-cell resolution Spatial Transcriptomics data, so Visium HD data needs to be preprocessed via [bin2cell](https://github.com/Teichlab/bin2cell), [ENACT](https://github.com/Sanofi-Public/enact-pipeline) or, as in the Visium HD HeadBlock, Space Ranger >= v4.0.1 (internally implementing StarDist segmentation). @@ -63,7 +65,7 @@ python format_visiumhd.py # -> contracts/.h5ad + core_samples We provide `demo_vhd.geojson` for this dataset, as an example QuPath export on the hires image (see [QuPath annotation tutorial](qupath-tutorial.md)). -## Xenium 5K uncoupled mode example (human melanoma) +## Xenium 5K decoupled mode example (human melanoma) You may download the Xenium dataset from the [10x Genomics web](https://www.10xgenomics.com/datasets/xenium-prime-ffpe-human-skin), or via terminal using curl or wget. @@ -103,14 +105,14 @@ Importantly, **`format_xenium.py` embeds a greyscale composite of the `morpholog Unlike the two examples above, this one runs a HeadBlock rather than `mode: decoupled`: the contract h5ad is built by `prepare_input_ate` from the raw bundle, so it exercises the whole head chain. The [dataset](https://www.10xgenomics.com/datasets/atera-wta-ffpe-human-breast-cancer) is 18,028 genes over 170,057 cells (CC BY 4.0). !!! note "Atera demo dataset size and SpaceBlocks mode" - This demo runs in `mode: atera` and the size of the dataset is ~190GB. + This demo runs in `mode: atera` and the size of the dataset is ~190GB, and the total space used after running SpaceBlocks is close to 300GB. ```bash # at the workflow dir, build the environment cd SpaceBlocks/ conda env create --file=workflow/envs/atera.yaml conda activate atera -# download into a scratch dir with ~120 GB free: the bundle alone is ~55 GB zipped +# download into a scratch dir with ~300 GB free: the bundle alone is ~55 GB zipped mkdir -p /path/to/atera && cd /path/to/atera BASE=https://s3-us-west-2.amazonaws.com/10x.files/samples/atera/dev/WTA_Preview_FFPE_Breast_Cancer curl -O $BASE/WTA_Preview_FFPE_Breast_Cancer_outs.zip @@ -142,21 +144,19 @@ snakemake run_postprocessing --sdm conda The helper also prints the `annotation_colors` block using 10x's own display colours, already pasted into the shipped config. -The usual SpaceBlocks route — a human mapping Leiden clusters to cell types in `cluster_annotations.tsv` is to be implemented post-alpha. Note that `leiden_analysis` stays **on** to showcase 10x Genomics marker table `demos/atera/snakemake_cell_markers.tsv`, which is the supplemental `gene_groups.csv` pivoted to one column per group. - Region annotation is optional here (the demo annotates cells from 10x's own calls). If you do want regions, `qupath_images` exports two annotatable TIFFs per sample, and `prepare_input_ate` reads whichever GeoJSON you export **named after the image stem** (`{sample}_.geojson`), preferring them in this order: - `{sample}_he_background.tiff` — the registered H&E resampled onto the morphology grid (also the embedded contract background). **Recommended**: it sits on the cells' coordinate grid, so regions map back with a single scalar (no affine). Export as `{sample}_he_background.geojson`. - `{sample}_morphology.tiff` — the fluorescence composite (the fallback when no H&E is configured). Export as `{sample}_morphology.geojson`. -Importantly, the registered H&E is not the raw whole-slide scan, which covers a second tissue section that Atera never imaged. +Importantly, the registered H&E is not the raw whole-slide scan, which covers a second tissue section. The bundle holds a single sample, so `run_postprocessing` is not meaningful/complete here because integration, pseudobulk DE and composition comparisons all need replicates. The shipped config therefore trims those options; see `demos/xenium5k/xenium5k_config.yaml` for a config that exercises them. ## MERSCOPE HeadBlock example (mouse brain) Like the Atera demo, the MERSCOPE demo runs its HeadBlock the contract h5ad is built by `prepare_input_mer` straight from a raw Vizgen region, so it exercises the whole MERSCOPE head chain (`generate_qupath_mer` → `prepare_input_mer`). The -head reads the two Vizgen cell CSVs directly and reuses the `5k.yaml` environment (no intermediate zarr, no new environment). +head reads the two Vizgen cell CSVs directly and reuses the `xenium5k.yaml` environment (no intermediate zarr, no new environment). !!! note "MERSCOPE demo dataset size and SpaceBlocks mode" This demo runs in `mode: merscope` and the size of the dataset is ~25GB. @@ -214,7 +214,9 @@ The shipped config holds a **single** region, so postprocessing (integration, ps ## Configure and run the examples -A ready-to-run config ships next to each example: `demos/xenium5k/xenium5k_config.yaml`, `demos/visiumhd/visiumhd_config.yaml`, `demos/atera/atera_config.yaml` and `demos/merscope/merscope_config.yaml`. Optionally, you may use an external reference for the `ingest` rule, we provide external links to set in the example config files. +A config ships next to each example: `demos/xenium5k/xenium5k_config.yaml`, `demos/visiumhd/visiumhd_config.yaml`, `demos/atera/atera_config.yaml` and `demos/merscope/merscope_config.yaml`. Note that configs paths need to be adapted with the ones in your specific system before reproducing the examples. + +Optionally, you may use an external reference for the `ingest` rule, we provide external links to set in the example config files. To use one, comment the default `configfile:` line in `workflow/Snakefile` and uncomment the matching example line (all are already present): @@ -242,7 +244,7 @@ snakemake run_postprocessing --sdm conda # integration + DE across The shipped `demos/xenium5k/xenium5k_config.yaml` is set up to demonstrate the optional rules too: -- **Subcompartment re-clustering.** `subcompartments` groups cell type labels and re-clusters them (`subcluster` rule, included in `run_posprocessing`). +- **Subcompartment re-clustering.** `subcompartments` groups cell type labels and re-clusters them (`subcluster` rule, included in `run_postprocessing`). - **Gene / signature exploration.** `gene_queries_demo.tsv` holds a small gene set and a gene to showcase the `run_exploration` CoreBlock capabilities. - **Automatic annotation (optional).** `ingest_ref` and `ingest_ref_label_key` are empty by default, they allow the use of external references for automatic annotation (which may guide manual annotation). We provide examples in the config files to test this feature during `qc_sweep_all` (showing which assigned cell types are removed during QC) and `run_preprocessing`. Automatic annotation will also be shown in the `sample_report.pdf` during `run_postprocessing`. @@ -274,8 +276,12 @@ where each `` is a row in `config/core_samples.tsv`. The sample names in mode: "decoupled" contract_dir: "demos/visiumhd/contracts" # holds .h5ad core_samples: "demos/visiumhd/contracts/core_samples.tsv" -# plus post_processing_outdir, logdir, geojson_path (see Configuration) +# plus post_processing_outdir and logdir (see Configuration) ``` -Region annotation in decoupled mode is baked into the contract (as `region_annotation`) when you -build it — there is no `qupath_images` target and no GeoJSON join later. +Region annotation in decoupled mode is baked into the contract (as `region_annotation`) when you build it. + +External cell-type labels may likewise be stored as an `obs` column in each contract. Set +`external_annotation.enabled: true` and point `external_annotation.column` at that column; a +separate `precomputed_metadata_dir` is optional for this decoupled-only input path. Contract +`obs_names` remain the join key, and validation checks that the configured labels are present. diff --git a/docs/design.md b/docs/design.md index c68222d..553e6ec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -35,19 +35,24 @@ SpaceBlocks/ mode: atera convert_zarr_ate → generate_qupath_ate → prepare_input_ate (alpha) ↘ generate_qupath_he_ate (opt.) ↗ mode: merscope → generate_qupath_mer → prepare_input_mer + optional GeoJSON process_geojson → prepare_input_* (HeadBlock modes only) mode: decoupled (no HeadBlock — you provide the contract h5ads) │ ‖ COREBLOCK INPUT POINT (validate_input): contract h5ad structure validation ▼ each HeadBlock writes the unfiltered contract h5ad; although these can also be prepared manually (mode: decoupled) ── CoreBlock (technology-agnostic) ────────────────────────────────────────────── STEP 1 · preprocessing - validate_input → [qc_sweep] → preprocess_umap → leiden_analysis - → generate_annotation_template - → ingest_ref (opt.) → spatial_niches (opt.) + validate_input ─┬→ qc_sweep (opt.) + └→ preprocess_umap ─┬→ leiden_analysis + ├→ generate_annotation_template + ├→ ingest_ref (opt.) + └→ spatial_niches (opt.; joint across samples) STEP 2 · postprocessing - annotate_cells → integrate_samples → pseudobulk_aggregate → pseudobulk_de - → neighbourhood_analysis → subcluster → sample_report + annotate_cells ─┬→ integrate_samples ─┬→ pseudobulk_aggregate → pseudobulk_de + │ └→ subcluster (opt.) + ├→ neighbourhood_analysis + └→ sample_report STEP 3 · exploration - explore_genes_integrated → explore_genes_sample + integrate_samples → explore_genes_integrated → explore_genes_sample ``` See the [Rule reference](rules.md) for a one-paragraph description of every rule. @@ -58,17 +63,20 @@ The point where the HeadBlocks meet the CoreBlocks is the `validate_input` rule. It is a single coupling point with one h5ad per sample. Every HeadBlock produces the same contract file structure, so the CoreBlock is identical for all technologies: -- **Path** — one config key (`contract.unfiltered_h5ad`, derived in the Snakefile), *written* by - the active HeadBlock's `prepare_input_*` and *read* by `validate_input`, `qc_sweep`, and - `preprocess_umap`, so producer and consumers can never drift. +- **Path** — one config key (`contract.unfiltered_h5ad`, derived in the Snakefile) is read by + `validate_input`, `qc_sweep`, and `preprocess_umap`. In HeadBlock modes it points to the + nested output written by `prepare_input_*`; in decoupled mode it resolves to + `contract_dir/{sample}.h5ad`. - **Shape of AnnData object:** - `X` — raw integer counts (no filtering, no normalisation). - `obsm["spatial"]` — per-cell `(x, y)` centroids, finite. - `uns["spatial"][sample]` — `{images: {hires}, scalefactors}` when a tissue/morphology image exists; omitted otherwise (the CoreBlock then scatters on coordinates). - - `obs` — `sample` (required); `region_annotation` when a GeoJSON was provided; and - `cell_id` (optional but strongly recommended, and **must equal `obs_names`** to prevent - unexpected behaviour, since downstream joins key on it — `validate_input` warns otherwise). + - `obs` — `sample` (required); optional `region_annotation`; optional external-annotation + columns; and `cell_id` (optional but strongly recommended, and **must equal + `obs_names`** to prevent unexpected behaviour, since downstream joins key on it — + `validate_input` warns otherwise). HeadBlocks create `region_annotation` from an optional + GeoJSON. Decoupled contracts must carry any region or direct external labels themselves. - **The image is a HeadBlock concern.** The CoreBlock never rebuilds an image — it consumes the embedded `uns["spatial"]` image when present and otherwise scatters on `obsm["spatial"]`. @@ -82,8 +90,8 @@ For what every file is and why it is useful, see [Outputs](outputs.md); the tree ``` {post_processing_outdir}/ ├── Samples/{sample}/ -│ ├── {sample}_unfiltered.h5ad CONTRACT H5AD (from HeadBlocks: prepare_input_{vhd,x5k}) -│ ├── QuPath_image/… generate_qupath_{vhd,x5k} +│ ├── {sample}_unfiltered.h5ad CONTRACT H5AD (HeadBlock modes only) +│ ├── QuPath_image/… generate_qupath_* (HeadBlock modes only) │ ├── validation/input_validation.json validate_input │ ├── qc_sweep/ qc_sweep (optional) │ ├── adata_{sample}.h5ad, metadata_{sample}.tsv, {sample}_report.tsv preprocess_umap @@ -91,23 +99,33 @@ For what every file is and why it is useful, see [Outputs](outputs.md); the tree │ ├── annotation/, adata_{sample}_annotated.h5ad annotate_cells │ └── neighbourhood_analysis/{annot_type}/ neighbourhood_analysis ├── integrated_samples/ concatenated / harmony / sketched / samples_report.pdf -├── pseudobulk/{annot_type}/{analysis_level}/ aggregated/ + de_results/ +├── pseudobulk/{annot_type}/{analysis_name}/ aggregated/ + de_results/ ├── Subcompartments/{name}/{Harmony,NoHarmony}/ subcluster ├── spatial_niches/ tsv/, plots/ ├── gene_exploration/ expression_ranges.tsv, {entry}/… └── cluster_annotations_template.tsv generate_annotation_template ``` +Decoupled source contracts remain under `contract_dir`; the workflow does not copy them into +this output tree. + ## Key design decisions - **HeadBlock/CoreBlock split.** A technology-specific set of rules (HeadBlock) produces a standardized unfiltered AnnData file (contract); the common CoreBlocks consume it for the analyses. Inclusion of new platform/s only requires the development of a new HeadBlock, and the CoreBlock does not need to be changed, unless it has to be expanded. - **`validate_input` is a DAG gate.** The division is ensured by a validation rule, which can pass or produce a hard/soft failure. - A `.json` is written if the contract structure is validated. - A hard failure prevents the `.json` from being written, and thus the CoreBlock from running. - - Soft issues (missing region annotation, no mito genes, no image) are recorded, but do not necessarily prevent CoreBlocks from running (the config file has a parameter for soft-passing). -- **Coherent naming.** Head rules carry a 3-letter technology code (`_vhd`, `_x5k`) so the organization is easy to follow and heads can coexist. + - Soft issues (missing region annotation, no mito genes, no image) are recorded. Missing + regions become a hard failure only when `contract.require_region: true`. +- **Coherent naming.** Head rules carry a 3-letter technology code (`_vhd`, `_x5k`, `_ate`, `_mer`) so the organization is easy to follow and heads can coexist. - **`qc_sweep` rule is diagnostic only** — it never filters, clusters, or writes an h5ad. -- **External annotation takes over.** When enabled, it becomes the primary annotation everywhere. The [Configuration](configuration.md) allows flexibility to retain all cells or remove externally unannotated ones. +- **Region ownership follows the input mode.** HeadBlocks validate and join optional GeoJSONs; + decoupled contracts arrive with `region_annotation` already attached or run without + region-dependent analyses. +- **External annotation takes over.** When enabled, it becomes the primary annotation + everywhere. Labels come from per-sample metadata or, for decoupled contracts, directly from + `obs`. Keeping unannotated cells preserves the normal QC path; removing them makes the + external labels define the preprocessing cell set. - **Config-driven colours** — regions, sample metadata, and cell types are applied consistently across every plot, with a grey fallback for undefined levels. This allows precise and consistent colour representations throughout the analyses. - **Retries scale memory.** `mem_mb` grows with the attempt number, so an OOM-killed job is resubmitted with more RAM. @@ -130,7 +148,7 @@ If you wished to write a new HeadBlock: - Add the mode in the `Snakefile` parameter `KNOWN_HEADS`. - Create a `demo/` (public data download, GeoJSON region annotations, short documentation) with a prepared config example and instructions for reproducibility with a test run. - Include the documentation in the `demos.md`. -- Create a Pull Request to the `development` branch. We will revise it promptly. +- Create a Pull Request to the `dev` branch. We will revise it promptly. ### Extending the CoreBlocks diff --git a/docs/environments.md b/docs/environments.md index 3eb3c6f..1faea77 100644 --- a/docs/environments.md +++ b/docs/environments.md @@ -17,6 +17,14 @@ The pipeline provisions one conda environment per rule group (run with `--use-co | `envs/xenium5k.yaml` | xenium5k and merscope HeadBlocks | `>=` lower bounds | | `envs/atera.yaml` | atera HeadBlocks (alpha) | pinned `spatialdata-io >=0.7,<0.8` | +All environment YAMLs declare `conda-forge` followed by `bioconda`. The Visium HD/Core +environment and the dedicated BANKSY environment include `python-igraph`; integration, +spatial-niche clustering, and subclustering use that backend for Leiden. + +Changing an environment YAML changes its Snakemake environment hash. The next run therefore +creates a new managed environment automatically. If you created a named environment manually, +update or recreate it from the current YAML. + `>=` bounds are reproducible enough for day-to-day use but not for archival reproducibility (a future solver may pick newer, potentially breaking versions). For a publication release, we recommend to generate and share the **exact** locks from the environments you actually tested, as below. diff --git a/docs/getting-started.md b/docs/getting-started.md index 40ae2e4..a2d4556 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,13 +12,17 @@ If you want to see an example run on public data, see the [public data end-to-en - [Snakemake](https://snakemake.readthedocs.io) ≥ 9.13 and Conda/Mamba. - For the **Visium HD** head only: an external [Space Ranger](https://www.10xgenomics.com/support/software/space-ranger) ≥ 4.0.1. - [QuPath](https://qupath.github.io/) (desktop), recommended to include manual region annotations for the downstream analyses (see [QuPath annotation tutorial](qupath-tutorial.md)). -- Configure your Snakemake profile in `--profile profiles/default`. An example for HPCs with Slurm scheduler is provided (tested with version 24.11.5). +- Use `--profile profiles/default` for local execution, or adapt the provided `--profile profiles/slurm` example for a Slurm HPC (tested with version 24.11.5). -We recommend to always run snakemake with `--sdm conda`, so each rule is executed within its pinned environment. +We recommend to always run Snakemake with `--sdm conda`, so each rule is executed within its dedicated environment. ## 2. Configure -In the `config/config.yaml` pick a `mode` and fill two files: +Create your run configuration, then pick a `mode` and fill the sample sheet: + +```bash +cp config/config.yaml.template config/config.yaml +``` 1. **`config/core_samples.tsv`** — one row per sample; extra columns (e.g. `patient`, `condition`) become design metadata, and can be used for downstream analyses and visualization. 2. **`config/config.yaml`** — set `mode`, the input and output paths, and (for Visium HD) the head files. See [Configuration](configuration.md) for full details. @@ -29,7 +33,7 @@ Before launching SpaceBlocks, we recommend testing your configuration using a dr ```bash snakemake -n --sdm conda # dry-run: builds the DAG and validates the config ``` -This will tell you if the config file has any incorrect input types (i.e. characters when expecting integers), and will build the environments for the different SpaceBlocks steps. +This reports invalid configuration values, builds the DAG, and shows which rule-specific environments would be used. Environment creation happens during the first real run. ## 3. Region annotation (*optional*, but **recommended**) @@ -41,7 +45,7 @@ snakemake qupath_images --sdm conda ``` You may then manually annotate spatial regions in QuPath, export GeoJSONs and make them available for SpaceBlocks via `geojson_path` in `config/config.yaml`. -Then follow the [QuPath annotation tutorial](qupath-tutorial.md), saving each file as `{sample}_tissue_hires_image.geojson` (Visium HD) or `{sample}_morphology.geojson` (Xenium 5K) into `config["geojson_path"]`. +Then follow the [QuPath annotation tutorial](qupath-tutorial.md), using the filename for your HeadBlock and saving each GeoJSON into `config["geojson_path"]`. !!! note "Alternatives for region annotation" We provide instructions for GeoJSON generation via QuPath, but alternative software (i.e. Napari) could be used to generate the region annotation files. @@ -50,24 +54,27 @@ While this step is optional (without it, all observations will become annotated ### 3.1. Region annotation in decoupled mode -In `mode: decoupled` there is no HeadBlock, so `qupath_images` produces nothing and the pipeline never joins a GeoJSON. +In `mode: decoupled` there is no HeadBlock, so `qupath_images` produces nothing and +`geojson_path` is not consumed. Region labels must already be stored in each contract as +`obs["region_annotation"]`. -Your provided contract h5ads should therefore already carry `obs["region_annotation"]`; otherwise every cell is `Unlabeled`. +If that column is absent, validation emits a soft warning under the `/results/Samples//validation/` directory, preprocessing labels every cell `Unlabeled`, and region-dependent plots and analyses are skipped. +Set `contract.require_region: true` if missing regions should instead stop the workflow. !!! warning "Coordinate scale divergence" Make sure the pixel size and coordinate system of the image you annotate match the contract's `obsm["spatial"]`. In head modes the `generate_qupath_*` rules export a correctly-scaled image; in `mode: decoupled` you are responsible for annotating on an image consistent with the coordinates you embedded. ## 4. Preprocess, then look before you filter -SpaceBlocks provide a rule to screen how different QC filters (under `qc_sweep:` -> `min_genes`, `min_counts`, `max_counts`, `max_pct_mito`) would affect each of your samples. +SpaceBlocks provides a rule to screen how different QC filters (under `qc_sweep:` -> `min_genes`, `min_counts`, `max_counts`, `max_pct_mt`) would affect each of your samples. QC candidates can be configured under `qc_sweep.thresholds`. -You can combine this screening with an external annotated reference (`ingest_ref` and `ingest_ref_label_key` in the config) to observe how many, where in the tissue and which (automatically annotated) cell type would be removed when appliyin a threshold. To run it, use: +You can combine this screening with an external annotated reference (`ingest_ref` and `ingest_ref_label_key` in the config) to observe how many, where in the tissue and which (automatically annotated) cell type would be removed when applying a threshold. To run it, use: ```bash snakemake qc_sweep_all --sdm conda # OPTIONAL: see where each candidate threshold cuts ``` -We recommend to run `qc_sweep` **first** to choose QC thresholds from your own data rather than the defaults. +**We recommend running `qc_sweep_all` first** to choose QC thresholds from your own data rather than the defaults. You can next use the same thresholds for all samples or use a `per_sample_qc` TSV if samples need different cut-offs. After defining your general (*default*) or sample-specific thresholds, you should specify the preprocessing parameters (under `analysis:` in the config). @@ -83,6 +90,10 @@ snakemake run_preprocessing --sdm conda # validate → QC → normalise → cl The output (see [Outputs](outputs.md) for what every file is, and [the output tree](design.md#output-structure) for where it lands) will show spatial plots and UMAPs containing clustering metrics (clustree, Silhouette scores) for each resolution in the range, predefined markers (*optional*), QC plots to diagnose potentially noisy clusters. Then, you may proceed to the annotation step. +When `spatial_niches.enabled: true`, this target also runs BANKSY jointly across all +preprocessed samples. Its dense matrix can require substantially more memory than the +per-sample steps. We recommend to use HVGs and set the `spatial_niches` resource block accordingly. + ## 5. Annotate cell types Annotation is the most limiting step in every high-throughput single cell pipeline. It conditions downstream analysis and, thus, it is a breakpoint between CoreBlocks within the SpaceBlocks workflow. @@ -93,15 +104,30 @@ Through the development of SpaceBlocks, we have tested several automatic annotat SpaceBlocks automatically generates a cluster-to-cell type annotation TSV template within preprocessing. In this TSV, rows are cluster numbers and columns are sample names and Leiden resolution (i.e. "Sample1_res0.6"). -The resolution is automatically read by the `annotation` rule, so in the TSV you need to update: +The resolution is automatically read by the `annotate_cells` rule, so in the TSV you need to update: 1. The equivalence between the cluster number and the cell type present. 2. The resolution desired to perform annotation in the column names. +Note that you may use different clustering resolutions, as long as they are found in the metadata, to annotate each of the samples. Change the header of the TSV template to your desired `{sample}_{resolution}` to do so. + +!!! danger "Save a backup of your metadata files" + Leiden clustering between two different runs may vary slightly, for example, changing the order of specific clusters. To avoid wasting time re-annotating your samples, we recommend users to back up the metadata files from the results to be annotated. They can be found under `{base_dir}/results/Samples//metadata_.tsv`. + After filling the annotation TSV, point `config["cluster_annotations"]` at it. This will unlock postprocessing rules. ### 5.2 External annotation -You may annotate your samples outside of SpaceBlocks, and continue the downstream analyses by providing annotated TSVs for each of them, see [advanced configuration](configuration.md#6-advanced-reproducibility-and-reusability). +You may use labels generated outside SpaceBlocks without a manual cluster-to-cell-type TSV. +Enable `external_annotation`, set its source `column`, and provide one +`metadata_{sample}.tsv` per sample under `precomputed_metadata_dir`. These files must be +indexed by the same cell identifiers as the contract. In `mode: decoupled`, the configured +column may instead be stored directly in each contract's `obs`. + +With `keep_unannotated: true`, ordinary QC is applied and unmatched cells remain as +`Unannotated`. With `keep_unannotated: false`, preprocessing keeps only cells with a real +external label and skips the ordinary QC thresholds because the external annotation defines +the cell set. Contract validation checks the source column and barcode overlap before the +analysis proceeds. See [advanced configuration](configuration.md#6-advanced-reproducibility-and-reusability). ## 6. Postprocessing and report @@ -111,9 +137,16 @@ After annotating your cells/barcodes, you may run postprocessing rules: snakemake run_postprocessing --sdm conda # annotate → integrate → DE → neighbourhood → reports ``` -This command will produce the integrated object, a per-sample report `sample_report.pdf` to share with collaborators, pseudobulk DE (*optional*, but recommended), neighbourhood/co-occurrence and optional subcompartment re-clustering (for cell subtyping exploration). +This command will produce the integrated object, a per-sample report `results/integrated_samples/sample_report.pdf` to share with collaborators, pseudobulk DE (*optional*, but recommended), neighbourhood/co-occurrence and optional subcompartment re-clustering (for cell subtyping exploration). + +## 7. Differential expression + +SpaceBlocks performs differential expression on raw counts summed into pseudobulk observations. Comparison variables come from the `core_samples.tsv` sample sheet and may define a single contrast (for example, treated versus untreated), combined categorical groups (for example, phenotype and treatment), or a multilevel variable (for example, cancer stage). +Importantly, combined groups are compared directly, since SpaceBlocks does not currently fit statistical interaction terms. For multilevel variables, optional one-vs-rest tests identify level-specific biomarkers, while the omnibus LRT gives a holistic test across levels. Both require at least three eligible levels after exclusion, replication, and pairing filters; configured pairwise contrasts still run when either is skipped. + +Independent samples should omit `paired_by`. When comparison levels are matched within the same patient or spatial sample, `paired_by` identifies that repeated unit. For each explicit pairwise contrast, only complete pairs are tested. See the [pseudobulk experimental-design configuration](configuration.md#pseudobulk-experimental-designs) for independent, combined-group, multilevel, and paired examples. -## 7. Explore genes and signatures +## 8. Explore genes and signatures Once the integrated file is available, you may explore any gene or gene set of interest (generated in the results, or completely external). @@ -123,18 +156,18 @@ List the genes / gene sets in the `gene_exploration.queries` TSV; integrated and snakemake explore_genes --sdm conda # AUCell scoring + spatial/dotplot composites ``` -## 8. Reproducibility +## 9. Reproducibility -### 8.1 Recommendations at a glance +### 9.1 Recommendations at a glance -- **Colours** are consistent across every plot — set them once in `config.yaml` +- **Colours** are consistent across output plots — set them once in `config.yaml` ([color scale configuration](configuration.md#4-color-scale-customization)). - **Reproducibility**: to close the UMAP/Leiden non-determinism gap across machines, share pre-computed clusters/annotations or a pre-built contract h5ad (`mode: decoupled`) — see [reproducibility configuration](configuration.md#6-advanced-reproducibility-and-reusability). -- Make sure to back up the intermediate files (see section 7.2) and to [save a locked copy your environment versions](environments.md). +- Make sure to back up the intermediate files (see section 9.2) and to [save a locked copy of your environment versions](environments.md). -### 8.2 Files to ensure reproducibility +### 9.2 Files to ensure reproducibility To reproduce your full run, make sure to back up: 1. The design files (`config/core_samples.tsv`; `visiumhd_samples.csv`). @@ -143,8 +176,8 @@ To reproduce your full run, make sure to back up: 4. The annotated metadata, generated during the postprocessing. Optionally, if using manual annotation, the TSV mapping cluster-to-cell type equivalences. 5. The environment versions (recommended for publication). -## 9. Example runs +## 10. Example runs You may check our [public data end-to-end example runs](demos.md) to see how a full run looks. -Both the Visium HD and Xenium 5K examples are set to run in <40 min under a slurm scheduler system, with individual jobs not going above 8Gb of RAM use. +Be mindful that wall time and memory usage depend strongly on dataset size and enabled analyses. Note that BANKSY spatial-niche construction is usually the most memory-intensive processing step. diff --git a/docs/index.md b/docs/index.md index 69b94b4..8194941 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ **A Snakemake workflow for single-cell-resolution spatial transcriptomics.** -This site documents SpaceBlocks, a technology-agnostic Snakemake workflow to **semi-automatically analyse single-cell-resolution spatial transcriptomics (ST).** +This site documents SpaceBlocks, a technology-agnostic Snakemake workflow for **expert-supervised cohort-level analysis of single cell resolution spatial transcriptomics (ST).** **New here?** Start taking a look at the **[configuration](configuration.md)**, follow with **[get started](getting-started.md)** for a complete run and check the [examples with public data](demos.md). diff --git a/docs/outputs.md b/docs/outputs.md index 07ab397..f8a9e28 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -1,6 +1,6 @@ # Outputs -What each step of the workflow writes, and why it is useful. Everything lands under +What each step of the workflow writes, and why it is useful. Most outputs land under `post_processing_outdir` (set in `config.yaml`); per-sample results live in `Samples//`. ## Output directory structure @@ -11,36 +11,41 @@ The tag after each folder marks which CoreBlock step produces it — **(pre)** p ``` / ├── Samples// -│ ├── _unfiltered.h5ad (contract — head or decoupled input) -│ ├── validation/ (pre) input_validation.json +│ ├── _unfiltered.h5ad (contract — HeadBlock modes only) +│ ├── validation/ (pre) input_validation.json │ ├── qc_sweep/ (pre) optional pre-filter QC diagnostics │ ├── adata_.h5ad, metadata_.tsv (pre) │ ├── _report.tsv (pre) -│ ├── leiden_resolution_/ (pre) -│ ├── adata__ingested.h5ad, ingest/ (pre) optional -│ ├── adata__annotated.h5ad, annotation/ (post) -│ └── neighbourhood_analysis// (post) +│ ├── leiden_resolution_/ (pre) +│ ├── adata__ingested.h5ad, ingest/ (pre) optional +│ ├── adata__annotated.h5ad, annotation/ (post) +│ └── neighbourhood_analysis// (post) ├── spatial_niches/ (pre) optional ├── cluster_annotations_template.tsv (pre) fill this in, then annotate ├── integrated_samples/ (post) concatenated + integrated objects, report -├── pseudobulk/// (post) aggregated/ + de_results/ +├── pseudobulk/// (post) aggregated/ + de_results/ ├── Subcompartments// (post) optional └── gene_exploration/ (expl) ``` +In `mode: decoupled`, source contracts remain under `contract_dir`; SpaceBlocks does not copy +them into `Samples//`. + ## Preprocessing outputs | Rule | File | What it is | | --- | --- | --- | | `validate_input` | `validation/input_validation.json` | Confirms the contract is valid and records what was found | +| `process_geojson` | `validation/geojson_validation.json` | Confirms that an optional HeadBlock GeoJSON has classifications and usable geometries | | `qc_sweep` | `qc_sweep/qc_*.png` | Shows where candidate QC thresholds would remove cells, before filtering | | `preprocess_umap` | `adata_.h5ad` | Filtered, normalised object with PCA, UMAP and multi-resolution clusters | -| `preprocess_umap` | `metadata_.tsv` | Per-cell table of clusters and QC metrics | +| `preprocess_umap` | `metadata_.tsv` | Per-cell table of per sample clusters, region, and parsed metadata columns. | | `preprocess_umap` | `_report.tsv` | Cells before vs after filtering and the cut-offs applied | | `leiden_analysis` | `leiden_resolution_/` | UMAP and spatial plots for each clustering resolution | | `generate_annotation_template` | `cluster_annotations_template.tsv` | Empty cluster to cell-type sheet for you to fill in | | `ingest_ref` | `adata__ingested.h5ad`, `ingest/` | Optional reference-based cell-type labels and plots | -| `spatial_niches` | `spatial_niches/tsv/niche_.tsv`, `plots/` | Optional spatial niches per cell and their plots | +| `spatial_niches` | `spatial_niches/spatial_niches_concatenated.h5ad` | Joint BANKSY object with Harmony embedding and `spatial_niche` labels | +| `spatial_niches` | `spatial_niches/tsv/niche_.tsv`, `plots/` | Per-sample niche assignments and plots | ## Postprocessing outputs @@ -48,12 +53,13 @@ The tag after each folder marks which CoreBlock step produces it — **(pre)** p | --- | --- | --- | | `annotate_cells` | `adata__annotated.h5ad` | Object with final cell-type labels applied | | `annotate_cells` | `annotation/` | Per-sample annotation and marker plots | -| `integrate_samples` | `integrated_samples/harmony_integrated.h5ad` | All samples concatenated and batch-integrated | -| `integrate_samples` | `integrated_samples/sketched.h5ad` | Geometric-sketch subsample for fast plotting | -| `pseudobulk_aggregate` | `pseudobulk///aggregated/` | Pseudobulk count matrices and their PCA QC | -| `pseudobulk_de` | `pseudobulk///de_results/` | Differential-expression tables and plots | +| `integrate_samples` | `integrated_samples/concatenated.h5ad` | Uncorrected concatenation with `leiden_uncorrected` clusters | +| `integrate_samples` | `integrated_samples/harmony_integrated.h5ad` | Batch-integrated object with `leiden_harmony` clusters | +| `integrate_samples` | `integrated_samples/sketched.h5ad` | Full dataset with sketch-derived `clusters` projected back by `ingest` | +| `pseudobulk_aggregate` | `pseudobulk///aggregated/` | Pseudobulk count matrices, model metadata, manifest, and PCA QC for one named analysis | +| `pseudobulk_de` | `pseudobulk///de_results/` | Pairwise outputs plus optional `one_vs_rest/` biomarker and `LRT/` omnibus results; ineligible multilevel tests write `SKIPPED_insufficient.txt` in their directory | | `neighbourhood_analysis` | `neighbourhood_analysis//` | Cell-type co-occurrence and enrichment per sample | -| `subcluster` | `Subcompartments//` | Optional re-clustering of a chosen compartment | +| `subcluster` | `Subcompartments//{Harmony,NoHarmony}/` | Re-clustered h5ads, diagnostics, markers, UMAPs, and composition plots | | `sample_report` | `integrated_samples/samples_report.pdf` | One page per sample with UMAP, spatial, dotplot and barplot | ## Exploration outputs diff --git a/docs/qupath-tutorial.md b/docs/qupath-tutorial.md index e03d656..8f3ae96 100644 --- a/docs/qupath-tutorial.md +++ b/docs/qupath-tutorial.md @@ -1,8 +1,8 @@ # QuPath region annotation -SpaceBlocks lets you overlay manual **region annotations** (tumour, healthy, necrosis, …) onto your samples. You draw them once in [QuPath](https://qupath.github.io/), export them as GeoJSON, and the pipeline folds them into `obs["region_annotation"]`. +SpaceBlocks lets you overlay manual **region annotations** (tumour, healthy, necrosis, …) onto your samples. In HeadBlock modes, you draw them in [QuPath](https://qupath.github.io/), export them as GeoJSON, and the pipeline folds them into `obs["region_annotation"]`. In decoupled mode, the column must already be part of the contract. -This is an **optional but recommended step** because it unlocks the region-aware analyses (neighbourhood, per-region co-occurrence, region-level pseudobulk). Without it, every cell will be `Unlabeled`. +This is an **optional but recommended step** because it unlocks the region-aware analyses (neighbourhood, per-region co-occurrence, region-level pseudobulk). Without `region_annotation`, every cell will be `Unlabeled` and those analyses are skipped. Choosing QuPath means anatomopathologists and researchers without bioinformatics skills can annotate the histology directly, while the annotations stay easy to fold back into the AnnData objects. @@ -12,8 +12,8 @@ Choosing QuPath means anatomopathologists and researchers without bioinformatics | Mode | How to obtain the image | | --- | --- | -| `visiumhd` / `xenium5k` (a Headblock runs) | `snakemake qupath_images` writes one image per sample under `Samples/{sample}/QuPath_image/` (a Hi-Res PNG for Visium HD, a morphology TIFF for Xenium 5K). | -| `decoupled` (no Headblock) | There is no `qupath_images` target — the image is instead **embedded in the contract h5ad** you provided (`uns["spatial"]`). Annotate the regions when you build the contract externally (see [Preparing inputs for decoupled mode](demos.md#preparing-inputs-for-decoupled-mode)). | +| `visiumhd` / `xenium5k` / `atera` / `merscope` | `snakemake qupath_images` writes the available annotation image(s) under `Samples/{sample}/QuPath_image/`. | +| `decoupled` (no Headblock) | There is no `qupath_images` target. Annotate the source image externally and add the labels while building the contract; embedding that image in `uns["spatial"]` is optional. See [Preparing inputs for decoupled mode](demos.md#preparing-inputs-for-decoupled-mode). | ## 2. Annotate in QuPath @@ -23,7 +23,7 @@ Choosing QuPath means anatomopathologists and researchers without bioinformatics 4. Right-click the polygon → *Set classification* → choose the region label. 5. Repeat until the slide is fully annotated. 6. **Select all annotations** → *File* → *Export objects as GeoJSON* → *Export as feature collection*. -7. Save the file following the naming convention below, into the folder referenced by `config["geojson_path"]`. +7. In a HeadBlock mode, save the file following the naming convention below into the folder referenced by `config["geojson_path"]`. @@ -44,6 +44,12 @@ Choosing QuPath means anatomopathologists and researchers without bioinformatics | --- | --- | | Visium HD | `{sample}_tissue_hires_image.geojson` | | Xenium 5K | `{sample}_morphology.geojson` | +| MERSCOPE | `{sample}_morphology.geojson` | +| Atera morphology | `{sample}_morphology.geojson` | +| Atera registered H&E background | `{sample}_he_background.geojson` | + +`mode: decoupled` has no workflow-side GeoJSON naming convention or join. Add the resulting +labels to `obs["region_annotation"]` while constructing the contract. The region labels you set as classifications become the values of `obs["region_annotation"]`; list them (and their colours) under `analysis.region_levels` / `analysis.region_colors` in diff --git a/docs/rules.md b/docs/rules.md index db07ff1..e144baf 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -25,6 +25,14 @@ consume pre-existing contract h5ads directly. launching the rest of the run. The contract builders (`prepare_input_*`) pick those GeoJSONs up automatically (and fall back to `Unlabeled` regions if none are present). +### Shared region validation + +#### `process_geojson` *(optional)* +Validates each discovered region GeoJSON before a HeadBlock joins it to the contract. Empty +files, missing QuPath classifications, and unusable geometries fail early; the source GeoJSON +is never modified. This rule does not run in `mode: decoupled`, where region labels must +already be present in the contract. + ### Visium HD Included only when `mode: visiumhd`. Produces the standardized contract h5ad. @@ -91,7 +99,7 @@ Reads the `morphology_focus` channels from the zarr at a configurable pyramid le Analogous to `generate_qupath_x5k`, and sharing its name-aware channel colouring. This is the **default annotation image** and is always produced. The GeoJSON files follow the naming convention `{sample}_morphology.geojson` (read from `config["geojson_path"]`). #### Atera: `generate_qupath_he_ate` *(optional)* -Included only when `atera.he_image` and `atera.he_alignment` are both set. Reads the registered H&E at a configurable pyramid level (the full-resolution image is tens of GB and never enters the zarr) and writes a second QuPath TIFF, so regions may be drawn on histology instead of fluorescence. GeoJSONs from this image follow the naming convention `{sample}_he.geojson`. +Included only when `atera.he_image` and `atera.he_alignment` are both set. Reads the registered H&E at a configurable pyramid level (the full-resolution image is tens of GB and never enters the zarr) and writes a second QuPath TIFF, so regions may be drawn on histology instead of fluorescence. GeoJSONs from this image follow the naming convention `{sample}_he_background.geojson`. It also composes the QuPath-pixel → micron transform from the 10x 3×3 alignment matrix and stores it in the scale-factor JSON, and resamples the H&E onto the morphology pixel grid to produce the contract background image. That resampling is required: `sc.pl.spatial` positions cells with a single scalar factor and no rotation, while the H&E and morphology frames differ by roughly 90°. !!! tip "Supply the keypoints file" @@ -99,7 +107,7 @@ It also composes the QuPath-pixel → micron transform from the 10x 3×3 alignme #### Atera: `prepare_input_ate` Takes the zarr and the QuPath GeoJSON, and writes the **unfiltered contract h5ad** for the CoreBlocks. Analogous to `prepare_input_x5k`. -It accepts a GeoJSON drawn on *either* annotation image, preferring `{sample}_he.geojson` when both exist and applying the matching transform. The embedded background likewise prefers the registered H&E, which is more informative histologically, and falls back to the channel-agnostic greyscale morphology composite whenever no H&E is configured. +It accepts a GeoJSON drawn on *either* annotation image, preferring `{sample}_he_background.geojson` when both exist and applying the matching transform. The embedded background likewise prefers the registered H&E, which is more informative histologically, and falls back to the channel-agnostic greyscale morphology composite whenever no H&E is configured. --- @@ -116,19 +124,19 @@ The first CoreBlock includes rules from data preparation to clustering. #### `validate_input` The **most important rule** of the pipeline: performs contract-format validation. -Confirms that the contract for each sample in `config/core_samples.tsv` is well-formed (loads as AnnData; >0 cells/genes; non-negative integer raw counts; finite `obsm["spatial"]`; `sample` in obs; unique names; well-formed `uns["spatial"]` if present). +Confirms that the contract for each sample in `config/core_samples.tsv` is well-formed (loads as AnnData; >0 cells/genes; non-negative integer raw counts; finite `obsm["spatial"]`; `sample` in obs; unique names; well-formed `uns["spatial"]` if present). When external annotation is enabled, it also validates the configured source column and its barcode overlap with `obs_names`. It supports two modes: requiring region annotations (hard) or not (soft). Hard failures prevent the DAG from advancing. Soft issues (missing region annotation, no mito genes, no image) are logged into `validation/input_validation.json` and are not fatal. #### `qc_sweep` *(optional; target `qc_sweep_all`)* Optional rule that provides diagnostic information about QC before committing to downstream analyses. It never filters, clusters, or writes an h5ad. The output shows *where on the tissue* each candidate QC threshold would remove cells, before any filter is committed. It includes joint scatter, violin, and spatial **plots** plus a **summary TSV** showing how the cut-offs would affect each sample. -Automatic annotation from an externally annotated dataset (see `ingest_ref`) is optional, shown side-by-side with the spatial output. +Automatic `scanpy.ingest` annotation from an externally annotated dataset (see `ingest_ref`) is optional, shown side-by-side with the spatial output. #### `preprocess_umap` The **core preprocessing step**, technology-agnostic. -Reads the validated contract, applies QC filters (shared or per sample) — or, with external annotation, masks to the externally-annotated cells; normalises + log1p (keeping a raw-counts layer), runs PCA → neighbours → UMAP → multi-resolution Leiden, and stamps core-sheet metadata into `obs`. +Reads the validated contract, applies QC filters (shared or per sample), normalises + log1p (keeping a raw-counts layer), runs PCA → neighbours → UMAP → multi-resolution Leiden, and stamps core-sheet metadata into `obs`. When external annotation is enabled with `keep_unannotated: false`, it instead keeps externally labelled cells and skips the ordinary QC thresholds because those labels define the cell set. Writes `adata_{sample}.h5ad`, `metadata_{sample}.tsv`, and a per-sample `{sample}_report.tsv` (cells before/after, per-feature min/max, applied cut-offs). #### `leiden_analysis` @@ -145,6 +153,10 @@ Automatic annotation via `scanpy.ingest()` from an annotated scRNA-seq reference #### `spatial_niches` *(optional)* Runs BANKSY across concatenated samples to identify shared spatial niches. +The default uses 18 physical neighbours to build BANKSY features, then a separate +50-neighbour graph in Harmony-corrected BANKSY PCA space. Leiden uses igraph at resolution +0.1 until convergence. These controls remain configurable under `spatial_niches` because +larger datasets may require different memory, runtime, or niche granularity. Produces per-sample niche TSVs (barcode → niche), a concatenated h5ad, and spatial plots for each niche. --- @@ -155,27 +167,38 @@ This second CoreBlock includes rules from sample annotation to integration and d #### `annotate_cells` Adds cell-type annotations from a manually curated TSV (cluster → cell type), or from an external annotation TSV (barcode → cell type). -Writes `adata_{sample}_annotated.h5ad` plus annotation plots and composition barplots. +Writes `adata_{sample}_annotated.h5ad` plus annotation plots and composition barplots. The output records which annotation columns contain real labels; placeholder-only columns such as an all-`Unannotated` `cell_type_tsv` are retained for compatibility but are not treated as available annotations. The external source column configured by `external_annotation.column` may have any name and is copied to the canonical `cell_type_external` column. It can come from `metadata_{sample}.tsv` or, in decoupled mode without external metadata, directly from the contract h5ad's `obs`. When `keep_unannotated: true`, mixed real and `Unannotated` labels remain active and the unannotated cells are retained in overview and composition plots. +A manual `cluster_annotations` TSV is therefore not required for an external-only run. #### `integrate_samples` -Concatenates all annotated samples, runs Harmony batch correction on the specified variable (`integration.integrate_key`), geosketch subsampling, and composition barplots. +Concatenates all annotated samples, runs Harmony batch correction on the specified variable (`integration.integrate_key`), geosketch subsampling, and composition barplots. It uses seeded, undirected igraph Leiden with two iterations for the uncorrected, Harmony, and sketch-derived clusters. -Writes `concatenated.h5ad`, `harmony_integrated.h5ad`, and `sketched.h5ad`. This is the input to pseudobulk, subclustering (CoreBlock 2), and gene exploration (CoreBlock 3). +Writes `concatenated.h5ad`, `harmony_integrated.h5ad`, and `sketched.h5ad`. The latter contains the full dataset after sketch-derived cluster labels are projected back with `ingest`; it is not only the sketch subset. These objects feed pseudobulk, subclustering (CoreBlock 2), and gene exploration (CoreBlock 3). #### `pseudobulk_aggregate` -Builds pseudobulk count matrices via [decoupler](https://decoupler.readthedocs.io/en/latest/) per `region`, `cell type` and (optionally) `niche` from the integrated object. +Builds raw-count pseudobulk matrices via [decoupler](https://decoupler.readthedocs.io/en/latest/) from the integrated object. Named analyses in `analysis.pseudobulk.analyses` choose one of four aggregations: all cells per sample, per cell type, per region, or per cell type and region. Sample metadata from `core_samples.tsv` are preserved for downstream models, and configured levels are excluded consistently before aggregation and testing. Produces diagnostic QC and PCA plots coloured by sample and by each extra-annotation column. #### `pseudobulk_de` *(optional; `analysis.run_pseudobulk_de: true`)* -Identifies deregulated genes between conditions and annotated regions via differential expression with DESeq2 (R package) on the pseudobulk matrices. +Identifies deregulated genes with DESeq2 on the pseudobulk matrices. The comparison may use region annotations or one or more sample-level variables such as phenotype and treatment. + +Differential expression runs only for tests requested in the named analysis: + +- explicit pairwise Wald contrasts, with optional categorical covariates; +- paired Wald contrasts using `paired_by: sample`, retaining complete pairs; +- optional one-vs-rest Wald tests for level-specific biomarkers when at least three eligible levels remain; +- and an optional omnibus likelihood-ratio test (LRT) when at least three eligible levels remain after exclusion, replication, and pairing filters, with DEGpatterns gene-group clustering when enough genes are significant. + +If fewer than three eligible levels remain for a subgroup or cell type, LRT and DEGpatterns +are skipped, the reason is recorded in the rule log and `LRT/SKIPPED_insufficient.txt`, and +configured pairwise contrasts continue normally. -Differential expression runs in three ways: -- pairwise Wald contrasts between level pairs (region A vs region B), -- each level against the rest, -- and, if there are more than 2 levels, a likelihood-ratio test (LRT) with DEGpatterns for gene-group clustering. +One-vs-rest tests use the configured covariates and pairing. For every eligible level they +write a result table, volcano plot, and heatmaps under `one_vs_rest/`, plus tables, a barplot, +and a heatmap summarizing genes uniquely upregulated in one level-versus-rest comparison. -Produces TSV tables, volcano plots, and metadata-annotated heatmaps between conditions. +Multiple `group_by` columns create a combined categorical group for comparisons such as drug versus vehicle within one phenotype. Produces TSV tables, volcano plots, and metadata-annotated heatmaps between configured groups. Note that SpaceBlocks does not currently support fitting statistical interactions. #### `neighbourhood_analysis` Runs squidpy neighbourhood enrichment per sample and per `region`, plus per-region cell-type co-occurrence (a full co-occurrence figure per region and compact pairwise heatmaps). @@ -183,12 +206,12 @@ Runs squidpy neighbourhood enrichment per sample and per `region`, plus per-regi #### `subcluster` *(optional)* Re-clustering of the specified subcompartment(s) (a config-specified subset of cell types) for cell-subtype exploration. -Generates integrated (Harmony) and unintegrated results, with UMAPs coloured by `region`, spatial niche, and external metadata columns, marker tables, and composition barplots. +Generates integrated (Harmony) and unintegrated results, using seeded, undirected igraph Leiden with two iterations at each configured resolution. Outputs include UMAPs coloured by `region`, spatial niche, and external metadata columns, marker tables, and composition barplots. #### `sample_report` Assembles a compact, multi-page PDF with graphical outputs per sample, ideal to share with internal and external collaborators. -Each page contains the most important data-descriptive information: cluster / annotation / region / niche UMAPs and matching spatial maps, bar plots for absolute and relative composition, and a dot plot with the top-10 expressed markers per cell type. +Each page contains the most important data-descriptive information: cluster / annotation / region / niche UMAPs and matching spatial maps, bar plots for absolute and relative composition, and a dot plot with the top-10 expressed markers per cell type. Only annotation sources containing real labels are shown; in runs without a manual TSV annotation, the external or ingest annotation also drives cell-type composition. Categorical legends use reserved bands below their plots, with adaptive rows and wrapped long labels, and always remain on the same PDF page as the corresponding panels. --- @@ -201,12 +224,12 @@ Integrated exploration scales are standardized to ease interpretation of the res #### `explore_genes_integrated` Exploration of the integrated samples. -Loads the Harmony-integrated object once and computes AUCell scores for the input gene or gene set. It then writes shared expression ranges and produces integrated UMAP/dotplot PNGs per gene or gene set. +Loads the Harmony-integrated object once and computes AUCell scores for the input gene or gene set. It then writes shared expression ranges and produces integrated UMAP/dotplot PNGs per gene or gene set. Multi-panel categorical legends are kept in dedicated bands within each composite. #### `explore_genes_sample` Exploration of individual samples. -Using the shared expression ranges from the integrated exploration, produces per-sample spatial composites (expression + cell type + region) and stacked dotplot composites for each query entry. +Using the shared expression ranges from the integrated exploration, produces per-sample spatial composites (expression + cell type + region) and stacked dotplot composites for each query entry. Multi-panel categorical legends are kept in dedicated bands within each composite. --- diff --git a/images/main.png b/images/main.png index 4cbc8ab..3bb25a8 100644 Binary files a/images/main.png and b/images/main.png differ diff --git a/images/rulegraph.svg b/images/rulegraph.svg index 21e46b4..f868151 100644 --- a/images/rulegraph.svg +++ b/images/rulegraph.svg @@ -1 +1 @@ -spaceranger_count_vhdgenerate_annotation_templateconvert_zarr_x5kconvert_zarr_ateprepare_input_mergenerate_qupath_merprepare_input_vhdgenerate_qupath_vhdprepare_input_x5kgenerate_qupath_x5kprepare_input_ategenerate_qupath_atevalidate_inputpreprocess_umapleiden_analysisspatial_nichesingest_refannotate_cellsneighbourhood_analysisintegrate_samplessample_reportpseudobulk_aggregateexplore_genes_integratedsubclusterpseudobulk_deexplore_genes_sampleHeadblocksCoreblocksVisium HDXenium 5KAteraMERSCOPEPreprocessingPostprocessingExploration \ No newline at end of file +spaceranger_count_vhdprocess_geojsongenerate_annotation_templateconvert_zarr_x5kconvert_zarr_ategenerate_qupath_mergenerate_qupath_vhdgenerate_qupath_x5kgenerate_qupath_ategenerate_qupath_he_ateprepare_input_merprepare_input_vhdprepare_input_x5kprepare_input_atevalidate_inputpreprocess_umapqc_sweepleiden_analysisspatial_nichesingest_refannotate_cellsneighbourhood_analysisintegrate_samplessample_reportpseudobulk_aggregateexplore_genes_integratedsubclusterpseudobulk_deexplore_genes_sampleHeadblocksCoreblocksVisium HDXenium 5KAteraMERSCOPEPreprocessingPostprocessingExploration \ No newline at end of file diff --git a/profiles/default/config.yaml b/profiles/default/config.yaml index cd838dd..b1fbdea 100644 --- a/profiles/default/config.yaml +++ b/profiles/default/config.yaml @@ -3,7 +3,6 @@ # ═══════════════════════════════════════════════════════════════════════════ # Invoke with: snakemake --profile profiles/default # Set as default for a shell session with: export SNAKEMAKE_PROFILE=profiles/default - executor: local jobs: 4 diff --git a/profiles/slurm/config.yaml b/profiles/slurm/config.yaml index 24246fb..bf205e8 100644 --- a/profiles/slurm/config.yaml +++ b/profiles/slurm/config.yaml @@ -3,7 +3,7 @@ # ═══════════════════════════════════════════════════════════════════════════ # Invoke with: snakemake --profile profiles/slurm # Set as default for a shell session with: export SNAKEMAKE_PROFILE=profiles/slurm - +# executor: slurm jobs: 30 diff --git a/tools/recolor_tubemap.py b/tools/recolor_tubemap.py index 6a972e7..ed3398e 100644 --- a/tools/recolor_tubemap.py +++ b/tools/recolor_tubemap.py @@ -30,6 +30,7 @@ CORE_PHASE = { # Preprocessing "validate_input": "pre", + "process_geojson": "pre", "qc_sweep": "pre", "qc_sweep_all": "pre", "preprocess_umap": "pre", diff --git a/workflow/Snakefile b/workflow/Snakefile index 9df8b03..1e4b66b 100644 --- a/workflow/Snakefile +++ b/workflow/Snakefile @@ -7,12 +7,15 @@ postprocessing → exploration). Rules are split across rules/*.smk and included directly below; helper functions live in rules/common.smk. """ +import glob import os +import re import sys +from itertools import combinations import pandas as pd from snakemake.utils import validate, min_version -min_version("8.0") +min_version("9.13") configfile: "config/config.yaml" @@ -26,13 +29,18 @@ configfile: "config/config.yaml" # configfile: "demos/atera/atera_config.yaml" # alpha: Atera HeadBlock test # configfile: "demos/merscope/merscope_config.yaml" -validate(config, schema="schemas/config.schema.yaml") - # Helper functions include: "rules/common.smk" +# Expand config paths recursively +config = _expand_base_dir(config) + +# Validate config against schema after path expansion +validate(config, schema="schemas/config.schema.yaml") + + # ── Globals ────────────────────────────────────────────────────────────────── ANALYSIS = config.get("analysis", {}) _region_colors = ANALYSIS.get("region_colors", {}) @@ -46,12 +54,12 @@ RANDOM_SEED = config.get("random_seed", 42) # Xenium 5K head specifics XENIUM5K = config.get("xenium5k", {}) or {} -XENIUM_ZARR_DIR = XENIUM5K.get("zarr_dir", "") or OUTDIR_SR +XENIUM_ZARR_DIR = OUTDIR_SR # Atera head specifics (ALPHA — the platform ships H2 2026 and its output format is # still a preview; see docs/rules.md). ATERA = config.get("atera", {}) or {} -ATERA_ZARR_DIR = ATERA.get("zarr_dir", "") or OUTDIR_SR +ATERA_ZARR_DIR = OUTDIR_SR # The registered H&E is OPTIONAL and ships separately from the outs/ bundle. Both the # image and its alignment matrix are needed for the transform to be defined, so the # H&E QuPath rule is included only when both are configured. @@ -132,6 +140,17 @@ if CORE_SAMPLES is None: ) SAMPLE_IDS = list(SAMPLES.keys()) +# Optional per-sample region GeoJSON (ROI-based analysis). Presence is optional per +# sample; a geojson-like file that matches neither accepted name is a fatal config +# error (see _find_geojson). +SAMPLES_WITH_GEOJSON = [s for s in SAMPLE_IDS if _find_geojson(s) is not None] +for _s in SAMPLE_IDS: + if _s not in SAMPLES_WITH_GEOJSON: + print( + f"[process_geojson] No GeoJSON found for sample '{_s}' — " + "no ROI-based analysis will be performed for this sample." + ) + # Optional per-column colour palettes (sample + design columns) for downstream visualization SAMPLE_COLORS = config.get("sample_colors", {}) or {} @@ -186,19 +205,41 @@ _ANNOT_TYPE_TO_COL = { "tsv_annotation": "cell_type_tsv", "external_annotation": "cell_type_external", "ingest_annotation": "cell_type_ingest", - "refined_annotation": "cell_type_refined", } DEFAULT_ANNOT_COL = _ANNOT_TYPE_TO_COL.get(PRIMARY_ANNOT_TYPE, "cell_type_tsv") ANNOT_TYPES = list(config.get("annotation_types") or [PRIMARY_ANNOT_TYPE]) -ANALYSIS_LEVELS = ANALYSIS.get("analysis_levels", ["by_region", "by_celltype_region"]) +_PSEUDOBULK_AGGREGATIONS = { + "all_cells", + "by_celltype", + "by_region", + "by_celltype_region", +} + + +PSEUDOBULK = ANALYSIS.get("pseudobulk", {}) or {} +_configured_pseudobulk_analyses = PSEUDOBULK.get("analyses") +if _configured_pseudobulk_analyses: + PSEUDOBULK_ANALYSES = [ + _validate_pseudobulk_analysis(spec) for spec in _configured_pseudobulk_analyses + ] +else: + PSEUDOBULK_ANALYSES = [ + _validate_pseudobulk_analysis(spec) for spec in _legacy_pseudobulk_analyses() + ] + +PSEUDOBULK_ANALYSIS_BY_NAME = {spec["name"]: spec for spec in PSEUDOBULK_ANALYSES} +if len(PSEUDOBULK_ANALYSIS_BY_NAME) != len(PSEUDOBULK_ANALYSES): + sys.exit("[config error] analysis.pseudobulk analysis names must be unique.") +PSEUDOBULK_ANALYSIS_NAMES = list(PSEUDOBULK_ANALYSIS_BY_NAME) # Pseudobulk DESeq2 DE is OPTIONAL and on by default RUN_DE = bool(ANALYSIS.get("run_pseudobulk_de", False)) RUN_LEIDEN_ANALYSIS = bool(ANALYSIS.get("run_leiden_analysis", True)) -# External annotation: fail fast if enabled but the column is not present in every sample's metadata +# External annotation: validate metadata headers now, or decoupled h5ad obs per sample +# in validate_input when no external metadata directory is configured. check_external_annotation() # Consistency: analysing the external annotation downstream requires it to be produced. @@ -256,6 +297,7 @@ elif MODE == "merscope": include: "rules/validate_input.smk" include: "rules/qc_sweep.smk" include: "rules/preprocess_umap.smk" +include: "rules/process_geojson.smk" include: "rules/leiden_analysis.smk" include: "rules/generate_annotation_template.smk" include: "rules/ingest_ref.smk" @@ -311,6 +353,7 @@ rule run_preprocessing: expand(rules.preprocess_umap.output.adata, sample=SAMPLE_IDS), expand(rules.preprocess_umap.output.metadata, sample=SAMPLE_IDS), expand(rules.preprocess_umap.output.report, sample=SAMPLE_IDS), + expand(rules.process_geojson.output.report, sample=SAMPLES_WITH_GEOJSON), *QUPATH_IMAGES, *( expand( @@ -338,7 +381,7 @@ rule run_postprocessing: expand( rules.pseudobulk_de.output.results_dir, annot_type=ANNOT_TYPES, - analysis_level=ANALYSIS_LEVELS, + analysis_name=PSEUDOBULK_ANALYSIS_NAMES, ) if RUN_DE else [] diff --git a/workflow/envs/dev.yaml b/workflow/envs/dev.yaml index 77d8837..620f59b 100644 --- a/workflow/envs/dev.yaml +++ b/workflow/envs/dev.yaml @@ -10,8 +10,12 @@ dependencies: # Formatting and linting tools - pre_commit - bioconda::snakefmt + - conda-forge::go-shfmt # Documentation tools - mkdocs - pymdown-extensions - mkdocs-material + + # Graphviz (for dot) + - graphviz diff --git a/workflow/envs/pseudobulk_aggregate.yaml b/workflow/envs/pseudobulk_aggregate.yaml index a67c312..28f29cc 100644 --- a/workflow/envs/pseudobulk_aggregate.yaml +++ b/workflow/envs/pseudobulk_aggregate.yaml @@ -1,6 +1,7 @@ name: pseudobulk_aggregate channels: - conda-forge + - bioconda dependencies: - python=3.10 - pip diff --git a/workflow/envs/spatial_niches.yaml b/workflow/envs/spatial_niches.yaml index a3020e6..4c888da 100644 --- a/workflow/envs/spatial_niches.yaml +++ b/workflow/envs/spatial_niches.yaml @@ -1,6 +1,7 @@ name: spatial_niches channels: - conda-forge + - bioconda dependencies: - python=3.10 diff --git a/workflow/envs/visiumhd.yaml b/workflow/envs/visiumhd.yaml index 69bb6e8..ca19bec 100644 --- a/workflow/envs/visiumhd.yaml +++ b/workflow/envs/visiumhd.yaml @@ -1,6 +1,7 @@ name: visiumhd channels: - conda-forge + - bioconda dependencies: - python=3.10 @@ -9,6 +10,7 @@ dependencies: - anndata>=0.10 - scanpy>=1.10 - harmonypy==0.0.10 + - python-igraph>=0.10 - leidenalg>=0.8 - squidpy>=1.4 diff --git a/workflow/rules/annotate_cells.smk b/workflow/rules/annotate_cells.smk index 22be00f..cf6f24d 100644 --- a/workflow/rules/annotate_cells.smk +++ b/workflow/rules/annotate_cells.smk @@ -1,37 +1,39 @@ -rule annotate_cells: - """ - Annotate cells: TSV cluster mapping + optional external annotation. - If use_precomputed_clusters, reads leiden from metadata TSV. - """ - input: - adata=_annotate_input_adata, - metadata=rules.preprocess_umap.output.metadata, - cluster_annotations=config.get("cluster_annotations", "") or [], - spatial_niche=_annotate_niche_input, - output: - adata_annot=f"{SAMPLES_DIR}/{{sample}}/adata_{{sample}}_annotated.h5ad", - plots_dir=directory(f"{SAMPLES_DIR}/{{sample}}/annotation"), - log: - out=f"{LOGDIR}/annotate_cells/{{sample}}.out", - err=f"{LOGDIR}/annotate_cells/{{sample}}.err", - benchmark: - f"{LOGDIR}/benchmarks/annotate_cells/{{sample}}.tsv" - conda: - "../envs/visiumhd.yaml" - threads: get_resource("annotate_cells", "threads") - resources: - mem_mb=mem_mb_attempt("annotate_cells"), - runtime=get_resource("annotate_cells", "runtime"), - params: - sample_id=lambda wc: wc.sample, - min_cells_per_type=ANALYSIS.get("min_cells_per_type", 15), - de_n_genes=ANALYSIS.get("de_n_genes", 10), - use_precomputed=USE_PRECOMPUTED, - external_annotation=config.get("external_annotation", {}), - precomputed_metadata_dir=config.get("precomputed_metadata_dir", ""), - annotation_colors=config.get("annotation_colors", {}), - region_colors=ANALYSIS.get("region_colors", {}), - dpi=ANALYSIS.get("plot_dpi", 300), - niche_column=GENE_EXPLORATION.get("niche_column", ""), - script: - "../scripts/annotate_cells.py" +rule annotate_cells: + """ + Annotate cells: TSV cluster mapping + optional external annotation. + If use_precomputed_clusters, reads leiden from metadata TSV. + """ + input: + adata=_annotate_input_adata, + metadata=rules.preprocess_umap.output.metadata, + external_metadata=_external_metadata_input, + cluster_annotations=config.get("cluster_annotations", "") or [], + spatial_niche=_annotate_niche_input, + output: + adata_annot=f"{SAMPLES_DIR}/{{sample}}/adata_{{sample}}_annotated.h5ad", + plots_dir=directory(f"{SAMPLES_DIR}/{{sample}}/annotation"), + log: + out=f"{LOGDIR}/annotate_cells/{{sample}}.out", + err=f"{LOGDIR}/annotate_cells/{{sample}}.err", + benchmark: + f"{LOGDIR}/benchmarks/annotate_cells/{{sample}}.tsv" + conda: + "../envs/visiumhd.yaml" + threads: get_resource("annotate_cells", "threads") + resources: + mem_mb=mem_mb_attempt("annotate_cells"), + runtime=get_resource("annotate_cells", "runtime"), + params: + sample_id=lambda wc: wc.sample, + de_n_genes=ANALYSIS.get("de_n_genes", 10), + use_precomputed=USE_PRECOMPUTED, + external_annotation=config.get("external_annotation", {}), + primary_annotation_column=DEFAULT_ANNOT_COL, + annotation_colors=config.get("annotation_colors", {}), + region_colors=ANALYSIS.get("region_colors", {}), + dpi=ANALYSIS.get("plot_dpi", 300), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), + niche_column=GENE_EXPLORATION.get("niche_column", ""), + script: + "../scripts/annotate_cells.py" diff --git a/workflow/rules/common.smk b/workflow/rules/common.smk index 2eb1183..fc10a17 100644 --- a/workflow/rules/common.smk +++ b/workflow/rules/common.smk @@ -1,355 +1,662 @@ -# workflow/rules/common.smk -# ───────────────────────────────────────────────────────────────────────────── -# Shared helper functions for the whole workflow. Collected here (rather than mixed -# in with rules) per the Snakemake modularization guideline: rule files contain -# only rules, this file contains only functions. Included FIRST from the Snakefile, -# so every function is visible to the globals block and to all rule modules. The -# functions reference workflow globals (config, SAMPLE_IDS, SAMPLES_DIR, …) that the -# Snakefile defines after this include; that is fine because the functions are only -# *called* later (at global-evaluation or DAG-build time), never at include time. -# ───────────────────────────────────────────────────────────────────────────── - - -# ── Resources ──────────────────────────────────────────────────────────────── -def get_resource(rule_name, key): - """Per-rule resource lookup with a `default` fallback (config['resources']).""" - section = config.get("resources", {}) - rule_res = section.get(rule_name, section.get("default", {})) - if key not in rule_res: - sys.exit(f"[config error] Resource '{key}' missing for rule '{rule_name}'.") - return rule_res[key] - - -def mem_mb_attempt(rule_name, cap_factor=None): - """mem_mb that grows with the Snakemake retry attempt (base * attempt), so a job - killed for OOM is automatically resubmitted with more memory. Pairs with the - global `retries` set in the execution profile. attempt starts at 1 (= base).""" - base = get_resource(rule_name, "mem_mb") - - def _mem(wildcards, attempt): - factor = attempt if cap_factor is None else min(attempt, cap_factor) - return int(base * factor) - - return _mem - - -# ── Small derivations called from the Snakefile globals block ──────────────── -def check_external_annotation(): - """Fail fast (at parse time, before any job) when external_annotation is enabled - but not fully in place: the annotation column must be present in the metadata for - EVERY sample. External labels can only come from precomputed_metadata_dir (the - pipeline's own metadata does not carry them), so that dir is required.""" - cfg = config.get("external_annotation", {}) or {} - if not cfg.get("enabled", False): - return - col = cfg.get("column", "") - if not col: - sys.exit( - "[config error] external_annotation.enabled is true but 'column' is empty." - ) - meta_dir = config.get("precomputed_metadata_dir", "") or "" - if not meta_dir: +# workflow/rules/common.smk +# ───────────────────────────────────────────────────────────────────────────── +# Shared helper functions for the whole workflow. Collected here (rather than mixed +# in with rules) per the Snakemake modularization guideline: rule files contain +# only rules, this file contains only functions. Included FIRST from the Snakefile, +# so every function is visible to the globals block and to all rule modules. The +# functions reference workflow globals (config, SAMPLE_IDS, SAMPLES_DIR, …) that the +# Snakefile defines after this include; that is fine because the functions are only +# *called* later (at global-evaluation or DAG-build time), never at include time. +# ───────────────────────────────────────────────────────────────────────────── + + +# ── Resources ──────────────────────────────────────────────────────────────── +def get_resource(rule_name, key): + """Per-rule resource lookup with a `default` fallback (config['resources']).""" + section = config.get("resources", {}) + rule_res = section.get(rule_name, section.get("default", {})) + if key not in rule_res: + sys.exit(f"[config error] Resource '{key}' missing for rule '{rule_name}'.") + return rule_res[key] + + +def mem_mb_attempt(rule_name, cap_factor=None): + """mem_mb that grows with the Snakemake retry attempt (base * attempt), so a job + killed for OOM is automatically resubmitted with more memory. Pairs with the + global `retries` set in the execution profile. attempt starts at 1 (= base).""" + base = get_resource(rule_name, "mem_mb") + + def _mem(wildcards, attempt): + factor = attempt if cap_factor is None else min(attempt, cap_factor) + return int(base * factor) + + return _mem + + +# ── Expand {base_dir} in config paths ──────────────────────────────────────── +BASE_DIR = config.get("base_dir", "") + + +def _expand_base_dir(node, root="config"): + """ + Expand config paths (recursively), replacing only {base_dir} + leaving other placeholders (for example, the wildcard {sample}) intact. + """ + if isinstance(node, str): + if "{base_dir}" in node: + expanded = node.replace("{base_dir}", BASE_DIR) + print(f"[config] expanded {root}: {expanded}", file=sys.stderr) + return expanded + return node + if isinstance(node, dict): + return {k: _expand_base_dir(v, f"{root}.{k}") for k, v in node.items()} + if isinstance(node, list): + return [_expand_base_dir(v, f"{root}[{i}]") for i, v in enumerate(node)] + return node + + +# ── Small derivations called from the Snakefile globals block ──────────────── +def check_external_annotation(): + """Fail fast (at parse time, before any job) when external_annotation is enabled + but not fully in place. Metadata TSV columns are checked here; in decoupled mode, + an absent metadata directory means the configured column will instead be checked + in each contract h5ad by validate_input.""" + cfg = config.get("external_annotation", {}) or {} + if not cfg.get("enabled", False): + return + col = cfg.get("column", "") + if not col: + sys.exit( + "[config error] external_annotation.enabled is true but 'column' is empty." + ) + meta_dir = config.get("precomputed_metadata_dir", "") or "" + if not meta_dir: + if IS_DECOUPLED: + return + sys.exit( + "[config error] external_annotation.enabled requires 'precomputed_metadata_dir' " + f"to point at a directory of metadata_{{sample}}.tsv files carrying the '{col}' " + "column. In decoupled mode only, the column may instead be stored directly " + "in each contract h5ad's obs." + ) + missing_file, missing_col = [], [] + for s in SAMPLE_IDS: + f = os.path.join(meta_dir, f"metadata_{s}.tsv") + if not os.path.isfile(f): + missing_file.append(f) + continue + try: + header = pd.read_csv(f, sep="\t", nrows=0, comment="#").columns + except Exception as e: + sys.exit(f"[config error] could not read external metadata {f}: {e}") + if col not in header: + missing_col.append(f) + if missing_file or missing_col: + msg = [ + f"[config error] external_annotation enabled (column '{col}') but not in place:" + ] + if missing_file: + msg.append( + " missing metadata file(s):\n " + "\n ".join(missing_file) + ) + if missing_col: + msg.append( + f" column '{col}' absent in:\n " + "\n ".join(missing_col) + ) + sys.exit("\n".join(msg)) + + +def _resolution_range(rmin, rmax, step): + """Inclusive Leiden resolution ladder from (min, max, step).""" + n = round((rmax - rmin) / step) + return [round(rmin + i * step, 10) for i in range(n + 1)] + + +def _cluster_annotations_available(): + """True when config['cluster_annotations'] points at a non-empty TSV.""" + path = config.get("cluster_annotations", "") + if not path or not os.path.isfile(path): + return False + try: + df = pd.read_csv(path, sep="\t", index_col=0) + return df.shape[0] > 0 and df.shape[1] > 0 and not df.isna().all().all() + except Exception: + return False + + +def _extra_annot_color_triples(): + """Flatten sample_colors for the extra-annotation columns into three parallel + lists (column, value, hex) for safe Python→R hand-off in pseudobulk_de.""" + cols, vals, colors = [], [], [] + sc_ = SAMPLE_COLORS if isinstance(SAMPLE_COLORS, dict) else {} + for c in EXTRA_ANNOT_COLUMNS: + for v, hexc in (sc_.get(c, {}) or {}).items(): + cols.append(c) + vals.append(str(v)) + colors.append(str(hexc)) + return cols, vals, colors + + +def _validate_pseudobulk_analysis(spec): + """Validate pseudobulk settings that JSON Schema cannot check across fields.""" + if not isinstance(spec, dict): + sys.exit( + "[config error] each analysis.pseudobulk.analyses entry must be a mapping." + ) + + name = str(spec.get("name", "")) + if not re.fullmatch(r"[A-Za-z0-9_-]+", name): + sys.exit( + "[config error] pseudobulk analysis names may contain only letters, " + f"numbers, '_' and '-'; got '{name}'." + ) + + aggregation = str(spec.get("aggregation", "")) + if aggregation not in _PSEUDOBULK_AGGREGATIONS: + sys.exit( + f"[config error] pseudobulk analysis '{name}' has unsupported aggregation " + f"'{aggregation}'. Choose one of {sorted(_PSEUDOBULK_AGGREGATIONS)}." + ) + + group_by = list(spec.get("group_by") or []) + if not group_by or any(not isinstance(c, str) or not c for c in group_by): + sys.exit( + f"[config error] pseudobulk analysis '{name}' needs at least one " + "non-empty group_by column." + ) + if len(group_by) != len(set(group_by)): sys.exit( - "[config error] external_annotation.enabled requires 'precomputed_metadata_dir' " - f"to point at a directory of metadata_{{sample}}.tsv files carrying the '{col}' " - "column (the pipeline's own metadata does not contain external labels)." + f"[config error] pseudobulk analysis '{name}' repeats a group_by column." ) - missing_file, missing_col = [], [] - for s in SAMPLE_IDS: - f = os.path.join(meta_dir, f"metadata_{s}.tsv") - if not os.path.isfile(f): - missing_file.append(f) - continue - try: - header = pd.read_csv(f, sep="\t", nrows=0, comment="#").columns - except Exception as e: - sys.exit(f"[config error] could not read external metadata {f}: {e}") - if col not in header: - missing_col.append(f) - if missing_file or missing_col: - msg = [ - f"[config error] external_annotation enabled (column '{col}') but not in place:" - ] - if missing_file: - msg.append( - " missing metadata file(s):\n " + "\n ".join(missing_file) - ) - if missing_col: - msg.append( - f" column '{col}' absent in:\n " + "\n ".join(missing_col) - ) - sys.exit("\n".join(msg)) - - -def _resolution_range(rmin, rmax, step): - """Inclusive Leiden resolution ladder from (min, max, step).""" - n = round((rmax - rmin) / step) - return [round(rmin + i * step, 10) for i in range(n + 1)] - -def _cluster_annotations_available(): - """True when config['cluster_annotations'] points at a non-empty TSV.""" - path = config.get("cluster_annotations", "") - if not path or not os.path.isfile(path): - return False - try: - df = pd.read_csv(path, sep="\t", index_col=0) - return df.shape[0] > 0 and df.shape[1] > 0 and not df.isna().all().all() - except Exception: - return False - - -def _extra_annot_color_triples(): - """Flatten sample_colors for the extra-annotation columns into three parallel - lists (column, value, hex) for safe Python→R hand-off in pseudobulk_de.""" - cols, vals, colors = [], [], [] - sc_ = SAMPLE_COLORS if isinstance(SAMPLE_COLORS, dict) else {} - for c in EXTRA_ANNOT_COLUMNS: - for v, hexc in (sc_.get(c, {}) or {}).items(): - cols.append(c) - vals.append(str(v)) - colors.append(str(hexc)) - return cols, vals, colors - - -def core_sample_meta(sample): - """Experimental-design metadata for `sample` (design columns only, excludes the - `sample` key). Returns {} when no core sheet is configured.""" - if CORE_SAMPLES is None or sample not in CORE_SAMPLES.index: - return {} - row = CORE_SAMPLES.loc[sample].to_dict() - row.pop("sample", None) - return row - - -# ── HEAD (Visium HD) input helpers ─────────────────────────────────────────── -def fastq_dirs_comma_separated(wc): - """Comma-separated fastq dirs for spaceranger (base + any re-sequencing runs).""" - s = SAMPLES[wc.sample] - dirs = [f"{s['fastq_dir']}/fastqs/"] - for col in ("resequenced_dir", "new_resequenced_dir", "third_resequencing_dir"): - val = s.get(col, "") - if val not in ("", "nan"): - dirs.append(f"{val}/fastqs/") - return ",".join(dirs) - - -# ── HEAD (Xenium 5K) input helper ──────────────────────────────────────────── -def xenium_dir_for(sample): - """Xenium output bundle directory for a sample, from the xenium5k.xenium_dir - {sample} pattern (e.g. 'data/xenium/{sample}').""" - pat = (config.get("xenium5k", {}) or {}).get("xenium_dir", "") - if not pat: + condition_order = list(spec.get("condition_order") or []) + if any(not isinstance(level, str) or not level for level in condition_order): sys.exit( - "[config error] mode 'xenium5k' requires xenium5k.xenium_dir " - "(a {sample} pattern to each Xenium output bundle)." + f"[config error] pseudobulk analysis '{name}'.condition_order must " + "contain only non-empty strings." ) - return pat.format(sample=sample) - - -# ── HEAD (Atera) ───────────────────────────────────────────────────────────── -def atera_dir_for(sample): - """Atera output bundle directory for a sample, from the atera.atera_dir - {sample} pattern (e.g. 'data/atera/{sample}/outs').""" - pat = (config.get("atera", {}) or {}).get("atera_dir", "") - if not pat: + if len(condition_order) != len(set(condition_order)): sys.exit( - "[config error] mode 'atera' requires atera.atera_dir " - "(a {sample} pattern to each Atera outs/ bundle)." + f"[config error] pseudobulk analysis '{name}'.condition_order repeats " + "a comparison-group label." ) - return pat.format(sample=sample) - - -def he_file_for(sample, key, required=True): - """Resolve one of the OPTIONAL H&E supplemental files (he_image, he_alignment, - he_keypoints) for a sample. These ship as separate downloads from the outs/ - bundle, so the config gives absolute {sample} patterns rather than the head - discovering them. Returns "" when unset and required=False — used for - he_keypoints, whose absence only disables the alignment QA.""" - pat = (config.get("atera", {}) or {}).get(key, "") - if not pat: - if required: - sys.exit( - f"[config error] atera.{key} is required when the optional H&E " - "annotation image is enabled (atera.he_image + atera.he_alignment)." - ) - return "" - return pat.format(sample=sample) - - -def _ate_prepare_inputs(wildcards): - """Inputs for prepare_input_ate. The H&E scale-factor JSON and background image are - required only when the optional H&E QuPath rule is active, so they are added - conditionally — a static input: block cannot express that.""" - inputs = { - "done": rules.convert_zarr_ate.output.done.format(sample=wildcards.sample), - "qupath_meta": rules.generate_qupath_ate.output.qupath_meta.format( - sample=wildcards.sample - ), - } - if ATERA_HAS_HE: - # Only the embedded background is consumed here; the raw-H&E QuPath image and its - # polygon affine were dropped, so he_meta is no longer an input. - inputs["he_background"] = ( - rules.generate_qupath_he_ate.output.he_background.format( - sample=wildcards.sample - ) - ) - # Track the region GeoJSON when present, so editing/renaming it retriggers the - # contract build. It is read by filename (regions are optional), not hard-required — - # same "declare only when present" pattern as _preprocess_inputs' precomputed_meta. - for _suffix in ("_he_background.geojson", "_morphology.geojson", "_he.geojson"): - _gj = os.path.join(GEOJ_DIR, f"{wildcards.sample}{_suffix}") - if os.path.isfile(_gj): - inputs["geojson"] = _gj - break - return inputs - - -# ── HEAD (MERSCOPE) input helper ───────────────────────────────────────────── -def merscope_dir_for(sample): - """MERSCOPE region directory for a sample, from the merscope.merscope_dir - {sample} pattern (e.g. 'data/merscope/{sample}'). The region dir holds the two - Vizgen CSVs (cell_by_gene, cell_metadata) and the images/ folder (mosaic TIFFs + - micron_to_mosaic_pixel_transform.csv).""" - pat = (config.get("merscope", {}) or {}).get("merscope_dir", "") - if not pat: + + paired_by = spec.get("paired_by", "") or "" + if not isinstance(paired_by, str): sys.exit( - "[config error] mode 'merscope' requires merscope.merscope_dir " - "(a {sample} pattern to each MERSCOPE region directory)." + f"[config error] pseudobulk analysis '{name}'.paired_by must be a string." ) - return pat.format(sample=sample) - - -# ── CORE per-rule input functions ──────────────────────────────────────────── -def _thresholds_for(sample): - """Global candidate threshold lists for qc_sweep (flat: feature -> list of - cut-offs). One set is applied to every sample; per-sample QC tuning lives in - preprocess_umap.""" - return dict(config.get("qc_sweep", {}).get("thresholds", {})) - - -def _preprocess_inputs(wc): - """preprocess_umap inputs: the validated contract h5ad (gated on the validation - report), plus OPTIONAL precomputed metadata and per-sample QC-threshold TSV, - each declared only when present so Snakemake tracks edits without hard-requiring - them.""" - inputs = { - "h5ad": config["contract"]["unfiltered_h5ad"].format(sample=wc.sample), - # gate: core runs only after the contract is validated - "validation": rules.validate_input.output.report.format(sample=wc.sample), - } - if USE_PRECOMPUTED: - precomp_dir = config.get("precomputed_metadata_dir", "") - if precomp_dir: - meta_file = os.path.join(precomp_dir, f"metadata_{wc.sample}.tsv") - if os.path.isfile(meta_file): - inputs["precomputed_meta"] = meta_file - th_tsv = config.get("per_sample_qc", "") or "" - if th_tsv and os.path.isfile(th_tsv): - inputs["thresholds_tsv"] = th_tsv - # External-annotation mask (keep_unannotated=false): the external metadata drives - # the cell set, so it is a tracked input of preprocessing. - _ext = config.get("external_annotation", {}) or {} - if _ext.get("enabled") and not _ext.get("keep_unannotated", True): - _meta_dir = config.get("precomputed_metadata_dir", "") or "" - if _meta_dir: - inputs["external_meta"] = os.path.join( - _meta_dir, f"metadata_{wc.sample}.tsv" - ) - return inputs - -def _spatial_niches_inputs(wc): - """Inputs for spatial_niches. When use_precomputed is set with an external - niche_dir, the per-sample niche_{sample}.tsv are added as MANDATORY inputs so - Snakemake aborts before the job if any is absent (complementary to the in-script - safeguard, which also catches empty/partial TSVs). When niche_dir is empty the - rule reloads from its own output dir, so nothing is added here (no circular - dependency).""" - inputs = { - "adatas": expand( - f"{SAMPLES_DIR}/{{sample}}/adata_{{sample}}.h5ad", sample=SAMPLE_IDS - ), - } - sn = config.get("spatial_niches", {}) - if sn.get("use_precomputed", False): - d = sn.get("niche_dir", "") - if d: - inputs["precomputed_niches"] = [ - os.path.join(d, f"niche_{s}.tsv") for s in SAMPLE_IDS - ] - return inputs - - -def _annotate_input_adata(wc): - """annotate_cells reads the ingested adata when an ingest reference is - configured, else the plain preprocessed adata.""" - if config.get("ingest_ref", ""): - return rules.ingest_ref.output.adata_ingested.format(sample=wc.sample) - return rules.preprocess_umap.output.adata.format(sample=wc.sample) - - -def _annotate_niche_input(wc): - """Spatial-niche TSV for this sample (barcode → spatial_niche), injected as the - spatial_niche obs column. Always the spatial_niches rule output (the rule handles - precomputed reload internally). Returns [] when niche identification is disabled, - so annotate_cells does not depend on it.""" - sn = config.get("spatial_niches", {}) - if not sn.get("enabled", False): - return [] - return f"{OUTDIR_PP}/spatial_niches/tsv/niche_{wc.sample}.tsv" - - -# ── Aggregate target list for `rule all` ───────────────────────────────────── -def get_all_targets(wildcards): - """Full default target list (per-sample preprocessing + optional niches + - annotation-dependent downstream + subcompartments). References producing rules - by name; resolved at DAG-build time, when every rule is defined.""" - targets = [] - targets += expand(rules.preprocess_umap.output.adata, sample=SAMPLE_IDS) - targets += expand(rules.preprocess_umap.output.metadata, sample=SAMPLE_IDS) - targets += expand(rules.preprocess_umap.output.report, sample=SAMPLE_IDS) - targets += list(QUPATH_IMAGES) # head QuPath image(s) for the active mode (or []) - if RUN_LEIDEN_ANALYSIS: - targets += expand( - rules.leiden_analysis.output.res_dir, - sample=SAMPLE_IDS, - resolution=RESOLUTIONS, - ) - if SPATIAL_NICHES_ENABLED: - targets.append(rules.spatial_niches.output.concatenated) - targets.append(rules.spatial_niches.output.plots_dir) - targets += list(rules.spatial_niches.output.niche_tsvs) - if HAS_CLUSTER_ANNOT: - if HAS_INGEST_REF: - targets += expand(rules.ingest_ref.output.adata_ingested, sample=SAMPLE_IDS) - targets += expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS) - targets += expand( - rules.neighbourhood_analysis.output.results_dir, - sample=SAMPLE_IDS, - annot_type=ANNOT_TYPES, - ) - targets += expand( - rules.pseudobulk_aggregate.output.agg_dir, - annot_type=ANNOT_TYPES, - analysis_level=ANALYSIS_LEVELS, + allow_repeated_unpaired = spec.get("allow_repeated_unpaired", False) + if not isinstance(allow_repeated_unpaired, bool): + sys.exit( + f"[config error] pseudobulk analysis '{name}'.allow_repeated_unpaired " + "must be a boolean." ) - if RUN_DE: - targets += expand( - rules.pseudobulk_de.output.results_dir, - annot_type=ANNOT_TYPES, - analysis_level=ANALYSIS_LEVELS, - ) - targets.append(rules.integrate_samples.output.concatenated) - targets.append(rules.integrate_samples.output.harmony) - targets.append(rules.integrate_samples.output.sketched) - targets.append(rules.sample_report.output.report) - if HAS_GENE_EXPLORATION: - targets.append(rules.explore_genes_integrated.output.ranges) - targets += expand(rules.explore_genes_sample.output.done, sample=SAMPLE_IDS) - if SUBCOMPARTMENTS: - targets += expand( - rules.subcluster.output.sub_dir, subcompartment=SUBCOMPARTMENTS + if paired_by and allow_repeated_unpaired: + sys.exit( + f"[config error] pseudobulk analysis '{name}' cannot set both " + "paired_by and allow_repeated_unpaired: true." ) - return targets + + covariates = list(spec.get("covariates") or []) + if any(not isinstance(c, str) or not c for c in covariates): + sys.exit( + f"[config error] pseudobulk analysis '{name}' has an invalid covariate." + ) + if len(covariates) != len(set(covariates)): + sys.exit(f"[config error] pseudobulk analysis '{name}' repeats a covariate.") + overlap = sorted(set(group_by) & set(covariates)) + if overlap: + sys.exit( + f"[config error] pseudobulk analysis '{name}' lists {overlap} as both " + "group_by and covariates." + ) + if paired_by and paired_by in group_by: + sys.exit( + f"[config error] pseudobulk analysis '{name}' lists '{paired_by}' as both " + "paired_by and group_by." + ) + if paired_by and paired_by in covariates: + sys.exit( + f"[config error] pseudobulk analysis '{name}' lists '{paired_by}' as both " + "paired_by and a covariate." + ) + + exclude_levels = spec.get("exclude_levels") or {} + if not isinstance(exclude_levels, dict): + sys.exit( + f"[config error] pseudobulk analysis '{name}'.exclude_levels must be a mapping." + ) + + contrast_names = set() + for contrast in list(spec.get("contrasts") or []): + if not isinstance(contrast, dict): + sys.exit( + f"[config error] contrasts in pseudobulk analysis '{name}' must be mappings." + ) + contrast_name = str(contrast.get("name", "")) + if not re.fullmatch(r"[A-Za-z0-9_-]+", contrast_name): + sys.exit( + f"[config error] contrast names in pseudobulk analysis '{name}' may " + "contain only letters, numbers, '_' and '-'." + ) + if contrast_name in contrast_names: + sys.exit( + f"[config error] duplicate contrast '{contrast_name}' in '{name}'." + ) + contrast_names.add(contrast_name) + for side in ("numerator", "denominator"): + values = contrast.get(side) + if not isinstance(values, dict) or set(values) != set(group_by): + sys.exit( + f"[config error] contrast '{contrast_name}' {side} must specify " + f"exactly the group_by columns {group_by}." + ) + excluded_values = { + column: {str(value) for value in exclude_levels.get(column, [])} + for column in group_by + } + conflicts = [ + f"{column}={value}" + for column, value in values.items() + if str(value) in excluded_values[column] + ] + if conflicts: + sys.exit( + f"[config error] contrast '{contrast_name}' {side} in '{name}' " + f"uses excluded level(s): {conflicts}." + ) + if contrast["numerator"] == contrast["denominator"]: + sys.exit( + f"[config error] contrast '{contrast_name}' in '{name}' has identical " + "numerator and denominator groups." + ) + + return { + **spec, + "name": name, + "aggregation": aggregation, + "group_by": group_by, + "condition_order": condition_order, + "paired_by": paired_by, + "allow_repeated_unpaired": allow_repeated_unpaired, + "covariates": covariates, + "exclude_levels": dict(exclude_levels), + "contrasts": list(spec.get("contrasts") or []), + "one_vs_rest": dict(spec.get("one_vs_rest") or {}), + "lrt": dict(spec.get("lrt") or {}), + } + + +def _legacy_pseudobulk_analyses(): + """Translate the former region-only config for existing demo/user configs. + + New configurations should use analysis.pseudobulk.analyses. The unimplemented + by_niche_region value is deliberately rejected rather than carried forward. + """ + levels = list(ANALYSIS.get("analysis_levels", ["by_region", "by_celltype_region"])) + unsupported = sorted(set(levels) - {"by_region", "by_celltype_region"}) + if unsupported: + sys.exit( + "[config error] unsupported legacy pseudobulk analysis_levels: " + f"{unsupported}. 'by_niche_region' has been removed because it was never implemented." + ) + contrasts = [ + { + "name": re.sub(r"[^A-Za-z0-9_-]+", "_", f"{a}_vs_{b}").strip("_"), + "numerator": {"region_annotation": str(a)}, + "denominator": {"region_annotation": str(b)}, + } + for a, b in combinations(REGION_LEVELS, 2) + ] + return [ + { + "name": level, + "aggregation": level, + "group_by": ["region_annotation"], + "paired_by": "sample", + "covariates": [], + "exclude_levels": {"region_annotation": ["Unlabeled", "Bubble"]}, + "contrasts": contrasts, + "one_vs_rest": {"enabled": len(REGION_LEVELS) > 2}, + "lrt": {"enabled": len(REGION_LEVELS) > 2}, + } + for level in levels + ] + + +def pseudobulk_analysis(name): + """Return one validated named pseudobulk analysis from the Snakefile globals.""" + return PSEUDOBULK_ANALYSIS_BY_NAME[str(name)] + + +def pseudobulk_group_label(spec, values): + """Stable display/factor label for one configured group combination.""" + columns = spec["group_by"] + if len(columns) == 1: + return str(values[columns[0]]) + return " | ".join(f"{column}={values[column]}" for column in columns) + + +def pseudobulk_contrast_param(analysis_name, field): + """Flatten named contrast mappings for safe Snakemake Python-to-R transfer.""" + spec = pseudobulk_analysis(analysis_name) + if field == "names": + return [str(c["name"]) for c in spec["contrasts"]] + if field not in {"numerator", "denominator"}: + raise ValueError(f"Unknown pseudobulk contrast field: {field}") + return [pseudobulk_group_label(spec, c[field]) for c in spec["contrasts"]] + + +def pseudobulk_group_palette(analysis_name): + """Palette for a one-column comparison; combined groups use R fallbacks.""" + spec = pseudobulk_analysis(analysis_name) + if len(spec["group_by"]) != 1: + return {} + column = spec["group_by"][0] + if column == "region_annotation": + return ANALYSIS.get("region_colors", {}) or {} + return SAMPLE_COLORS.get(column, {}) if isinstance(SAMPLE_COLORS, dict) else {} + + +def pseudobulk_condition_order(analysis_name): + """Preferred condition order, with region_levels as a legacy fallback.""" + spec = pseudobulk_analysis(analysis_name) + if spec["condition_order"]: + return [str(level) for level in spec["condition_order"]] + if spec["group_by"] == ["region_annotation"]: + return [str(level) for level in REGION_LEVELS] + return [] + + +def core_sample_meta(sample): + """Experimental-design metadata for `sample` (design columns only, excludes the + `sample` key). Returns {} when no core sheet is configured.""" + if CORE_SAMPLES is None or sample not in CORE_SAMPLES.index: + return {} + row = CORE_SAMPLES.loc[sample].to_dict() + row.pop("sample", None) + return row + + +# ── HEAD (Visium HD) input helpers ─────────────────────────────────────────── +def fastq_dirs_comma_separated(wc): + """Comma-separated fastq dirs for spaceranger (base + any re-sequencing runs).""" + s = SAMPLES[wc.sample] + dirs = [f"{s['fastq_dir']}/fastqs/"] + for col in ("resequenced_dir", "new_resequenced_dir", "third_resequencing_dir"): + val = s.get(col, "") + if val not in ("", "nan"): + dirs.append(f"{val}/fastqs/") + return ",".join(dirs) + + +# ── HEAD (Xenium 5K) input helper ──────────────────────────────────────────── +def xenium_dir_for(sample): + """Xenium output bundle directory for a sample, from the xenium5k.xenium_dir + {sample} pattern (e.g. 'data/xenium/{sample}').""" + pat = (config.get("xenium5k", {}) or {}).get("xenium_dir", "") + if not pat: + sys.exit( + "[config error] mode 'xenium5k' requires xenium5k.xenium_dir " + "(a {sample} pattern to each Xenium output bundle)." + ) + return pat.format(sample=sample) + + +# ── HEAD (Atera) ───────────────────────────────────────────────────────────── +def atera_dir_for(sample): + """Atera output bundle directory for a sample, from the atera.atera_dir + {sample} pattern (e.g. 'data/atera/{sample}/outs').""" + pat = (config.get("atera", {}) or {}).get("atera_dir", "") + if not pat: + sys.exit( + "[config error] mode 'atera' requires atera.atera_dir " + "(a {sample} pattern to each Atera outs/ bundle)." + ) + return pat.format(sample=sample) + + +def he_file_for(sample, key, required=True): + """Resolve one of the OPTIONAL H&E supplemental files (he_image, he_alignment, + he_keypoints) for a sample. These ship as separate downloads from the outs/ + bundle, so the config gives absolute {sample} patterns rather than the head + discovering them. Returns "" when unset and required=False — used for + he_keypoints, whose absence only disables the alignment QA.""" + pat = (config.get("atera", {}) or {}).get(key, "") + if not pat: + if required: + sys.exit( + f"[config error] atera.{key} is required when the optional H&E " + "annotation image is enabled (atera.he_image + atera.he_alignment)." + ) + return "" + return pat.format(sample=sample) + + +def _ate_prepare_inputs(wildcards): + """Inputs for prepare_input_ate. The H&E scale-factor JSON and background image are + required only when the optional H&E QuPath rule is active, so they are added + conditionally — a static input: block cannot express that.""" + inputs = { + "done": rules.convert_zarr_ate.output.done.format(sample=wildcards.sample), + "qupath_meta": rules.generate_qupath_ate.output.qupath_meta.format( + sample=wildcards.sample + ), + } + if ATERA_HAS_HE: + # Only the embedded background is consumed here; the raw-H&E QuPath image and its + # polygon affine were dropped, so he_meta is no longer an input. + inputs["he_background"] = ( + rules.generate_qupath_he_ate.output.he_background.format( + sample=wildcards.sample + ) + ) + # Region annotations are optional. When one is present, track both the source + # and its validation report so the contract is rebuilt only after validation. + _gj = _find_geojson(wildcards.sample) + if _gj: + inputs["geojson"] = _gj + inputs["geojson_validation"] = _geojson_validation_report(wildcards.sample) + return inputs + + +def _geojson_suffixes(): + """Accepted region-annotation filenames for the active head, in priority order.""" + return { + "visiumhd": ("_tissue_hires_image.geojson",), + "xenium5k": ("_morphology.geojson",), + "atera": ("_he_background.geojson", "_morphology.geojson"), + "merscope": ("_morphology.geojson",), + }.get(MODE, ()) + + +def _find_geojson(sample): + """Resolve an optional region GeoJSON using filenames supported by the active head.""" + if not GEOJ_DIR or not _geojson_suffixes(): + return None + + accepted = [] + for _suffix in _geojson_suffixes(): + accepted.append(os.path.join(GEOJ_DIR, f"{sample}{_suffix}")) + + # A sample-prefixed GeoJSON with any other suffix is almost certainly a typo or + # an export for a different coordinate system. Fail before silently ignoring it. + discovered = sorted(glob.glob(os.path.join(GEOJ_DIR, f"{sample}_*.geojson"))) + unsupported = [path for path in discovered if path not in accepted] + if unsupported: + sys.exit( + f"[config error] Unsupported GeoJSON filename(s) for sample '{sample}' " + f"in mode '{MODE}': {unsupported}. Expected one of: {accepted}" + ) + return next((path for path in accepted if os.path.isfile(path)), None) + + +def _geojson_validation_report(sample): + """Validation marker consumed by a head only when that sample has a GeoJSON.""" + if _find_geojson(sample) is None: + return [] + return f"{SAMPLES_DIR}/{sample}/validation/geojson_validation.json" + + +def _external_metadata_input(wildcards): + """Tracked per-sample external metadata, or no input when the feature is off.""" + cfg = config.get("external_annotation", {}) or {} + metadata_dir = config.get("precomputed_metadata_dir", "") or "" + if not cfg.get("enabled", False) or not metadata_dir: + return [] + return os.path.join( + metadata_dir, + f"metadata_{wildcards.sample}.tsv", + ) + + +# ── HEAD (MERSCOPE) input helper ───────────────────────────────────────────── +def merscope_dir_for(sample): + """MERSCOPE region directory for a sample, from the merscope.merscope_dir + {sample} pattern (e.g. 'data/merscope/{sample}'). The region dir holds the two + Vizgen CSVs (cell_by_gene, cell_metadata) and the images/ folder (mosaic TIFFs + + micron_to_mosaic_pixel_transform.csv).""" + pat = (config.get("merscope", {}) or {}).get("merscope_dir", "") + if not pat: + sys.exit( + "[config error] mode 'merscope' requires merscope.merscope_dir " + "(a {sample} pattern to each MERSCOPE region directory)." + ) + return pat.format(sample=sample) + + +# ── CORE per-rule input functions ──────────────────────────────────────────── +def _thresholds_for(sample): + """Global candidate threshold lists for qc_sweep (flat: feature -> list of + cut-offs). One set is applied to every sample; per-sample QC tuning lives in + preprocess_umap.""" + return dict(config.get("qc_sweep", {}).get("thresholds", {})) + + +def _preprocess_inputs(wc): + """preprocess_umap inputs: the validated contract h5ad (gated on the validation + report), plus OPTIONAL precomputed metadata and per-sample QC-threshold TSV, + each declared only when present so Snakemake tracks edits without hard-requiring + them.""" + inputs = { + "h5ad": config["contract"]["unfiltered_h5ad"].format(sample=wc.sample), + # gate: core runs only after the contract is validated + "validation": rules.validate_input.output.report.format(sample=wc.sample), + } + if USE_PRECOMPUTED: + precomp_dir = config.get("precomputed_metadata_dir", "") + if precomp_dir: + meta_file = os.path.join(precomp_dir, f"metadata_{wc.sample}.tsv") + if os.path.isfile(meta_file): + inputs["precomputed_meta"] = meta_file + th_tsv = config.get("per_sample_qc", "") or "" + if th_tsv and os.path.isfile(th_tsv): + inputs["thresholds_tsv"] = th_tsv + # External-annotation mask (keep_unannotated=false): the external metadata drives + # the cell set, so it is a tracked input of preprocessing. + _ext = config.get("external_annotation", {}) or {} + if _ext.get("enabled") and not _ext.get("keep_unannotated", True): + _meta_dir = config.get("precomputed_metadata_dir", "") or "" + if _meta_dir: + inputs["external_meta"] = os.path.join( + _meta_dir, f"metadata_{wc.sample}.tsv" + ) + return inputs + + +def _spatial_niches_inputs(wc): + """Inputs for spatial_niches. When use_precomputed is set with an external + niche_dir, the per-sample niche_{sample}.tsv are added as MANDATORY inputs so + Snakemake aborts before the job if any is absent (complementary to the in-script + safeguard, which also catches empty/partial TSVs). When niche_dir is empty the + rule reloads from its own output dir, so nothing is added here (no circular + dependency).""" + inputs = { + "adatas": expand( + f"{SAMPLES_DIR}/{{sample}}/adata_{{sample}}.h5ad", sample=SAMPLE_IDS + ), + } + sn = config.get("spatial_niches", {}) + if sn.get("use_precomputed", False): + d = sn.get("niche_dir", "") + if d: + inputs["precomputed_niches"] = [ + os.path.join(d, f"niche_{s}.tsv") for s in SAMPLE_IDS + ] + return inputs + + +def _annotate_input_adata(wc): + """annotate_cells reads the ingested adata when an ingest reference is + configured, else the plain preprocessed adata.""" + if config.get("ingest_ref", ""): + return rules.ingest_ref.output.adata_ingested.format(sample=wc.sample) + return rules.preprocess_umap.output.adata.format(sample=wc.sample) + + +def _annotate_niche_input(wc): + """Spatial-niche TSV for this sample (barcode → spatial_niche), injected as the + spatial_niche obs column. Always the spatial_niches rule output (the rule handles + precomputed reload internally). Returns [] when niche identification is disabled, + so annotate_cells does not depend on it.""" + sn = config.get("spatial_niches", {}) + if not sn.get("enabled", False): + return [] + return f"{OUTDIR_PP}/spatial_niches/tsv/niche_{wc.sample}.tsv" + + +# ── Aggregate target list for `rule all` ───────────────────────────────────── +def get_all_targets(wildcards): + """Full default target list (per-sample preprocessing + optional niches + + annotation-dependent downstream + subcompartments). References producing rules + by name; resolved at DAG-build time, when every rule is defined.""" + targets = [] + targets += expand(rules.preprocess_umap.output.adata, sample=SAMPLE_IDS) + targets += expand(rules.preprocess_umap.output.metadata, sample=SAMPLE_IDS) + targets += expand(rules.preprocess_umap.output.report, sample=SAMPLE_IDS) + targets += expand(rules.process_geojson.output.report, sample=SAMPLES_WITH_GEOJSON) + targets += list(QUPATH_IMAGES) # head QuPath image(s) for the active mode (or []) + if RUN_LEIDEN_ANALYSIS: + targets += expand( + rules.leiden_analysis.output.res_dir, + sample=SAMPLE_IDS, + resolution=RESOLUTIONS, + ) + if SPATIAL_NICHES_ENABLED: + targets.append(rules.spatial_niches.output.concatenated) + targets.append(rules.spatial_niches.output.plots_dir) + targets += list(rules.spatial_niches.output.niche_tsvs) + if HAS_CLUSTER_ANNOT: + if HAS_INGEST_REF: + targets += expand(rules.ingest_ref.output.adata_ingested, sample=SAMPLE_IDS) + targets += expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS) + targets += expand( + rules.neighbourhood_analysis.output.results_dir, + sample=SAMPLE_IDS, + annot_type=ANNOT_TYPES, + ) + targets += expand( + rules.pseudobulk_aggregate.output.agg_dir, + annot_type=ANNOT_TYPES, + analysis_name=PSEUDOBULK_ANALYSIS_NAMES, + ) + if RUN_DE: + targets += expand( + rules.pseudobulk_de.output.results_dir, + annot_type=ANNOT_TYPES, + analysis_name=PSEUDOBULK_ANALYSIS_NAMES, + ) + targets.append(rules.integrate_samples.output.concatenated) + targets.append(rules.integrate_samples.output.harmony) + targets.append(rules.integrate_samples.output.sketched) + targets.append(rules.sample_report.output.report) + if HAS_GENE_EXPLORATION: + targets.append(rules.explore_genes_integrated.output.ranges) + targets += expand(rules.explore_genes_sample.output.done, sample=SAMPLE_IDS) + if SUBCOMPARTMENTS: + targets += expand( + rules.subcluster.output.sub_dir, subcompartment=SUBCOMPARTMENTS + ) + return targets diff --git a/workflow/rules/explore_genes.smk b/workflow/rules/explore_genes.smk index 8915d73..0db4ae1 100644 --- a/workflow/rules/explore_genes.smk +++ b/workflow/rules/explore_genes.smk @@ -8,7 +8,7 @@ rule explore_genes_integrated: """ input: integrated=rules.integrate_samples.output.harmony, - queries=GENE_EXPLORATION.get("queries", "") or [], + queries=GENE_EXPLORATION.get("queries", "config/gene_queries.tsv"), output: ranges=f"{OUTDIR_PP}/gene_exploration/expression_ranges.tsv", done=touch(f"{OUTDIR_PP}/gene_exploration/.integrated_done"), @@ -29,6 +29,7 @@ rule explore_genes_integrated: aucell_fraction=GENE_EXPLORATION.get("aucell_max_rank_fraction", 0.05), niche_column=GENE_EXPLORATION.get("niche_column", ""), dpi=GENE_EXPLORATION.get("dpi", ANALYSIS.get("plot_dpi", 300)), + umap_point_size=ANALYSIS.get("umap_point_size", 2), annotation_colors=config.get("annotation_colors", {}), region_colors=ANALYSIS.get("region_colors", {}), extra_annot_columns=EXTRA_ANNOT_COLUMNS, @@ -71,6 +72,7 @@ rule explore_genes_sample: niche_column=GENE_EXPLORATION.get("niche_column", ""), aucell_fraction=GENE_EXPLORATION.get("aucell_max_rank_fraction", 0.05), dpi=GENE_EXPLORATION.get("dpi", ANALYSIS.get("plot_dpi", 300)), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), annotation_colors=config.get("annotation_colors", {}), region_colors=ANALYSIS.get("region_colors", {}), script: diff --git a/workflow/rules/ingest_ref.smk b/workflow/rules/ingest_ref.smk index 2f7b569..a88d4d2 100644 --- a/workflow/rules/ingest_ref.smk +++ b/workflow/rules/ingest_ref.smk @@ -33,5 +33,7 @@ rule ingest_ref: ref_label_key=config.get("ingest_ref_label_key", "cell_type"), de_n_genes=ANALYSIS.get("de_n_genes", 10), annotation_colors=config.get("annotation_colors", {}), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), script: "../scripts/ingest_ref.py" diff --git a/workflow/rules/integrate_samples.smk b/workflow/rules/integrate_samples.smk index 120240e..4f6875b 100644 --- a/workflow/rules/integrate_samples.smk +++ b/workflow/rules/integrate_samples.smk @@ -1,40 +1,42 @@ -rule integrate_samples: - """ - Integrate all annotated samples into a multi-sample object. - - Produces three h5ad files in integrated_samples/: - - concatenated.h5ad: simple concatenation (no batch correction) - - harmony_integrated.h5ad: Harmony-corrected PCA + UMAP - - sketched.h5ad: geosketched 25% subset with projected clusters - """ - input: - annotated=expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS), - output: - concatenated=f"{OUTDIR_PP}/integrated_samples/concatenated.h5ad", - harmony=f"{OUTDIR_PP}/integrated_samples/harmony_integrated.h5ad", - sketched=f"{OUTDIR_PP}/integrated_samples/sketched.h5ad", - log: - out=f"{LOGDIR}/integrate_samples/integrate.out", - err=f"{LOGDIR}/integrate_samples/integrate.err", - benchmark: - f"{LOGDIR}/benchmarks/integrate_samples/integrate.tsv" - conda: - "../envs/visiumhd.yaml" - threads: get_resource("integrate_samples", "threads") - resources: - mem_mb=mem_mb_attempt("integrate_samples"), - runtime=get_resource("integrate_samples", "runtime"), - params: - sample_ids=SAMPLE_IDS, - n_neighbors=ANALYSIS.get("n_neighbors", 10), - sketch_fraction=ANALYSIS.get("sketch_fraction", 0.25), +rule integrate_samples: + """ + Integrate all annotated samples into a multi-sample object. + + Produces three h5ad files in integrated_samples/: + - concatenated.h5ad: simple concatenation (no batch correction) + - harmony_integrated.h5ad: Harmony-corrected PCA + UMAP + - sketched.h5ad: geosketched 25% subset with projected clusters + """ + input: + annotated=expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS), + output: + concatenated=f"{OUTDIR_PP}/integrated_samples/concatenated.h5ad", + harmony=f"{OUTDIR_PP}/integrated_samples/harmony_integrated.h5ad", + sketched=f"{OUTDIR_PP}/integrated_samples/sketched.h5ad", + log: + out=f"{LOGDIR}/integrate_samples/integrate.out", + err=f"{LOGDIR}/integrate_samples/integrate.err", + benchmark: + f"{LOGDIR}/benchmarks/integrate_samples/integrate.tsv" + conda: + "../envs/visiumhd.yaml" + threads: get_resource("integrate_samples", "threads") + resources: + mem_mb=mem_mb_attempt("integrate_samples"), + runtime=get_resource("integrate_samples", "runtime"), + params: + sample_ids=SAMPLE_IDS, + n_neighbors=ANALYSIS.get("n_neighbors", 10), + sketch_fraction=ANALYSIS.get("sketch_fraction", 0.25), random_seed=RANDOM_SEED, + primary_annotation_column=DEFAULT_ANNOT_COL, annotation_colors=config.get("annotation_colors", {}), - region_colors=ANALYSIS.get("region_colors", {}), - dpi=ANALYSIS.get("plot_dpi", 300), - niche_column=GENE_EXPLORATION.get("niche_column", ""), - extra_annot_columns=EXTRA_ANNOT_COLUMNS, - sample_colors=SAMPLE_COLORS, - integrate_key=INTEGRATE_KEY, - script: - "../scripts/integrate_samples.py" + region_colors=ANALYSIS.get("region_colors", {}), + dpi=ANALYSIS.get("plot_dpi", 300), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + niche_column=GENE_EXPLORATION.get("niche_column", ""), + extra_annot_columns=EXTRA_ANNOT_COLUMNS, + sample_colors=SAMPLE_COLORS, + integrate_key=INTEGRATE_KEY, + script: + "../scripts/integrate_samples.py" diff --git a/workflow/rules/leiden_analysis.smk b/workflow/rules/leiden_analysis.smk index 8081868..0acee35 100644 --- a/workflow/rules/leiden_analysis.smk +++ b/workflow/rules/leiden_analysis.smk @@ -8,7 +8,9 @@ rule leiden_analysis: """ input: adata=rules.preprocess_umap.output.adata, - cell_markers=config["snakemake_cell_markers"], + cell_markers=config.get( + "snakemake_cell_markers", "config/snakemake_cell_markers.tsv" + ), output: res_dir=directory(f"{SAMPLES_DIR}/{{sample}}/leiden_resolution_{{resolution}}"), log: @@ -31,5 +33,7 @@ rule leiden_analysis: resolution_scan_step=ANALYSIS.get("resolution_scan_step", 0.1), annotation_colors=config.get("annotation_colors", {}), region_colors=ANALYSIS.get("region_colors", {}), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), script: "../scripts/leiden_analysis.py" diff --git a/workflow/rules/neighbourhood_analysis.smk b/workflow/rules/neighbourhood_analysis.smk index 9845add..82b3a83 100644 --- a/workflow/rules/neighbourhood_analysis.smk +++ b/workflow/rules/neighbourhood_analysis.smk @@ -1,29 +1,25 @@ rule neighbourhood_analysis: - """ - Spatial neighbourhood analysis, run for both annotation types. - - The {annot_type} wildcard is either 'tsv_annotation' or 'refined_annotation'. - """ - input: - adata=rules.annotate_cells.output.adata_annot, - output: - results_dir=directory( - f"{SAMPLES_DIR}/{{sample}}/neighbourhood_analysis/{{annot_type}}" - ), - log: - out=f"{LOGDIR}/neighbourhood_analysis/{{sample}}_{{annot_type}}.out", - err=f"{LOGDIR}/neighbourhood_analysis/{{sample}}_{{annot_type}}.err", - benchmark: - f"{LOGDIR}/benchmarks/neighbourhood_analysis/{{sample}}_{{annot_type}}.tsv" - conda: - "../envs/visiumhd.yaml" - threads: get_resource("neighbourhood_analysis", "threads") - resources: - mem_mb=mem_mb_attempt("neighbourhood_analysis"), - runtime=get_resource("neighbourhood_analysis", "runtime"), - params: - sample_id=lambda wc: wc.sample, - annot_type=lambda wc: wc.annot_type, - annotation_colors=config.get("annotation_colors", {}), - script: - "../scripts/neighbourhood_analysis.py" + """Spatial neighbourhood analysis for the configured annotation types.""" + input: + adata=rules.annotate_cells.output.adata_annot, + output: + results_dir=directory( + f"{SAMPLES_DIR}/{{sample}}/neighbourhood_analysis/{{annot_type}}" + ), + log: + out=f"{LOGDIR}/neighbourhood_analysis/{{sample}}_{{annot_type}}.out", + err=f"{LOGDIR}/neighbourhood_analysis/{{sample}}_{{annot_type}}.err", + benchmark: + f"{LOGDIR}/benchmarks/neighbourhood_analysis/{{sample}}_{{annot_type}}.tsv" + conda: + "../envs/visiumhd.yaml" + threads: get_resource("neighbourhood_analysis", "threads") + resources: + mem_mb=mem_mb_attempt("neighbourhood_analysis"), + runtime=get_resource("neighbourhood_analysis", "runtime"), + params: + sample_id=lambda wc: wc.sample, + annot_type=lambda wc: wc.annot_type, + annotation_colors=config.get("annotation_colors", {}), + script: + "../scripts/neighbourhood_analysis.py" diff --git a/workflow/rules/prepare_input_mer.smk b/workflow/rules/prepare_input_mer.smk index d3829df..6403a2a 100644 --- a/workflow/rules/prepare_input_mer.smk +++ b/workflow/rules/prepare_input_mer.smk @@ -8,6 +8,8 @@ rule prepare_input_mer: input: qupath_meta=rules.generate_qupath_mer.output.qupath_meta, # px<->µm mapping + p0 background=rules.generate_qupath_mer.output.background, # grey contract image + geojson=lambda wc: _find_geojson(wc.sample) or [], + geojson_validation=lambda wc: _geojson_validation_report(wc.sample), output: h5ad=config["contract"]["unfiltered_h5ad"], log: diff --git a/workflow/rules/prepare_input_vhd.smk b/workflow/rules/prepare_input_vhd.smk index b3950ac..f7d5134 100644 --- a/workflow/rules/prepare_input_vhd.smk +++ b/workflow/rules/prepare_input_vhd.smk @@ -3,6 +3,8 @@ rule prepare_input_vhd: input: sr_done=f"{OUTDIR_SR}/{{sample}}/.done", hires_png=rules.generate_qupath_vhd.output.qupath_image, + geojson=lambda wc: _find_geojson(wc.sample) or [], + geojson_validation=lambda wc: _geojson_validation_report(wc.sample), output: h5ad=config["contract"]["unfiltered_h5ad"], log: diff --git a/workflow/rules/prepare_input_x5k.smk b/workflow/rules/prepare_input_x5k.smk index f9bffc5..a54c9b3 100644 --- a/workflow/rules/prepare_input_x5k.smk +++ b/workflow/rules/prepare_input_x5k.smk @@ -7,6 +7,8 @@ rule prepare_input_x5k: input: done=rules.convert_zarr_x5k.output.done, # zarr completion marker qupath_meta=rules.generate_qupath_x5k.output.qupath_meta, # geojson px→µm scale + geojson=lambda wc: _find_geojson(wc.sample) or [], + geojson_validation=lambda wc: _geojson_validation_report(wc.sample), output: h5ad=config["contract"]["unfiltered_h5ad"], log: diff --git a/workflow/rules/preprocess_umap.smk b/workflow/rules/preprocess_umap.smk index cf057f2..44da7fe 100644 --- a/workflow/rules/preprocess_umap.smk +++ b/workflow/rules/preprocess_umap.smk @@ -35,12 +35,12 @@ rule preprocess_umap: mito_prefix=config.get("contract", {}).get("mito_prefix", ["MT-", "mt-"]), random_seed=RANDOM_SEED, use_precomputed=USE_PRECOMPUTED, - precomputed_metadata_dir=config.get("precomputed_metadata_dir", ""), external_enabled=EXTERNAL_ENABLED, external_column=(config.get("external_annotation", {}) or {}).get("column", ""), keep_unannotated=(config.get("external_annotation", {}) or {}).get( "keep_unannotated", True ), region_colors=ANALYSIS.get("region_colors", {}), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), script: "../scripts/preprocess_umap.py" diff --git a/workflow/rules/process_geojson.smk b/workflow/rules/process_geojson.smk new file mode 100644 index 0000000..d98a1c8 --- /dev/null +++ b/workflow/rules/process_geojson.smk @@ -0,0 +1,21 @@ +rule process_geojson: + """Validate an optional region GeoJSON without modifying the source file.""" + input: + geojson=lambda wc: _find_geojson(wc.sample), + output: + report=f"{SAMPLES_DIR}/{{sample}}/validation/geojson_validation.json", + log: + out=f"{LOGDIR}/process_geojson/{{sample}}.out", + err=f"{LOGDIR}/process_geojson/{{sample}}.err", + benchmark: + f"{LOGDIR}/benchmarks/process_geojson/{{sample}}.tsv" + conda: + "../envs/visiumhd.yaml" + threads: get_resource("process_geojson", "threads") + resources: + mem_mb=mem_mb_attempt("process_geojson"), + runtime=get_resource("process_geojson", "runtime"), + params: + sample_id=lambda wc: wc.sample, + script: + "../scripts/process_geojson.py" diff --git a/workflow/rules/pseudobulk_aggregate.smk b/workflow/rules/pseudobulk_aggregate.smk index 312f2d0..710d68b 100644 --- a/workflow/rules/pseudobulk_aggregate.smk +++ b/workflow/rules/pseudobulk_aggregate.smk @@ -1,43 +1,43 @@ -rule pseudobulk_aggregate: - """ - Aggregate single cells into pseudobulk count matrices using decoupler. - - Output structure: - {annot_type}/{analysis_level}/ - ├── matrices/ count TSVs - ├── metadata/ metadata TSVs - ├── plots/ sample-level QC (pseudobulk bar, PCA) - └── manifest.tsv - """ - input: - adata=rules.integrate_samples.output.concatenated, - output: - agg_dir=directory( - f"{OUTDIR_PP}/pseudobulk/{{annot_type}}/{{analysis_level}}/aggregated" - ), - log: - out=f"{LOGDIR}/pseudobulk_aggregate/{{annot_type}}_{{analysis_level}}.out", - err=f"{LOGDIR}/pseudobulk_aggregate/{{annot_type}}_{{analysis_level}}.err", - benchmark: - f"{LOGDIR}/benchmarks/pseudobulk_aggregate/{{annot_type}}_{{analysis_level}}.tsv" +rule pseudobulk_aggregate: + """ + Aggregate single cells into pseudobulk count matrices using a named analysis. + + Output structure: + {annot_type}/{analysis_name}/aggregated/ + ├── matrices/ count TSVs + ├── metadata/ metadata TSVs + ├── plots/ sample-level QC + └── manifest.tsv + """ + input: + adata=rules.integrate_samples.output.concatenated, + output: + agg_dir=directory( + f"{OUTDIR_PP}/pseudobulk/{{annot_type}}/{{analysis_name}}/aggregated" + ), + log: + out=f"{LOGDIR}/pseudobulk_aggregate/{{annot_type}}_{{analysis_name}}.out", + err=f"{LOGDIR}/pseudobulk_aggregate/{{annot_type}}_{{analysis_name}}.err", + benchmark: + f"{LOGDIR}/benchmarks/pseudobulk_aggregate/{{annot_type}}_{{analysis_name}}.tsv" wildcard_constraints: - annot_type="tsv_annotation|ingest_annotation", - analysis_level="by_region|by_celltype_region|by_niche_region", - conda: - "../envs/pseudobulk_aggregate.yaml" - threads: get_resource("pseudobulk_aggregate", "threads") - resources: - mem_mb=mem_mb_attempt("pseudobulk_aggregate"), - runtime=get_resource("pseudobulk_aggregate", "runtime"), - params: - annot_type=lambda wc: wc.annot_type, - analysis_level=lambda wc: wc.analysis_level, - min_cells_per_pseudobulk=ANALYSIS.get("min_cells_per_pseudobulk", 10), - min_counts_per_pseudobulk=ANALYSIS.get("min_counts_per_pseudobulk", 1000), - annotation_colors=config.get("annotation_colors", {}), - region_colors=ANALYSIS.get("region_colors", {}), - dpi=ANALYSIS.get("plot_dpi", 300), - extra_annot_columns=EXTRA_ANNOT_COLUMNS, - sample_colors=SAMPLE_COLORS, - script: - "../scripts/pseudobulk_aggregate.py" + annot_type="tsv_annotation|external_annotation|ingest_annotation", + analysis_name="[A-Za-z0-9_-]+", + conda: + "../envs/pseudobulk_aggregate.yaml" + threads: get_resource("pseudobulk_aggregate", "threads") + resources: + mem_mb=mem_mb_attempt("pseudobulk_aggregate"), + runtime=get_resource("pseudobulk_aggregate", "runtime"), + params: + annot_type=lambda wc: wc.annot_type, + analysis_spec=lambda wc: pseudobulk_analysis(wc.analysis_name), + min_cells_per_pseudobulk=ANALYSIS.get("min_cells_per_pseudobulk", 10), + min_counts_per_pseudobulk=ANALYSIS.get("min_counts_per_pseudobulk", 1000), + annotation_colors=config.get("annotation_colors", {}), + region_colors=ANALYSIS.get("region_colors", {}), + dpi=ANALYSIS.get("plot_dpi", 300), + extra_annot_columns=EXTRA_ANNOT_COLUMNS, + sample_colors=SAMPLE_COLORS, + script: + "../scripts/pseudobulk_aggregate.py" diff --git a/workflow/rules/pseudobulk_de.smk b/workflow/rules/pseudobulk_de.smk index 15c5536..dadec1e 100644 --- a/workflow/rules/pseudobulk_de.smk +++ b/workflow/rules/pseudobulk_de.smk @@ -1,44 +1,61 @@ rule pseudobulk_de: - """ - DE with R DESeq2 (Wald + LRT), EnhancedVolcano, ComplexHeatmap, DEGpatterns. - Region levels and colors from config control ordering and visualization. - """ - input: - agg_dir=rules.pseudobulk_aggregate.output.agg_dir, - output: - results_dir=directory( - f"{OUTDIR_PP}/pseudobulk/{{annot_type}}/{{analysis_level}}/de_results" - ), - log: - out=f"{LOGDIR}/pseudobulk_de/{{annot_type}}_{{analysis_level}}.out", - err=f"{LOGDIR}/pseudobulk_de/{{annot_type}}_{{analysis_level}}.err", - benchmark: - f"{LOGDIR}/benchmarks/pseudobulk_de/{{annot_type}}_{{analysis_level}}.tsv" + """Explicit Wald contrasts, one-vs-rest biomarkers, and optional LRT with DESeq2.""" + input: + agg_dir=rules.pseudobulk_aggregate.output.agg_dir, + output: + results_dir=directory( + f"{OUTDIR_PP}/pseudobulk/{{annot_type}}/{{analysis_name}}/de_results" + ), + log: + out=f"{LOGDIR}/pseudobulk_de/{{annot_type}}_{{analysis_name}}.out", + err=f"{LOGDIR}/pseudobulk_de/{{annot_type}}_{{analysis_name}}.err", + benchmark: + f"{LOGDIR}/benchmarks/pseudobulk_de/{{annot_type}}_{{analysis_name}}.tsv" wildcard_constraints: - annot_type="tsv_annotation|refined_annotation|ingest_annotation", - analysis_level="by_region|by_celltype_region|by_niche_region", - conda: - "../envs/pseudobulk_de.yaml" - threads: get_resource("pseudobulk_de", "threads") - resources: - mem_mb=mem_mb_attempt("pseudobulk_de"), - runtime=get_resource("pseudobulk_de", "runtime"), - params: - annot_type=lambda wc: wc.annot_type, - analysis_level=lambda wc: wc.analysis_level, - min_replicates=ANALYSIS.get("min_replicates", 3), - de_n_genes=ANALYSIS.get("de_n_genes", 10), - padj_threshold=ANALYSIS.get("padj_threshold", 0.05), - lfc_threshold=ANALYSIS.get("lfc_threshold", 0.5), - region_levels=REGION_LEVELS, - # Pass colors as two parallel lists for safe Python→R conversion - region_color_names=list(ANALYSIS.get("region_colors", {}).keys()), - region_color_values=list(ANALYSIS.get("region_colors", {}).values()), - # Design columns to annotate heatmaps with (chosen via config design.columns); - # palettes flattened into parallel lists (grey fallback applied in R). - extra_annot_columns=EXTRA_ANNOT_COLUMNS, - extra_anno_col_names=EXTRA_ANNO_COLS, - extra_anno_values=EXTRA_ANNO_VALS, - extra_anno_colors=EXTRA_ANNO_COLORS, - script: - "../scripts/pseudobulk_de.R" + annot_type="tsv_annotation|external_annotation|ingest_annotation", + analysis_name="[A-Za-z0-9_-]+", + conda: + "../envs/pseudobulk_de.yaml" + threads: get_resource("pseudobulk_de", "threads") + resources: + mem_mb=mem_mb_attempt("pseudobulk_de"), + runtime=get_resource("pseudobulk_de", "runtime"), + params: + analysis_name=lambda wc: wc.analysis_name, + min_replicates=ANALYSIS.get("min_replicates", 3), + de_n_genes=ANALYSIS.get("de_n_genes", 10), + padj_threshold=ANALYSIS.get("padj_threshold", 0.05), + lfc_threshold=ANALYSIS.get("lfc_threshold", 0.5), + dpi=ANALYSIS.get("plot_dpi", 300), + group_by_columns=lambda wc: pseudobulk_analysis(wc.analysis_name)["group_by"], + paired_by=lambda wc: pseudobulk_analysis(wc.analysis_name)["paired_by"], + allow_repeated_unpaired=lambda wc: pseudobulk_analysis(wc.analysis_name)[ + "allow_repeated_unpaired" + ], + covariates=lambda wc: pseudobulk_analysis(wc.analysis_name)["covariates"], + contrast_names=lambda wc: pseudobulk_contrast_param(wc.analysis_name, "names"), + contrast_numerators=lambda wc: pseudobulk_contrast_param( + wc.analysis_name, "numerator" + ), + contrast_denominators=lambda wc: pseudobulk_contrast_param( + wc.analysis_name, "denominator" + ), + one_vs_rest_enabled=lambda wc: bool( + pseudobulk_analysis(wc.analysis_name)["one_vs_rest"].get("enabled", False) + ), + lrt_enabled=lambda wc: bool( + pseudobulk_analysis(wc.analysis_name)["lrt"].get("enabled", False) + ), + group_color_names=lambda wc: list( + pseudobulk_group_palette(wc.analysis_name).keys() + ), + group_color_values=lambda wc: list( + pseudobulk_group_palette(wc.analysis_name).values() + ), + condition_level_order=lambda wc: pseudobulk_condition_order(wc.analysis_name), + extra_annot_columns=EXTRA_ANNOT_COLUMNS, + extra_anno_col_names=EXTRA_ANNO_COLS, + extra_anno_values=EXTRA_ANNO_VALS, + extra_anno_colors=EXTRA_ANNO_COLORS, + script: + "../scripts/pseudobulk_de.R" diff --git a/workflow/rules/qc_sweep.smk b/workflow/rules/qc_sweep.smk index 8e77c07..081a7c3 100644 --- a/workflow/rules/qc_sweep.smk +++ b/workflow/rules/qc_sweep.smk @@ -42,5 +42,6 @@ rule qc_sweep: ref_label_key=config.get("ingest_ref_label_key", "cell_type"), region_colors=config.get("analysis", {}).get("region_colors", {}), region_levels=config.get("analysis", {}).get("region_levels", []), + spatial_point_size=config.get("analysis", {}).get("spatial_point_size", 20), script: "../scripts/qc_sweep.py" diff --git a/workflow/rules/sample_report.smk b/workflow/rules/sample_report.smk index d8868c0..b3a97c5 100644 --- a/workflow/rules/sample_report.smk +++ b/workflow/rules/sample_report.smk @@ -1,31 +1,34 @@ -rule sample_report: - """ - Generate a multi-page PDF report with separate pages per sample. - - Each page contains: (1) UMAP (clusters + annotation), spatial plot - (clusters + annotation), (2-3) barplots with cell proportions per region, cluster and niche, - and (4) a dotplot with the top markers per cell type. - """ - input: - annotated=expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS), - output: - report=f"{OUTDIR_PP}/integrated_samples/samples_report.pdf", - log: - out=f"{LOGDIR}/sample_report/sample_report.out", - err=f"{LOGDIR}/sample_report/sample_report.err", - benchmark: - f"{LOGDIR}/benchmarks/sample_report/sample_report.tsv" - conda: - "../envs/visiumhd.yaml" - threads: get_resource("sample_report", "threads") - resources: - mem_mb=mem_mb_attempt("sample_report"), - runtime=get_resource("sample_report", "runtime"), - params: - sample_ids=SAMPLE_IDS, - annotation_colors=config.get("annotation_colors", {}), - region_colors=ANALYSIS.get("region_colors", {}), - dpi=ANALYSIS.get("plot_dpi", 300), - niche_column=GENE_EXPLORATION.get("niche_column", ""), - script: - "../scripts/sample_report.py" +rule sample_report: + """ + Generate a multi-page PDF report with separate pages per sample. + + Each sample contains UMAP/spatial panels for every available annotation, + plus niche/region summaries, primary-annotation composition, and a marker + dotplot. External annotation is prioritised over ingest when configured. + """ + input: + annotated=expand(rules.annotate_cells.output.adata_annot, sample=SAMPLE_IDS), + output: + report=f"{OUTDIR_PP}/integrated_samples/samples_report.pdf", + log: + out=f"{LOGDIR}/sample_report/sample_report.out", + err=f"{LOGDIR}/sample_report/sample_report.err", + benchmark: + f"{LOGDIR}/benchmarks/sample_report/sample_report.tsv" + conda: + "../envs/visiumhd.yaml" + threads: get_resource("sample_report", "threads") + resources: + mem_mb=mem_mb_attempt("sample_report"), + runtime=get_resource("sample_report", "runtime"), + params: + sample_ids=SAMPLE_IDS, + primary_annotation_column=DEFAULT_ANNOT_COL, + annotation_colors=config.get("annotation_colors", {}), + region_colors=ANALYSIS.get("region_colors", {}), + dpi=ANALYSIS.get("plot_dpi", 300), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), + niche_column=GENE_EXPLORATION.get("niche_column", ""), + script: + "../scripts/sample_report.py" diff --git a/workflow/rules/spatial_niches.smk b/workflow/rules/spatial_niches.smk index 60258c6..6e2cc8e 100644 --- a/workflow/rules/spatial_niches.smk +++ b/workflow/rules/spatial_niches.smk @@ -41,10 +41,10 @@ rule spatial_niches: use_hvg=SPATIAL_NICHES.get("use_hvg", False), n_top_genes=SPATIAL_NICHES.get("n_top_genes", ANALYSIS.get("n_top_genes", 2000)), n_pcs=SPATIAL_NICHES.get("n_pcs", ANALYSIS.get("n_pcs", 30)), - niche_resolution=SPATIAL_NICHES.get("niche_resolution", 0.3), + niche_resolution=SPATIAL_NICHES.get("niche_resolution", 0.1), compute_umap=SPATIAL_NICHES.get("compute_umap", False), - cluster_n_neighbours=SPATIAL_NICHES.get("cluster_n_neighbours", 15), - n_iterations=SPATIAL_NICHES.get("n_iterations", 2), + cluster_n_neighbours=SPATIAL_NICHES.get("cluster_n_neighbours", 50), + n_iterations=SPATIAL_NICHES.get("n_iterations", -1), use_precomputed=SPATIAL_NICHES.get("use_precomputed", False), niche_dir=SPATIAL_NICHES.get("niche_dir", ""), refine=SPATIAL_NICHES.get("refine", False), @@ -54,5 +54,7 @@ rule spatial_niches: annotation_colors=config.get("annotation_colors", {}), region_colors=ANALYSIS.get("region_colors", {}), dpi=ANALYSIS.get("plot_dpi", 300), + umap_point_size=ANALYSIS.get("umap_point_size", 2), + spatial_point_size=ANALYSIS.get("spatial_point_size", 20), script: "../scripts/spatial_niches.py" diff --git a/workflow/rules/subcluster.smk b/workflow/rules/subcluster.smk index d0f0e01..2cca051 100644 --- a/workflow/rules/subcluster.smk +++ b/workflow/rules/subcluster.smk @@ -46,6 +46,7 @@ rule subcluster: region_colors=ANALYSIS.get("region_colors", {}), region_levels=REGION_LEVELS, dpi=ANALYSIS.get("plot_dpi", 300), + umap_point_size=ANALYSIS.get("umap_point_size", 2), niche_column=GENE_EXPLORATION.get("niche_column", ""), extra_annot_columns=EXTRA_ANNOT_COLUMNS, sample_colors=SAMPLE_COLORS, diff --git a/workflow/rules/validate_input.smk b/workflow/rules/validate_input.smk index f16b614..952fc18 100644 --- a/workflow/rules/validate_input.smk +++ b/workflow/rules/validate_input.smk @@ -31,6 +31,7 @@ rule validate_input: """Assert the unfiltered h5ad satisfies the core contract; record image presence.""" input: h5ad=lambda wc: _CONTRACT["unfiltered_h5ad"].format(sample=wc.sample), + external_metadata=_external_metadata_input, output: report=f"{_OUT}/{{sample}}/validation/input_validation.json", log: @@ -51,6 +52,5 @@ rule validate_input: mito_prefix=_CONTRACT.get("mito_prefix", ["MT-", "mt-"]), external_enabled=EXTERNAL_ENABLED, external_column=(config.get("external_annotation", {}) or {}).get("column", ""), - external_meta_dir=config.get("precomputed_metadata_dir", ""), script: "../scripts/validate_input.py" diff --git a/workflow/schemas/config.schema.yaml b/workflow/schemas/config.schema.yaml index 3dcdb33..9147b57 100644 --- a/workflow/schemas/config.schema.yaml +++ b/workflow/schemas/config.schema.yaml @@ -1,557 +1,760 @@ -$schema: "https://json-schema.org/draft/2020-12/schema" -description: Configuration schema for the SpaceBlocks spatial-transcriptomics workflow -type: object -# Strict: unknown top-level keys are rejected (catches typos). The Snakefile injects -# `contract.unfiltered_h5ad` and top-level `outdir` AFTER validation, so those two are -# intentionally absent here. -additionalProperties: false - -required: -- snakemake_cell_markers -- geojson_path -- spaceranger_processing_outdir -- post_processing_outdir -- logdir -- resources -properties: - samples: - type: string - description: Path to the Visium HD head sample sheet (fastq dirs / slide / area per sample; mode visiumhd). - snakemake_cell_markers: - type: string - description: TSV of canonical marker genes drawn on the Leiden diagnostic plots to guide manual annotation. - probe_set: - type: string - description: Path to the 10x probe-set CSV needed to run Space Ranger (mode visiumhd). - transcriptome: - type: string - description: Path to the Space Ranger reference transcriptome (mode visiumhd). - spaceranger: - type: string - description: Path to the locally installed spaceranger executable - - xenium5k: - type: ["object", "null"] - description: Xenium 5K head settings (used only when mode == "xenium5k"). - properties: - xenium_dir: - type: string - description: "{sample} pattern to each Xenium output bundle." - zarr_dir: - type: string - description: Where {sample}.zarr stores are written ("" → spaceranger_processing_outdir). - qupath_pyramid_level: - type: integer - minimum: 0 - description: Pyramid level of the morphology image used for the QuPath composite TIFF. - default: 3 - hires_pyramid_level: - type: integer - minimum: 0 - description: Pyramid level embedded as the uns['spatial'] background image. - default: 3 - pixel_size_um: - type: number - exclusiveMinimum: 0 - - description: Xenium pixel size in microns. - default: 0.2125 - - atera: - type: ["object", "null"] - description: >- - Atera head settings (used only when mode == "atera"). ALPHA support — validated - against the 10x WTA preview bundle, whose format is not final. - properties: - atera_dir: - type: string - description: "{sample} pattern to each Atera outs/ bundle." - zarr_dir: - type: string - description: Where {sample}.zarr stores are written ("" -> spaceranger_processing_outdir). - qupath_pyramid_level: - type: integer - minimum: 0 - description: Pyramid level of the morphology image used for the QuPath composite TIFF. - default: 3 - hires_pyramid_level: - type: integer - minimum: 0 - description: >- - Pyramid level embedded as the uns["spatial"] background image. Also the grid - the registered H&E background is resampled onto, when one is configured. - default: 3 - pixel_size_um: - type: number - exclusiveMinimum: 0 - description: Microns per full-resolution morphology pixel (experiment.xenium pixel_size). - default: 0.2125 - he_image: - type: string - description: >- - OPTIONAL absolute {sample} pattern to the registered H&E OME-TIFF, which ships - separately from the outs/ bundle. When set together with he_alignment, a second - QuPath image is produced, region annotations may be drawn on either image, and - the H&E becomes the embedded contract background. - he_alignment: - type: string - description: >- - OPTIONAL absolute {sample} pattern to the 10x 3x3 he_alignment.csv mapping - full-resolution H&E pixels onto full-resolution morphology pixels. Required - whenever he_image is set. - he_keypoints: - type: string - description: >- - OPTIONAL absolute {sample} pattern to the 10x keypoints.csv. Not required, but - without it the H&E transform is applied unverified. - he_pyramid_level: - type: integer - minimum: 0 - description: Pyramid level of the H&E read for the QuPath TIFF and the background warp. - default: 4 - he_residual_warn_px: - type: number - exclusiveMinimum: 0 - description: >- - Warn when the largest keypoint residual exceeds this many morphology pixels — - the tripwire for a changed alignment convention. - default: 50 - - merscope: - type: ["object", "null"] - description: MERSCOPE (Vizgen MERFISH) head settings (used only when mode == "merscope"). - properties: - merscope_dir: - type: string - description: "{sample} pattern to each MERSCOPE region directory (holds the two Vizgen CSVs and the images/ folder)." - z_index: - type: integer - minimum: 0 - description: Mosaic z-plane used for the QuPath composite and embedded background (DAPI/PolyT always present). - default: 3 - hires_pixel_size_um: - type: number - exclusiveMinimum: 0 - description: Target microns-per-pixel of the downsampled background embedded in uns["spatial"] and annotated in QuPath. - default: 1.0 - channels: - type: array - items: - type: string - description: Mosaic stains to composite, in order; channels absent on disk are skipped (default DAPI, PolyT, Cellbound1-3). - - cluster_annotations: - type: string - description: >- - Optional path to a TSV with cluster-to-cell-type annotations. - Rows are cluster numbers, columns are sample names, values are - cell-type labels. If missing or empty, DE and neighbourhood - analyses are skipped. - - per_sample_qc: - type: string - description: >- - Optional path to a TSV of per-sample QC filtering thresholds for - preprocess_umap. First column = sample name; remaining columns any of - min_counts, min_genes, min_cells, max_counts, max_pct_mt. Present values - override the analysis.* defaults per sample, key-by-key; missing - samples/columns fall back to config. Empty string disables overrides. - - mode: - type: string - enum: ["visiumhd", "xenium5k", "atera", "merscope", "decoupled"] - default: "visiumhd" - description: >- - A HEAD technology name (that head builds the contract h5ads in this run) or - "decoupled" (the core consumes pre-existing contracts from contract_dir; head - rules skipped; validation aborts if any core_samples sample is missing its - contract). Extend the enum as new heads are added (e.g. xenium5k). "atera" is - ALPHA support: the platform ships in H2 2026 and the public data is a preview - whose output format 10x state will change at commercial release. - - core_samples: - type: string - description: >- - Optional path to the CORE sample sheet (TSV; required column `sample` plus - optional experimental-design columns). Authoritative sample list for the - core; empty = derive from the head visiumhd_samples.csv. The `sample` value need not - match obs["sample"] in the h5ad (samples may be renamed during preparation). - - contract_dir: - type: string - description: >- - Directory the core reads contract h5ads from. Empty = the per-sample output - tree. In decoupled mode, set to the head's output location. - - sample_colors: - type: ["object", "null"] - description: >- - Optional colour palettes for core-sheet columns: column -> {value -> hex}. - additionalProperties: - type: object - additionalProperties: - type: string - - integration: - type: ["object", "null"] - description: Integration settings for integrate_samples. - properties: - integrate_key: - type: string - description: obs column Harmony corrects on ("sample" = per-sample). - - extra_annotations: - type: ["object", "null"] - description: >- - core_samples.tsv columns to surface downstream as UMAP colourings, pseudobulk - QC panels, and DE heatmap annotation tracks. Annotation-only (no DESeq2 covariate). - properties: - columns: - type: array - items: - type: string - description: Columns to plot/annotate ([] = all non-sample columns). - - geojson_path: - type: string - description: Directory of per-sample QuPath region-annotation GeoJSONs. - spaceranger_processing_outdir: - type: string - description: Directory where the head writes heavy intermediates (Space Ranger tree / SpatialData zarr). - post_processing_outdir: - type: string - description: Analysis output root; holds the result folders for the run. - logdir: - type: string - - description: Directory for per-rule logs and benchmarks. - analysis: - type: object - properties: - min_counts: - type: integer - minimum: 1 - default: 1 - description: Minimum total counts per cell (QC filter). - min_cells: - type: integer - minimum: 1 - default: 3 - description: Minimum number of cells a gene must be detected in (QC filter). - min_genes: - type: integer - minimum: 1 - default: 100 - description: Minimum number of genes per cell (QC filter). - max_counts: - type: integer - minimum: 1 - description: Optional upper bound on total counts per cell (omit to disable). - max_pct_mt: - type: number - minimum: 0 - maximum: 100 - description: Optional upper bound on percent mitochondrial counts (omit to disable). - n_top_genes: - type: integer - minimum: 100 - default: 5000 - description: Number of highly variable genes to select. - n_neighbors: - type: integer - minimum: 2 - default: 10 - description: Number of neighbours for the kNN graph. - n_pcs: - type: integer - minimum: 2 - default: 30 - description: Number of principal components used to build the neighbours graph. - leiden_resolution: - type: number - exclusiveMinimum: 0 - default: 0.6 - description: Single Leiden resolution used when not scanning a range. - resolution_scan_min: - type: number - exclusiveMinimum: 0 - default: 0.8 - description: Lowest Leiden resolution in the multi-resolution scan. - resolution_scan_max: - type: number - exclusiveMinimum: 0 - default: 1.2 - description: Highest Leiden resolution in the multi-resolution scan. - resolution_scan_step: - type: number - exclusiveMinimum: 0 - default: 0.2 - description: Step between Leiden resolutions in the scan. - de_n_genes: - type: integer - minimum: 1 - default: 10 - description: Number of top differential-expression marker genes reported per group. - run_pseudobulk_de: - type: boolean - default: false - description: Gate the optional R/DESeq2 pseudobulk_de step. - run_leiden_analysis: - type: boolean - default: true - description: >- - Produce the per-resolution Leiden diagnostic plots. Optional (clustering - itself happens in preprocess_umap); set false to skip, e.g. when overlaying - external annotation. - analysis_levels: - type: array - items: - type: string - description: Pseudobulk analysis levels (e.g. by_region, by_celltype_region). - region_levels: - type: array - items: - type: string - description: Ordered region names (must match region_annotation values in the data). - region_colors: - type: object - additionalProperties: - type: string - description: region name -> hex colour. - plot_dpi: - type: integer - minimum: 1 - description: Default figure DPI. - - ingest_ref: - type: string - description: >- - Optional reference h5ad for scanpy label transfer (ingest_ref rule) and the - qc_sweep cell-type overlay. Empty string disables ingest. - - ingest_ref_label_key: - type: string - description: obs column in the ingest reference holding the cell-type label. - +$schema: "https://json-schema.org/draft/2020-12/schema" +description: Configuration schema for the SpaceBlocks spatial-transcriptomics workflow +type: object +# Strict: unknown top-level keys are rejected (catches typos). The Snakefile injects +# `contract.unfiltered_h5ad` and top-level `outdir` AFTER validation, so those two are +# intentionally absent here. +additionalProperties: false + +required: +- snakemake_cell_markers +- geojson_path +- spaceranger_processing_outdir +- post_processing_outdir +- logdir +- resources +properties: + samples: + type: string + description: Path to the Visium HD head sample sheet (fastq dirs / slide / area per sample; mode visiumhd). + snakemake_cell_markers: + type: string + description: TSV of canonical marker genes drawn on the Leiden diagnostic plots to guide manual annotation. + probe_set: + type: string + description: Path to the 10x probe-set CSV needed to run Space Ranger (mode visiumhd). + transcriptome: + type: string + description: Path to the Space Ranger reference transcriptome (mode visiumhd). + spaceranger: + type: string + description: Path to the locally installed spaceranger executable + + xenium5k: + type: ["object", "null"] + description: Xenium 5K head settings (used only when mode == "xenium5k"). + properties: + xenium_dir: + type: string + description: "{sample} pattern to each Xenium output bundle." + qupath_pyramid_level: + type: integer + minimum: 0 + description: Pyramid level of the morphology image used for the QuPath composite TIFF. + default: 3 + hires_pyramid_level: + type: integer + minimum: 0 + description: Pyramid level embedded as the uns['spatial'] background image. + default: 3 + pixel_size_um: + type: number + exclusiveMinimum: 0 + + description: Xenium pixel size in microns. + default: 0.2125 + + atera: + type: ["object", "null"] + description: >- + Atera head settings (used only when mode == "atera"). ALPHA support — validated + against the 10x WTA preview bundle, whose format is not final. + properties: + atera_dir: + type: string + description: "{sample} pattern to each Atera outs/ bundle." + qupath_pyramid_level: + type: integer + minimum: 0 + description: Pyramid level of the morphology image used for the QuPath composite TIFF. + default: 3 + hires_pyramid_level: + type: integer + minimum: 0 + description: >- + Pyramid level embedded as the uns["spatial"] background image. Also the grid + the registered H&E background is resampled onto, when one is configured. + default: 3 + pixel_size_um: + type: number + exclusiveMinimum: 0 + description: Microns per full-resolution morphology pixel (experiment.xenium pixel_size). + default: 0.2125 + he_image: + type: string + description: >- + OPTIONAL absolute {sample} pattern to the registered H&E OME-TIFF, which ships + separately from the outs/ bundle. When set together with he_alignment, a second + QuPath image is produced, region annotations may be drawn on either image, and + the H&E becomes the embedded contract background. + he_alignment: + type: string + description: >- + OPTIONAL absolute {sample} pattern to the 10x 3x3 he_alignment.csv mapping + full-resolution H&E pixels onto full-resolution morphology pixels. Required + whenever he_image is set. + he_keypoints: + type: string + description: >- + OPTIONAL absolute {sample} pattern to the 10x keypoints.csv. Not required, but + without it the H&E transform is applied unverified. + he_pyramid_level: + type: integer + minimum: 0 + description: Pyramid level of the H&E read for the QuPath TIFF and the background warp. + default: 4 + he_residual_warn_px: + type: number + exclusiveMinimum: 0 + description: >- + Warn when the largest keypoint residual exceeds this many morphology pixels — + the tripwire for a changed alignment convention. + default: 50 + + merscope: + type: ["object", "null"] + description: MERSCOPE (Vizgen MERFISH) head settings (used only when mode == "merscope"). + properties: + merscope_dir: + type: string + description: "{sample} pattern to each MERSCOPE region directory (holds the two Vizgen CSVs and the images/ folder)." + z_index: + type: integer + minimum: 0 + description: Mosaic z-plane used for the QuPath composite and embedded background (DAPI/PolyT always present). + default: 3 + hires_pixel_size_um: + type: number + exclusiveMinimum: 0 + description: Target microns-per-pixel of the downsampled background embedded in uns["spatial"] and annotated in QuPath. + default: 1.0 + channels: + type: array + items: + type: string + description: Mosaic stains to composite, in order; channels absent on disk are skipped (default DAPI, PolyT, Cellbound1-3). + + cluster_annotations: + type: string + description: >- + Optional path to a TSV with cluster-to-cell-type annotations. + Rows are cluster numbers, columns are sample names, values are + cell-type labels. If missing or empty, DE and neighbourhood + analyses are skipped. + + per_sample_qc: + type: string + description: >- + Optional path to a TSV of per-sample QC filtering thresholds for + preprocess_umap. First column = sample name; remaining columns any of + min_counts, min_genes, min_cells, max_counts, max_pct_mt. Present values + override the analysis.* defaults per sample, key-by-key; missing + samples/columns fall back to config. Empty string disables overrides. + + base_dir: + type: string + description: >- + Base project directory used by config path templates (for example, + "{base_dir}/output"). + + mode: + type: string + enum: ["visiumhd", "xenium5k", "atera", "merscope", "decoupled"] + default: "decoupled" + description: >- + A HEAD technology name (that head builds the contract h5ads in this run) or + "decoupled" (the core consumes pre-existing contracts from contract_dir; head + rules are skipped; Snakemake reports any missing configured contracts while + building the DAG). Extend the enum as new heads are added (e.g. xenium5k). + "atera" is ALPHA support: the platform ships in H2 2026 and the public data is + a preview whose output format 10x state will change at commercial release. + + core_samples: + type: string + description: >- + Optional path to the CORE sample sheet (TSV; required column `sample` plus + optional experimental-design columns). Authoritative sample list for the + core; empty = derive from the head visiumhd_samples.csv. The `sample` value need not + match obs["sample"] in the h5ad (samples may be renamed during preparation). + + contract_dir: + type: string + description: >- + Directory the core reads contract h5ads from. Empty = the per-sample output + tree. In decoupled mode, set to the head's output location. + + sample_colors: + type: ["object", "null"] + description: >- + Optional colour palettes for core-sheet columns: column -> {value -> hex}. + additionalProperties: + type: object + additionalProperties: + type: string + + integration: + type: ["object", "null"] + description: Integration settings for integrate_samples. + properties: + integrate_key: + type: string + description: obs column Harmony corrects on ("sample" = per-sample). + + extra_annotations: + type: ["object", "null"] + description: >- + core_samples.tsv columns to surface downstream as UMAP colourings, pseudobulk + QC panels, and DE heatmap annotation tracks. This controls plotting only; + sample-sheet columns may independently be selected as pseudobulk covariates. + properties: + columns: + type: array + items: + type: string + description: Columns to plot/annotate ([] = all non-sample columns). + + geojson_path: + type: string + description: Directory of per-sample QuPath region-annotation GeoJSONs. + spaceranger_processing_outdir: + type: string + description: Directory where the head writes heavy intermediates (Space Ranger tree / SpatialData zarr). + post_processing_outdir: + type: string + description: Analysis output root; holds the result folders for the run. + logdir: + type: string + + description: Directory for per-rule logs and benchmarks. + analysis: + type: object + properties: + min_counts: + type: integer + minimum: 1 + default: 1 + description: Minimum total counts per cell (QC filter). + min_cells: + type: integer + minimum: 1 + default: 3 + description: Minimum number of cells a gene must be detected in (QC filter). + min_genes: + type: integer + minimum: 1 + default: 100 + description: Minimum number of genes per cell (QC filter). + max_counts: + type: integer + minimum: 1 + description: Optional upper bound on total counts per cell (omit to disable). + max_pct_mt: + type: number + minimum: 0 + maximum: 100 + description: Optional upper bound on percent mitochondrial counts (omit to disable). + n_top_genes: + type: integer + minimum: 100 + default: 5000 + description: Number of highly variable genes to select. + n_neighbors: + type: integer + minimum: 2 + default: 10 + description: Number of neighbours for the kNN graph. + n_pcs: + type: integer + minimum: 2 + default: 30 + description: Number of principal components used to build the neighbours graph. + leiden_resolution: + type: number + exclusiveMinimum: 0 + default: 0.6 + description: Single Leiden resolution used when not scanning a range. + resolution_scan_min: + type: number + exclusiveMinimum: 0 + default: 0.8 + description: Lowest Leiden resolution in the multi-resolution scan. + resolution_scan_max: + type: number + exclusiveMinimum: 0 + default: 1.2 + description: Highest Leiden resolution in the multi-resolution scan. + resolution_scan_step: + type: number + exclusiveMinimum: 0 + default: 0.2 + description: Step between Leiden resolutions in the scan. + de_n_genes: + type: integer + minimum: 1 + default: 10 + description: Number of top differential-expression marker genes reported per group. + run_pseudobulk_de: + type: boolean + default: false + description: Gate the optional R/DESeq2 pseudobulk_de step. + min_cells_per_pseudobulk: + type: integer + minimum: 1 + default: 10 + description: Minimum cells contributing to one retained pseudobulk observation. + min_counts_per_pseudobulk: + type: integer + minimum: 1 + default: 1000 + description: Minimum summed raw counts in one retained pseudobulk observation. + min_replicates: + type: integer + minimum: 2 + default: 3 + description: >- + Minimum independent samples per unpaired level, or complete pairs when + paired_by is configured, required to run a contrast or include an LRT level. + padj_threshold: + type: number + exclusiveMinimum: 0 + maximum: 1 + default: 0.05 + lfc_threshold: + type: number + minimum: 0 + default: 0.5 + run_leiden_analysis: + type: boolean + default: true + description: >- + Produce the per-resolution Leiden diagnostic plots. Optional (clustering + itself happens in preprocess_umap); set false to skip, e.g. when overlaying + external annotation. + analysis_levels: + type: array + items: + type: string + enum: ["by_region", "by_celltype_region"] + description: >- + Deprecated region-only pseudobulk configuration retained for older + configs. New configs should use analysis.pseudobulk.analyses. + region_levels: + type: array + items: + type: string + description: >- + Optional preferred first-to-last display order for region names in QC-sweep, + subcluster, and region-only pseudobulk split heatmaps. This does not filter + pseudobulk observations; use pseudobulk analyses' exclude_levels instead. + region_colors: + type: object + additionalProperties: + type: string + description: region name -> hex colour. + pseudobulk: + type: object + additionalProperties: false + description: >- + Named pseudobulk aggregations and DE models. Each analysis defines how + cells are pooled, which sample metadata form the comparison group, + exclusions, explicit Wald contrasts, optional one-vs-rest biomarkers, + and an optional omnibus LRT. + required: [analyses] + properties: + analyses: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: [name, aggregation, group_by] + properties: + name: + type: string + pattern: "^[A-Za-z0-9_-]+$" + description: Stable analysis identifier used in output paths. + aggregation: + type: string + enum: ["all_cells", "by_celltype", "by_region", "by_celltype_region"] + group_by: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + description: >- + One or more obs/core_samples columns whose values define the + comparison groups. Multiple columns form a combined group. + condition_order: + type: array + uniqueItems: true + default: [] + items: + type: string + minLength: 1 + description: >- + Preferred first-to-last order of comparison-group labels in + heatmaps, one-vs-rest outputs, and LRT/DEGpatterns plots. + Unlisted observed levels are appended after configured levels. + For multiple group_by columns, use the combined labels shown in + the output metadata (for example, "condition=A | batch=B"). + paired_by: + type: string + default: "" + description: >- + Optional matched-unit column, normally sample for comparisons + between regions from the same sample. Empty means unpaired. + allow_repeated_unpaired: + type: boolean + default: false + description: >- + Explicitly allow an unpaired analysis when one sample contributes + multiple comparison levels. This treats repeated sample-level + pseudobulks as independent and may underestimate standard errors. + Cannot be combined with paired_by. + covariates: + type: array + uniqueItems: true + default: [] + items: + type: string + description: Categorical sample metadata adjusted for before group_by. + exclude_levels: + type: object + default: {} + additionalProperties: + type: array + uniqueItems: true + items: + type: string + description: >- + Column -> levels removed before aggregation. The same exclusion + applies to explicit Wald contrasts, one-vs-rest tests, and the + optional LRT. + contrasts: + type: array + default: [] + items: + type: object + additionalProperties: false + required: [name, numerator, denominator] + properties: + name: + type: string + pattern: "^[A-Za-z0-9_-]+$" + numerator: + type: object + additionalProperties: + type: string + denominator: + type: object + additionalProperties: + type: string + one_vs_rest: + type: object + additionalProperties: false + default: {} + properties: + enabled: + type: boolean + default: false + description: >- + Test each comparison-group level against all other eligible + levels when at least three levels remain. Uses configured + covariates and pairing and reports level-specific biomarkers. + lrt: + type: object + additionalProperties: false + default: {} + properties: + enabled: + type: boolean + default: false + description: >- + Run an omnibus LRT only when at least three eligible levels remain + after exclusions, replicate filtering, and pairing. Otherwise LRT + and DEGpatterns are skipped while pairwise contrasts still run. + plot_dpi: + type: integer + minimum: 1 + description: Default figure DPI. + umap_point_size: + type: number + exclusiveMinimum: 0 + default: 2 + description: Marker size for ordinary UMAP embedding plots. + spatial_point_size: + type: number + exclusiveMinimum: 0 + default: 20 + description: Marker size for spatial maps. + + ingest_ref: + type: string + description: >- + Optional reference h5ad for scanpy label transfer (ingest_ref rule) and the + qc_sweep cell-type overlay. Empty string disables ingest. + + ingest_ref_label_key: + type: string + description: obs column in the ingest reference holding the cell-type label. + annotation_types: type: array items: type: string + enum: ["tsv_annotation", "external_annotation", "ingest_annotation"] description: >- Annotation types analysed downstream (neighbourhood, pseudobulk). Defaults to ["tsv_annotation"] when omitted. - - random_seed: - type: integer - description: Global RNG seed. - - use_precomputed_clusters: - type: boolean - description: Reuse precomputed cluster metadata when available. - - precomputed_metadata_dir: - type: string - description: Directory of precomputed metadata_{sample}.tsv (with use_precomputed_clusters). - - external_annotation: - type: ["object", "null"] - description: Optional external cell-type annotation source. - properties: - enabled: - type: boolean - description: Enable overlaying external cell-type labels. - default: false - column: - type: string - description: obs/metadata column holding the external label. - keep_unannotated: - type: boolean - default: true - description: >- - false → keep only externally-annotated cells and skip pipeline QC (external - labels drive QC); true → normal QC, labels overlaid at annotation. - - contract: - type: ["object", "null"] - description: >- - Semantic HEAD->CORE contract keys. `unfiltered_h5ad` and `outdir` are injected by - the Snakefile after validation and are intentionally not defined here. - properties: - sample_key: - type: string - description: obs column holding the sample id. - default: sample - spatial_key: - type: string - description: obsm key holding the spatial coordinates. - default: spatial - require_region: - type: boolean - description: If true, a missing region_annotation is a hard validation failure. - default: false - require_raw_counts: - type: boolean - description: If true, validate that X holds non-negative integer raw counts. - default: true - mito_prefix: - type: array - items: - type: string - - description: Gene-name prefixes used to flag mitochondrial genes. - qc_sweep: - type: ["object", "null"] - description: OPTIONAL pre-filtering QC diagnostic (target qc_sweep_all). - properties: - ingest_enabled: - type: boolean - description: Overlay ingest-reference cell types on the QC-sweep plots. - default: false - dpi: - type: integer - minimum: 1 - description: Figure resolution (DPI) for the QC-sweep plots. - default: 300 - thresholds: - type: object - description: Candidate cut-off LISTS per feature (min_genes/min_counts/max_counts/max_pct_mt). - additionalProperties: - type: array - items: - type: number - ingest_key: - type: string - description: (Legacy; unused — qc_sweep reuses the top-level ingest_ref.) - cell_id_key: - type: string - description: (Legacy; unused.) - - annotation_colors: - type: ["object", "null"] - description: Cell-type colour palettes — annotation column -> {value -> hex}. - additionalProperties: - type: object - additionalProperties: - type: string - - gene_exploration: - type: ["object", "null"] - description: Gene/signature exploration settings (target explore_genes). - properties: - queries: - type: string - description: TSV of genes / gene sets to score (AUCell) and plot. - annot_key: - type: string - description: Annotation column used to colour the exploration plots. - niche_column: - type: string - description: obs column holding the spatial-niche label. - aucell_max_rank_fraction: - type: number - description: AUCell max rank, as a fraction of the ranked genes. - dpi: - type: integer - minimum: 1 - - description: Figure resolution (DPI) for the exploration plots. - default: 300 - spatial_niches: - type: ["object", "null"] - description: OPTIONAL cross-sample spatial-niche identification. - properties: - enabled: - type: boolean - description: Enable BANKSY spatial-niche detection. - default: false - use_precomputed: - type: boolean - description: Reuse precomputed niche assignments from niche_dir instead of recomputing. - niche_dir: - type: string - - description: Directory of precomputed per-sample niche TSVs. - subcompartments: - type: ["object", "null"] - description: >- - Subcompartment definitions for the subcluster rule (name -> settings). Keys are - the subcompartment names; each needs a non-empty `strings` list of cell types. - additionalProperties: - type: object - required: - - strings - properties: - strings: - type: array - items: - type: string - minItems: 1 - description: Cell-type labels that make up this subcompartment. - resolution_min: - type: number - exclusiveMinimum: 0 - description: Lowest Leiden resolution when re-clustering this subcompartment. - resolution_max: - type: number - exclusiveMinimum: 0 - description: Highest Leiden resolution when re-clustering this subcompartment. - resolution_step: - type: number - exclusiveMinimum: 0 - - description: Step between Leiden resolutions for this subcompartment. - resources: - type: object - required: - - spaceranger_count_vhd - - preprocess_umap - - leiden_analysis - - default - additionalProperties: - type: object - required: - - mem_mb - - runtime - - threads - properties: - mem_mb: - type: integer - minimum: 1024 - description: Memory request in MB (scaled by the retry attempt). - runtime: - type: integer - minimum: 1 - description: Wall-clock limit in minutes. - threads: - type: integer - minimum: 1 - description: Number of CPU threads. -allOf: -- if: - properties: - mode: - const: visiumhd - then: - required: - - samples - - probe_set - - transcriptome - - spaceranger -- if: - properties: - mode: - const: xenium5k - required: - - mode - then: - required: - - xenium5k -- if: - properties: - mode: - const: merscope - required: - - mode - then: - required: - - merscope + + random_seed: + type: integer + description: Global RNG seed. + + use_precomputed_clusters: + type: boolean + description: Reuse precomputed cluster metadata when available. + + precomputed_metadata_dir: + type: string + description: >- + Directory of read-only metadata_{sample}.tsv files. Cell IDs may be stored in + the first column or in a cell_id column. + + external_annotation: + type: ["object", "null"] + description: Optional external cell-type annotation source. + properties: + enabled: + type: boolean + description: Enable overlaying external cell-type labels. + default: false + column: + type: string + description: >- + Source column holding the external label, either in + metadata_{sample}.tsv or, in decoupled mode without external metadata, + directly in the contract h5ad obs. The name is arbitrary; SpaceBlocks + copies it to the canonical cell_type_external obs column downstream. + keep_unannotated: + type: boolean + default: true + description: >- + false → keep only externally-annotated cells and skip pipeline QC (external + labels drive QC); true → normal QC, labels overlaid at annotation and + cells labelled Unannotated retained. A mixed labelled/Unannotated column + remains available for plots; only placeholder-only columns are omitted. + + contract: + type: ["object", "null"] + description: >- + Semantic HEAD->CORE contract keys. `unfiltered_h5ad` and `outdir` are injected by + the Snakefile after validation and are intentionally not defined here. + properties: + sample_key: + type: string + description: obs column holding the sample id. + default: sample + spatial_key: + type: string + description: obsm key holding the spatial coordinates. + default: spatial + require_region: + type: boolean + description: If true, a missing region_annotation is a hard validation failure. + default: false + require_raw_counts: + type: boolean + description: If true, validate that X holds non-negative integer raw counts. + default: true + mito_prefix: + type: array + items: + type: string + + description: Gene-name prefixes used to flag mitochondrial genes. + qc_sweep: + type: ["object", "null"] + description: OPTIONAL pre-filtering QC diagnostic (target qc_sweep_all). + properties: + ingest_enabled: + type: boolean + description: Overlay ingest-reference cell types on the QC-sweep plots. + default: false + dpi: + type: integer + minimum: 1 + description: Figure resolution (DPI) for the QC-sweep plots. + default: 300 + thresholds: + type: object + description: Candidate cut-off LISTS per feature (min_genes/min_counts/max_counts/max_pct_mt). + additionalProperties: + type: array + items: + type: number + ingest_key: + type: string + description: (Legacy; unused — qc_sweep reuses the top-level ingest_ref.) + cell_id_key: + type: string + description: (Legacy; unused.) + + annotation_colors: + type: ["object", "null"] + description: Cell-type colour palettes — annotation column -> {value -> hex}. + additionalProperties: + type: object + additionalProperties: + type: string + + gene_exploration: + type: ["object", "null"] + description: Gene/signature exploration settings (target explore_genes). + properties: + queries: + type: string + description: TSV of genes / gene sets to score (AUCell) and plot. + annot_key: + type: string + description: Annotation column used to colour the exploration plots. + niche_column: + type: string + description: obs column holding the spatial-niche label. + aucell_max_rank_fraction: + type: number + description: AUCell max rank, as a fraction of the ranked genes. + dpi: + type: integer + minimum: 1 + + description: Figure resolution (DPI) for the exploration plots. + default: 300 + spatial_niches: + type: ["object", "null"] + description: OPTIONAL cross-sample spatial-niche identification. + properties: + enabled: + type: boolean + description: Enable BANKSY spatial-niche detection. + default: false + use_precomputed: + type: boolean + description: Reuse precomputed niche assignments from niche_dir instead of recomputing. + niche_dir: + type: string + + description: Directory of precomputed per-sample niche TSVs. + lambda: + type: number + minimum: 0 + maximum: 1 + default: 0.8 + description: Weight of spatial-neighbour features in the BANKSY matrix. + num_neighbours: + type: integer + minimum: 1 + default: 18 + description: Number of physical neighbours used to build BANKSY features. + cluster_n_neighbours: + type: integer + minimum: 1 + default: 50 + description: Number of neighbours in the BANKSY latent-space clustering graph. + niche_resolution: + type: number + exclusiveMinimum: 0 + default: 0.1 + description: Leiden resolution for spatial-niche clustering. + n_iterations: + type: integer + anyOf: + - const: -1 + - minimum: 1 + default: -1 + description: Leiden iterations; -1 runs until convergence. + subcompartments: + type: ["object", "null"] + description: >- + Subcompartment definitions for the subcluster rule (name -> settings). Keys are + the subcompartment names; each needs a non-empty `strings` list of cell types. + additionalProperties: + type: object + required: + - strings + properties: + strings: + type: array + items: + type: string + minItems: 1 + description: Cell-type labels that make up this subcompartment. + resolution_min: + type: number + exclusiveMinimum: 0 + description: Lowest Leiden resolution when re-clustering this subcompartment. + resolution_max: + type: number + exclusiveMinimum: 0 + description: Highest Leiden resolution when re-clustering this subcompartment. + resolution_step: + type: number + exclusiveMinimum: 0 + + description: Step between Leiden resolutions for this subcompartment. + resources: + type: object + required: + - spaceranger_count_vhd + - preprocess_umap + - leiden_analysis + - default + additionalProperties: + type: object + required: + - mem_mb + - runtime + - threads + properties: + mem_mb: + type: integer + minimum: 1024 + description: Memory request in MB (scaled by the retry attempt). + runtime: + type: integer + minimum: 1 + description: Wall-clock limit in minutes. + threads: + type: integer + minimum: 1 + description: Number of CPU threads. +allOf: +- if: + properties: + mode: + const: visiumhd + then: + required: + - samples + - probe_set + - transcriptome + - spaceranger +- if: + properties: + mode: + const: xenium5k + required: + - mode + then: + required: + - xenium5k +- if: + properties: + mode: + const: merscope + required: + - mode + then: + required: + - merscope diff --git a/workflow/scripts/annotate_cells.py b/workflow/scripts/annotate_cells.py index 719a896..96de3be 100644 --- a/workflow/scripts/annotate_cells.py +++ b/workflow/scripts/annotate_cells.py @@ -1,475 +1,546 @@ -""" -annotate_cells.py – Cell type annotation -========================================= -1. TSV-based annotation: maps Leiden clusters → cell types (cell_type_tsv). - -2. External annotation (optional): loads a cell-type column from the metadata - TSV (cell_type_external) when enabled in config. - -3. Generates annotation plots for the TSV annotation. If cell_type_ingest - (from ingest_ref) or cell_type_external are present, plots them too, plus - side-by-side UMAP/spatial comparisons and per-sample composition barplots. -""" - -import csv -import logging -import os -import sys -import traceback -from pathlib import Path - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import scanpy as sc - -# Shared composition-barplot helpers (black edge, legend==stack order, config colours) -try: - _here = os.path.dirname(os.path.abspath(__file__)) -except NameError: # very old Snakemake - _here = os.getcwd() -sys.path.insert(0, _here) -from composition_barplots import composition_pair, find_niche_column - - -# ── Logging ────────────────────────────────────────────────────────────────── -log_handlers = [logging.StreamHandler(sys.stderr)] -if hasattr(snakemake, "log"): - if snakemake.log.out: - Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) - log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) - if snakemake.log.err: - Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) - sys.stderr = open(snakemake.log.err, "w") - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-8s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - handlers=log_handlers, -) -log = logging.getLogger("annotate_cells") - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -def _niche_sort_key(label): - """Sort niche labels numerically when they are numbers, else alphabetically, so - legends read 0,1,2,…,10 instead of the lexicographic 1,10,2.""" - try: - return (0, float(label), "") - except (TypeError, ValueError): - return (1, 0.0, str(label)) - - -def read_tsv_to_dict(tsv_path): - with open(tsv_path) as fh: - reader = csv.DictReader(fh, delimiter="\t") - columns = {field: [] for field in reader.fieldnames} - for row in reader: - for key, val in row.items(): - if val.strip(): - columns[key].append(val.strip()) - return columns - - -def find_sample_column(annot_df, sample_id): - for col in annot_df.columns: - if col.startswith(sample_id + "_"): - return col, col[len(sample_id) + 1:] - if sample_id in annot_df.columns: - return sample_id, None - return None, None - - -def generate_annotation_plots(adata, annot_key, label, plots_dir, sample_id, - de_n_genes, library_id=None): - subdir = os.path.join(plots_dir, label) - os.makedirs(subdir, exist_ok=True) - adata.obs[annot_key] = adata.obs[annot_key].astype("category") - - # UMAP - log.info(" [%s] UMAP …", label) - sc.pl.umap(adata, color=[annot_key], size=2, wspace=0.25, frameon=False, - title=f"Cell types ({label})") - plt.savefig(os.path.join(subdir, f"UMAP_{label}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - # Spatial overview - try: - sc.pl.spatial(adata, color=annot_key, spot_size=20, frameon=False, - title=f"Spatial – {label}", library_id=library_id) - plt.savefig(os.path.join(subdir, f"spatial_all_{label}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - except Exception as e: - log.warning(" [%s] Spatial failed: %s", label, e) - - # Per-cell-type spatial - ct_dir = os.path.join(subdir, "spatial_per_celltype") - os.makedirs(ct_dir, exist_ok=True) - for ct in adata.obs[annot_key].cat.categories: - if ct == "Unannotated": - continue - adata.obs["_hl"] = adata.obs[annot_key].apply( - lambda x, c=ct: c if x == c else "Other" - ) - try: - sc.pl.spatial(adata, color="_hl", spot_size=20, frameon=False, - palette={"Other": "#d3d3d3", ct: "#000000"}, - title=ct, library_id=library_id) - safe = ct.replace("/", "_").replace(" ", "_") - plt.savefig(os.path.join(ct_dir, f"{safe}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - except Exception as e: - log.warning(" [%s] Spatial '%s' failed: %s", label, ct, e) - if "_hl" in adata.obs.columns: - del adata.obs["_hl"] - - # DE markers - valid = adata.obs[annot_key].value_counts() - valid = valid[(valid.index != "Unannotated") & (valid >= 10)] - if len(valid) >= 2: - log.info(" [%s] DE (top %d) …", label, de_n_genes) - sub = adata[adata.obs[annot_key].isin(valid.index)].copy() - sub.obs[annot_key] = sub.obs[annot_key].cat.remove_unused_categories() - try: - sc.tl.rank_genes_groups(sub, groupby=annot_key, method="wilcoxon") - sc.tl.dendrogram(sub, groupby=annot_key, use_rep="X_pca") - - sc.pl.rank_genes_groups_dotplot( - sub, groupby=annot_key, standard_scale="var", - n_genes=de_n_genes, swap_axes=True, dendrogram=True) - plt.savefig(os.path.join(subdir, f"top{de_n_genes}_dotplot_{label}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - sc.pl.rank_genes_groups_heatmap( - sub, n_genes=de_n_genes, groupby=annot_key, use_raw=False, - swap_axes=True, dendrogram=True, show_gene_labels=True) - plt.savefig(os.path.join(subdir, f"top{de_n_genes}_heatmap_{label}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - sc.pl.rank_genes_groups_matrixplot( - sub, n_genes=de_n_genes, groupby=annot_key, use_raw=False, - swap_axes=True, dendrogram=True) - plt.savefig(os.path.join(subdir, f"top{de_n_genes}_matrixplot_{label}_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - df = sc.get.rank_genes_groups_df(sub, None) - df.to_csv(os.path.join(subdir, f"DE_markers_{label}_{sample_id}.tsv"), - sep="\t", index=False) - except Exception as e: - log.warning(" [%s] DE failed: %s", label, e) - - -# ── Parameters ─────────────────────────────────────────────────────────────── -sample_id = snakemake.params.sample_id -MIN_CELLS_PER_TYPE = int(snakemake.params.min_cells_per_type) -DE_N_GENES = int(snakemake.params.de_n_genes) -USE_PRECOMPUTED = bool(snakemake.params.use_precomputed) -EXT_ANNOT_CFG = snakemake.params.external_annotation -PRECOMPUTED_DIR = str(snakemake.params.precomputed_metadata_dir) -ANNOTATION_COLORS = snakemake.params.annotation_colors -REGION_COLORS = snakemake.params.region_colors -DPI = int(getattr(snakemake.params, "dpi", 300)) -NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") - -adata_path = str(snakemake.input.adata) -metadata_path = str(snakemake.input.metadata) -annot_tsv_path = str(snakemake.input.cluster_annotations) -out_adata_path = str(snakemake.output.adata_annot) -plots_dir = str(snakemake.output.plots_dir) - -try: - log.info("=" * 70) - log.info("Annotating sample: %s", sample_id) - log.info(" Min cells per type: %d", MIN_CELLS_PER_TYPE) - log.info("=" * 70) - - os.makedirs(plots_dir, exist_ok=True) - - adata = sc.read_h5ad(adata_path) - log.info("Loaded adata: %d cells, %d genes", adata.n_obs, adata.n_vars) - - # Check if ingest labels are present - has_ingest = "cell_type_ingest" in adata.obs.columns - if has_ingest: - log.info("Ingest labels found — will include in plots.") - - library_id = None - if "spatial" in adata.uns and len(adata.uns["spatial"]) == 1: - library_id = list(adata.uns["spatial"].keys())[0] - - # Reload precomputed clusters from metadata TSV if configured - if USE_PRECOMPUTED: - _ext_meta = os.path.join(PRECOMPUTED_DIR, f"metadata_{sample_id}.tsv") if PRECOMPUTED_DIR else "" - if _ext_meta and os.path.isfile(_ext_meta): - _meta_source = _ext_meta - elif os.path.isfile(metadata_path): - _meta_source = metadata_path - else: - _meta_source = None - - if _meta_source: - log.info("Reloading clusters from metadata: %s", _meta_source) - saved = pd.read_csv(_meta_source, sep="\t", index_col=0, comment="#") - saved.index = saved.index.astype(str) # obs_names are str; numeric cell ids - # would be inferred as int and misalign - leiden_cols = [c for c in saved.columns if c.startswith("leiden_")] - for col in leiden_cols: - if col not in adata.obs.columns: - adata.obs[col] = pd.Categorical(saved[col].reindex(adata.obs_names).astype(int)) - log.info(" Loaded %s from metadata", col) - - # cell_type_tsv annotation may be skipped entirely (external-only runs). Define - # the leiden key it would have chosen up-front so uns['annotation_leiden_key'] is - # always writable — otherwise the external-only path NameErrors at save time. - leiden_key = None - - # ── 1. TSV-based annotation ────────────────────────────────────────── - # cluster_annotations is OPTIONAL: it is the MANUAL cluster -> cell-type mapping. - # A run annotated purely from an external source - # (annotation_types: ["external_annotation"]) legitimately has no such file, in - # which case the rule input resolves to an empty list and str() renders it "[]". - # Skip this section instead of failing, and let the external block below supply the - # labels — the Snakefile already advertises that route and validates it. - _have_annot_tsv = (annot_tsv_path not in ("", "[]", "None") - and os.path.isfile(annot_tsv_path)) - if not _have_annot_tsv: - log.info("No cluster_annotations TSV configured — cell_type_tsv set to " - "'Unannotated'; annotation will come from the external/ingest " - "source(s).") - adata.obs["cell_type_tsv"] = pd.Categorical(["Unannotated"] * adata.n_obs) - else: - annot_df = pd.read_csv(annot_tsv_path, sep="\t", index_col=0) - log.info("Annotation TSV columns: %s", list(annot_df.columns)) - - annot_col, resolution = find_sample_column(annot_df, sample_id) - - if annot_col is None: - log.warning("Sample '%s' not found in TSV. All cells → 'Unannotated'.", sample_id) - adata.obs["cell_type_tsv"] = "Unannotated" - Path(out_adata_path).parent.mkdir(parents=True, exist_ok=True) - adata.write(out_adata_path) - sys.exit(0) - - log.info("Using column '%s' (resolution: %s)", annot_col, - resolution if resolution else "not specified") - - if resolution is not None: - leiden_key = f"leiden_{resolution.replace('.', '_')}" - else: - leiden_cols = [c for c in adata.obs.columns if c.startswith("leiden_")] - if leiden_cols: - leiden_key = leiden_cols[0] - else: - raise ValueError("No leiden columns found in adata.") - - if leiden_key not in adata.obs.columns: - available = [c for c in adata.obs.columns if c.startswith("leiden_")] - raise ValueError( - f"Leiden column '{leiden_key}' not found. Available: {available}." - ) - - cluster_to_celltype = annot_df[annot_col].dropna().to_dict() - cluster_to_celltype = {str(k): str(v) for k, v in cluster_to_celltype.items()} - log.info("Cluster → cell type mapping (%d entries): %s", - len(cluster_to_celltype), cluster_to_celltype) - - adata.obs["cell_type_tsv"] = ( - adata.obs[leiden_key].astype(str) - .map(cluster_to_celltype) - .fillna("Unannotated") - .astype("category") - ) - - tsv_counts = adata.obs["cell_type_tsv"].value_counts() - log.info("TSV annotation distribution:\n%s", tsv_counts.to_string()) - - # ── 2. External annotation (from metadata TSV column) ──────────────── - # When enabled the pipeline MUST find the column for this sample — a missing - # source or column is a hard error (fail loudly rather than silently skipping), - # so a run only proceeds when the external labels are in place for every sample. - ext_enabled = False - if isinstance(EXT_ANNOT_CFG, dict) and EXT_ANNOT_CFG.get("enabled", False): - ext_col = EXT_ANNOT_CFG.get("column", "") - if not ext_col: - raise ValueError("external_annotation.enabled is true but no 'column' is set in config.") - # Resolve metadata source for external annotation - _ext_meta = os.path.join(PRECOMPUTED_DIR, f"metadata_{sample_id}.tsv") if PRECOMPUTED_DIR else "" - if _ext_meta and os.path.isfile(_ext_meta): - _ext_source = _ext_meta - elif os.path.isfile(metadata_path): - _ext_source = metadata_path - else: - _ext_source = None - - if _ext_source is None: - raise FileNotFoundError( - f"external_annotation enabled but no metadata found for sample " - f"'{sample_id}' (looked in precomputed_metadata_dir and {metadata_path}).") - - log.info("Loading external annotation from '%s' in %s …", ext_col, _ext_source) - saved = pd.read_csv(_ext_source, sep="\t", index_col=0, comment="#") - saved.index = saved.index.astype(str) # see note above - if ext_col not in saved.columns: - raise ValueError( - f"external_annotation column '{ext_col}' not found in {_ext_source} " - f"(sample '{sample_id}'). Available columns: {list(saved.columns)}") - adata.obs["cell_type_external"] = ( - saved[ext_col].reindex(adata.obs_names).fillna("Unannotated").astype("category")) - ext_enabled = True - log.info(" External annotation: %d types (%d cells unmatched → Unannotated)", - adata.obs["cell_type_external"].nunique(), - int((adata.obs["cell_type_external"] == "Unannotated").sum())) - - # ── 2b. Spatial niche labels (from spatial_niches rule / external) ─── - _ni = getattr(snakemake.input, "spatial_niche", "") - if isinstance(_ni, (list, tuple)): - niche_tsv = str(_ni[0]) if len(_ni) else "" - else: - niche_tsv = str(_ni) if _ni else "" - if niche_tsv and os.path.isfile(niche_tsv): - log.info("Merging spatial niche labels from %s …", niche_tsv) - # dtype=str: labels are written as text and must stay text — pandas would otherwise - # re-infer them as numbers, and any NaN then upcasts the column to float ("1" -> "1.0"). - ndf = pd.read_csv(niche_tsv, sep="\t", index_col=0, dtype=str) - ndf.index = ndf.index.astype(str) # see note above - col = "spatial_niche" if "spatial_niche" in ndf.columns else ndf.columns[0] - # Keyed by obs_names: the contract defines obs['cell_id'] == obs_names, and - # validate_input checks it, so there is exactly one join key here. - mapped = ndf[col].reindex(np.asarray(adata.obs_names, dtype=str)) - mapped = pd.Series(np.asarray(mapped), index=adata.obs_names) - n_assigned = int(mapped.notna().sum()) - if n_assigned < adata.n_obs: - log.warning(" %d/%d cells have no niche label -> 'Unassigned' " - "(do the niche TSV keys match obs_names?)", - adata.n_obs - n_assigned, adata.n_obs) - mapped = mapped.fillna("Unassigned").astype(str) - # Order numerically when the labels are numbers, so legends read 0,1,2,…,10 rather - # than the lexicographic 1,10,2 (matching the spatial_niches plots). - cats = sorted({c for c in mapped if c != "Unassigned"}, key=_niche_sort_key) - if (mapped == "Unassigned").any(): - cats.append("Unassigned") - adata.obs["spatial_niche"] = pd.Categorical(mapped, categories=cats, ordered=True) - log.info(" spatial_niche: %d/%d cells assigned, %d niches", - n_assigned, adata.n_obs, adata.obs["spatial_niche"].nunique()) - - # ── 3. Plots ───────────────────────────────────────────────────────── - log.info("Generating annotation plots …") - - # Apply custom palettes if configured - if isinstance(ANNOTATION_COLORS, dict): - for obs_key in ["cell_type_tsv", "cell_type_ingest", "cell_type_external"]: - cd = ANNOTATION_COLORS.get(obs_key, {}) - if cd and obs_key in adata.obs.columns: - cats = adata.obs[obs_key].cat.categories - adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] - - if REGION_COLORS and "region_annotation" in adata.obs.columns: - adata.obs["region_annotation"] = adata.obs["region_annotation"].astype("category") - cats = adata.obs["region_annotation"].cat.categories - adata.uns["region_annotation_colors"] = [ - REGION_COLORS.get(str(c), "#cccccc") for c in cats - ] - - generate_annotation_plots(adata, "cell_type_tsv", "tsv", plots_dir, - sample_id, DE_N_GENES, library_id) - - # If ingest labels exist, plot those too - if has_ingest: - generate_annotation_plots(adata, "cell_type_ingest", "ingest", plots_dir, - sample_id, DE_N_GENES, library_id) - - if ext_enabled: - generate_annotation_plots(adata, "cell_type_external", "external", plots_dir, - sample_id, DE_N_GENES, library_id) - - # Side-by-side comparison (dynamic number of panels) - annot_cols_present = [c for c in ["cell_type_tsv", "cell_type_ingest", - "cell_type_external"] - if c in adata.obs.columns] - n_panels = len(annot_cols_present) - if n_panels >= 2: - fig, axes = plt.subplots(1, n_panels, figsize=(8 * n_panels, 6), - gridspec_kw={"wspace": 0.5}) - for ax, col in zip(axes, annot_cols_present): - sc.pl.umap(adata, color=col, size=2, frameon=False, - title=col.replace("cell_type_", ""), ax=ax, show=False, - legend_fontsize=6, na_in_legend=False) - plt.savefig(os.path.join(plots_dir, f"UMAP_comparison_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - # Side-by-side SPATIAL comparison (only if >1 annotation method) - if n_panels >= 2: - try: - fig, axes = plt.subplots(1, n_panels, figsize=(8 * n_panels, 6), - gridspec_kw={"wspace": 0.5}) - for ax, col in zip(axes, annot_cols_present): - sc.pl.spatial(adata, color=col, spot_size=20, frameon=False, - title=col.replace("cell_type_", ""), - library_id=library_id, ax=ax, show=False, - legend_fontsize=6, na_in_legend=False) - plt.savefig(os.path.join(plots_dir, f"spatial_comparison_{sample_id}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - except Exception as e: - log.warning("Spatial comparison plot failed: %s", e) - - # ── Per-sample composition barplots (sample / region / niche) ──────── - annot_cols_present = [c for c in ["cell_type_tsv", "cell_type_ingest", - "cell_type_external"] - if c in adata.obs.columns] - - # sample column (one bar per sample; here a single sample). Per-sample adatas - # carry "sample"; "sample_batch" is an integration-time concat label and does - # not exist here, so it is not consulted. - sample_col = "sample" if "sample" in adata.obs.columns else None - if sample_col is None: - adata.obs["sample"] = sample_id - sample_col = "sample" - - has_regions = ("region_annotation" in adata.obs.columns - and adata.obs["region_annotation"].nunique() > 1 - and not all(adata.obs["region_annotation"] == "Unlabeled")) - niche_col = find_niche_column(adata, NICHE_COLUMN) # NICHE_COLUMN may be "" - - bar_dir = os.path.join(plots_dir, "composition") - for annot_col in annot_cols_present: - cmap = (ANNOTATION_COLORS.get(annot_col, {}) - if isinstance(ANNOTATION_COLORS, dict) else {}) - label = annot_col.replace("cell_type_", "") - composition_pair(adata, sample_col, annot_col, cmap, bar_dir, - f"{label}_by_sample", group_label="Sample", - cat_label=label, dpi=DPI) - if has_regions: - composition_pair(adata, "region_annotation", annot_col, cmap, - bar_dir, f"{label}_by_region", - group_label="Region", cat_label=label, dpi=DPI) - if niche_col: - composition_pair(adata, niche_col, annot_col, cmap, bar_dir, - f"{label}_by_niche", group_label="Niche", - cat_label=label, dpi=DPI) - - # ── 4. Save ────────────────────────────────────────────────────────── - log.info("Saving annotated adata → %s", out_adata_path) - if leiden_key is None: # external-only / no TSV mapping - _lc = [c for c in adata.obs.columns if c.startswith("leiden_")] - leiden_key = _lc[0] if _lc else "" - adata.uns["annotation_leiden_key"] = leiden_key # TSV-annotation res, or fallback - Path(out_adata_path).parent.mkdir(parents=True, exist_ok=True) - adata.write(out_adata_path) - - log.info("Annotation complete for %s.", sample_id) - -except Exception: - log.error("FAILED for %s:\n%s", sample_id, traceback.format_exc()) - raise +""" +annotate_cells.py – Cell type annotation +========================================= +1. TSV-based annotation: maps Leiden clusters → cell types (cell_type_tsv). + +2. External annotation (optional): loads a cell-type column from the metadata + TSV (cell_type_external) when enabled in config. + +3. Generates annotation plots for the TSV annotation. If cell_type_ingest + (from ingest_ref) or cell_type_external are present, plots them too, plus + side-by-side UMAP/spatial comparisons and per-sample composition barplots. +""" + +import csv +import logging +import os +import sys +import traceback +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import scanpy as sc + +# Shared composition-barplot helpers (black edge, legend==stack order, config colours) +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from composition_barplots import composition_pair, find_niche_column +from annotation_utils import ( + normalize_annotation_labels, + ordered_annotation_columns, + record_active_annotation_columns, +) +from external_metadata import optional_input_path, read_cell_metadata +from plotting_legends import ( + category_labels, + move_legend_to_axis, + panel_figure, +) + + +# ── Logging ────────────────────────────────────────────────────────────────── +log_handlers = [logging.StreamHandler(sys.stderr)] +if hasattr(snakemake, "log"): + if snakemake.log.out: + Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) + log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) + if snakemake.log.err: + Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) + sys.stderr = open(snakemake.log.err, "w") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=log_handlers, +) +log = logging.getLogger("annotate_cells") + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _niche_sort_key(label): + """Sort niche labels numerically when they are numbers, else alphabetically, so + legends read 0,1,2,…,10 instead of the lexicographic 1,10,2.""" + try: + return (0, float(label), "") + except (TypeError, ValueError): + return (1, 0.0, str(label)) + + +def read_tsv_to_dict(tsv_path): + with open(tsv_path) as fh: + reader = csv.DictReader(fh, delimiter="\t") + columns = {field: [] for field in reader.fieldnames} + for row in reader: + for key, val in row.items(): + if val.strip(): + columns[key].append(val.strip()) + return columns + + +def find_sample_column(annot_df, sample_id): + for col in annot_df.columns: + if col.startswith(sample_id + "_"): + return col, col[len(sample_id) + 1:] + if sample_id in annot_df.columns: + return sample_id, None + return None, None + + +def generate_annotation_plots(adata, annot_key, label, plots_dir, sample_id, + de_n_genes, library_id=None): + subdir = os.path.join(plots_dir, label) + os.makedirs(subdir, exist_ok=True) + adata.obs[annot_key] = adata.obs[annot_key].astype("category") + + # UMAP + log.info(" [%s] UMAP …", label) + sc.pl.umap(adata, color=[annot_key], size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, + title=f"Cell types ({label})") + plt.savefig(os.path.join(subdir, f"UMAP_{label}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + # Spatial overview + try: + sc.pl.spatial(adata, color=annot_key, spot_size=SPATIAL_POINT_SIZE, frameon=False, + title=f"Spatial – {label}", library_id=library_id) + plt.savefig(os.path.join(subdir, f"spatial_all_{label}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + except Exception as e: + log.warning(" [%s] Spatial failed: %s", label, e) + + # Per-cell-type spatial + ct_dir = os.path.join(subdir, "spatial_per_celltype") + os.makedirs(ct_dir, exist_ok=True) + for ct in adata.obs[annot_key].cat.categories: + if ct == "Unannotated": + continue + adata.obs["_hl"] = adata.obs[annot_key].apply( + lambda x, c=ct: c if x == c else "Other" + ) + try: + sc.pl.spatial(adata, color="_hl", spot_size=SPATIAL_POINT_SIZE, frameon=False, + palette={"Other": "#d3d3d3", ct: "#000000"}, + title=ct, library_id=library_id) + safe = ct.replace("/", "_").replace(" ", "_") + plt.savefig(os.path.join(ct_dir, f"{safe}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + except Exception as e: + log.warning(" [%s] Spatial '%s' failed: %s", label, ct, e) + if "_hl" in adata.obs.columns: + del adata.obs["_hl"] + + # DE markers + valid = adata.obs[annot_key].value_counts() + valid = valid[(valid.index != "Unannotated") & (valid >= 10)] + if len(valid) >= 2: + log.info(" [%s] DE (top %d) …", label, de_n_genes) + sub = adata[adata.obs[annot_key].isin(valid.index)].copy() + sub.obs[annot_key] = sub.obs[annot_key].cat.remove_unused_categories() + try: + sc.tl.rank_genes_groups(sub, groupby=annot_key, method="wilcoxon") + sc.tl.dendrogram(sub, groupby=annot_key, use_rep="X_pca") + + sc.pl.rank_genes_groups_dotplot( + sub, groupby=annot_key, standard_scale="var", + n_genes=de_n_genes, swap_axes=True, dendrogram=True) + plt.savefig(os.path.join(subdir, f"top{de_n_genes}_dotplot_{label}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + sc.pl.rank_genes_groups_heatmap( + sub, n_genes=de_n_genes, groupby=annot_key, use_raw=False, + swap_axes=True, dendrogram=True, show_gene_labels=True) + plt.savefig(os.path.join(subdir, f"top{de_n_genes}_heatmap_{label}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + sc.pl.rank_genes_groups_matrixplot( + sub, n_genes=de_n_genes, groupby=annot_key, use_raw=False, + swap_axes=True, dendrogram=True) + plt.savefig(os.path.join(subdir, f"top{de_n_genes}_matrixplot_{label}_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + df = sc.get.rank_genes_groups_df(sub, None) + df.to_csv(os.path.join(subdir, f"DE_markers_{label}_{sample_id}.tsv"), + sep="\t", index=False) + except Exception as e: + log.warning(" [%s] DE failed: %s", label, e) + + +# ── Parameters ─────────────────────────────────────────────────────────────── +sample_id = snakemake.params.sample_id +DE_N_GENES = int(snakemake.params.de_n_genes) +USE_PRECOMPUTED = bool(snakemake.params.use_precomputed) +EXT_ANNOT_CFG = snakemake.params.external_annotation +PRIMARY_ANNOTATION_COLUMN = str( + getattr(snakemake.params, "primary_annotation_column", "") or "" +) +ANNOTATION_COLORS = snakemake.params.annotation_colors +REGION_COLORS = snakemake.params.region_colors +DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) +NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") + +adata_path = str(snakemake.input.adata) +metadata_path = str(snakemake.input.metadata) +annot_tsv_path = str(snakemake.input.cluster_annotations) +external_metadata_path = optional_input_path( + getattr(snakemake.input, "external_metadata", None) +) +out_adata_path = str(snakemake.output.adata_annot) +plots_dir = str(snakemake.output.plots_dir) + +try: + log.info("=" * 70) + log.info("Annotating sample: %s", sample_id) + log.info("=" * 70) + + os.makedirs(plots_dir, exist_ok=True) + + adata = sc.read_h5ad(adata_path) + log.info("Loaded adata: %d cells, %d genes", adata.n_obs, adata.n_vars) + + # Check if ingest labels are present + has_ingest = "cell_type_ingest" in adata.obs.columns + if has_ingest: + log.info("Ingest labels found — will include in plots.") + + library_id = None + if "spatial" in adata.uns and len(adata.uns["spatial"]) == 1: + library_id = list(adata.uns["spatial"].keys())[0] + + # Reload precomputed clusters from metadata TSV if configured + if USE_PRECOMPUTED: + if external_metadata_path: + _meta_source = external_metadata_path + elif os.path.isfile(metadata_path): + _meta_source = metadata_path + else: + _meta_source = None + + if _meta_source: + log.info("Reloading clusters from metadata: %s", _meta_source) + saved = read_cell_metadata(_meta_source) + leiden_cols = [c for c in saved.columns if c.startswith("leiden_")] + for col in leiden_cols: + if col not in adata.obs.columns: + adata.obs[col] = pd.Categorical(saved[col].reindex(adata.obs_names).astype(int)) + log.info(" Loaded %s from metadata", col) + + # cell_type_tsv annotation may be skipped entirely (external-only runs). Define + # the leiden key it would have chosen up-front so uns['annotation_leiden_key'] is + # always writable — otherwise the external-only path NameErrors at save time. + leiden_key = None + + # ── 1. TSV-based annotation ────────────────────────────────────────── + # cluster_annotations is OPTIONAL: it is the MANUAL cluster -> cell-type mapping. + # A run annotated purely from an external source + # (annotation_types: ["external_annotation"]) legitimately has no such file, in + # which case the rule input resolves to an empty list and str() renders it "[]". + # Skip this section instead of failing, and let the external block below supply the + # labels — the Snakefile already advertises that route and validates it. + _have_annot_tsv = (annot_tsv_path not in ("", "[]", "None") + and os.path.isfile(annot_tsv_path)) + if not _have_annot_tsv: + log.info("No cluster_annotations TSV configured — cell_type_tsv set to " + "'Unannotated'; annotation will come from the external/ingest " + "source(s).") + adata.obs["cell_type_tsv"] = pd.Categorical(["Unannotated"] * adata.n_obs) + else: + annot_df = pd.read_csv(annot_tsv_path, sep="\t", index_col=0) + log.info("Annotation TSV columns: %s", list(annot_df.columns)) + + annot_col, resolution = find_sample_column(annot_df, sample_id) + + if annot_col is None: + log.warning( + "Sample '%s' not found in cluster_annotations TSV. " + "cell_type_tsv set to 'Unannotated'; continuing with any " + "external/ingest annotation.", + sample_id, + ) + adata.obs["cell_type_tsv"] = pd.Categorical( + ["Unannotated"] * adata.n_obs + ) + else: + log.info("Using column '%s' (resolution: %s)", annot_col, + resolution if resolution else "not specified") + + if resolution is not None: + leiden_key = f"leiden_{resolution.replace('.', '_')}" + else: + leiden_cols = [c for c in adata.obs.columns if c.startswith("leiden_")] + if leiden_cols: + leiden_key = leiden_cols[0] + else: + raise ValueError("No leiden columns found in adata.") + + if leiden_key not in adata.obs.columns: + available = [c for c in adata.obs.columns if c.startswith("leiden_")] + raise ValueError( + f"Leiden column '{leiden_key}' not found. Available: {available}." + ) + + cluster_to_celltype = annot_df[annot_col].dropna().to_dict() + cluster_to_celltype = {str(k): str(v) for k, v in cluster_to_celltype.items()} + log.info("Cluster → cell type mapping (%d entries): %s", + len(cluster_to_celltype), cluster_to_celltype) + + adata.obs["cell_type_tsv"] = ( + adata.obs[leiden_key].astype(str) + .map(cluster_to_celltype) + .fillna("Unannotated") + .astype("category") + ) + + tsv_counts = adata.obs["cell_type_tsv"].value_counts() + log.info("TSV annotation distribution:\n%s", tsv_counts.to_string()) + + # ── 2. External annotation (from metadata TSV column) ──────────────── + # When enabled the pipeline MUST find the column for this sample — a missing + # source or column is a hard error (fail loudly rather than silently skipping), + # so a run only proceeds when the external labels are in place for every sample. + if isinstance(EXT_ANNOT_CFG, dict) and EXT_ANNOT_CFG.get("enabled", False): + ext_col = EXT_ANNOT_CFG.get("column", "") + if not ext_col: + raise ValueError("external_annotation.enabled is true but no 'column' is set in config.") + # Prefer the explicitly supplied metadata TSV. In decoupled mode, a custom + # source column may instead already be present in the contract h5ad and is + # preserved by preprocessing. + if external_metadata_path: + _ext_source = external_metadata_path + log.info( + "Loading external annotation from '%s' in %s …", + ext_col, + _ext_source, + ) + saved = read_cell_metadata( + _ext_source, required_columns=(ext_col,) + ) + external_labels = saved[ext_col].reindex( + adata.obs_names.astype(str) + ) + elif ext_col in adata.obs.columns: + _ext_source = f"processed contract h5ad obs['{ext_col}']" + log.info("Loading external annotation from %s …", _ext_source) + external_labels = adata.obs[ext_col] + else: + raise ValueError( + f"external_annotation column '{ext_col}' not found for sample " + f"'{sample_id}': no matching precomputed metadata column or h5ad " + "obs column is available" + ) + + # Construct the categorical explicitly from the helper's object-backed + # labels. A generic Series.astype("category") may re-infer pandas' + # nullable string dtype and make the h5ad unreadable by older AnnData. + adata.obs["cell_type_external"] = pd.Categorical( + normalize_annotation_labels(external_labels) + ) + adata.uns["external_annotation_source_column"] = ext_col + n_external_unannotated = int( + (adata.obs["cell_type_external"] == "Unannotated").sum() + ) + log.info( + " External annotation '%s' copied to 'cell_type_external': %d types " + "(%d cells unmatched → Unannotated)", + ext_col, + adata.obs["cell_type_external"].nunique(), + n_external_unannotated, + ) + + # ── 2b. Spatial niche labels (from spatial_niches rule / external) ─── + _ni = getattr(snakemake.input, "spatial_niche", "") + if isinstance(_ni, (list, tuple)): + niche_tsv = str(_ni[0]) if len(_ni) else "" + else: + niche_tsv = str(_ni) if _ni else "" + if niche_tsv and os.path.isfile(niche_tsv): + log.info("Merging spatial niche labels from %s …", niche_tsv) + # dtype=str: labels are written as text and must stay text — pandas would otherwise + # re-infer them as numbers, and any NaN then upcasts the column to float ("1" -> "1.0"). + ndf = pd.read_csv(niche_tsv, sep="\t", index_col=0, dtype=str) + ndf.index = ndf.index.astype(str) # see note above + col = "spatial_niche" if "spatial_niche" in ndf.columns else ndf.columns[0] + # Keyed by obs_names: the contract defines obs['cell_id'] == obs_names, and + # validate_input checks it, so there is exactly one join key here. + mapped = ndf[col].reindex(np.asarray(adata.obs_names, dtype=str)) + mapped = pd.Series(np.asarray(mapped), index=adata.obs_names) + n_assigned = int(mapped.notna().sum()) + if n_assigned < adata.n_obs: + log.warning(" %d/%d cells have no niche label -> 'Unassigned' " + "(do the niche TSV keys match obs_names?)", + adata.n_obs - n_assigned, adata.n_obs) + mapped = mapped.fillna("Unassigned").astype(str) + # Order numerically when the labels are numbers, so legends read 0,1,2,…,10 rather + # than the lexicographic 1,10,2 (matching the spatial_niches plots). + cats = sorted({c for c in mapped if c != "Unassigned"}, key=_niche_sort_key) + if (mapped == "Unassigned").any(): + cats.append("Unassigned") + adata.obs["spatial_niche"] = pd.Categorical(mapped, categories=cats, ordered=True) + log.info(" spatial_niche: %d/%d cells assigned, %d niches", + n_assigned, adata.n_obs, adata.obs["spatial_niche"].nunique()) + + # ── 3. Plots ───────────────────────────────────────────────────────── + log.info("Generating annotation plots …") + + record_active_annotation_columns( + adata, + preferred=PRIMARY_ANNOTATION_COLUMN, + ) + active_annotations = ordered_annotation_columns( + adata, + preferred=PRIMARY_ANNOTATION_COLUMN, + ) + primary_annotation = active_annotations[0] if active_annotations else None + log.info("Active annotation columns: %s", active_annotations or "none") + log.info("Primary annotation column: %s", primary_annotation or "none") + if ( + isinstance(EXT_ANNOT_CFG, dict) + and EXT_ANNOT_CFG.get("enabled", False) + and "cell_type_external" not in active_annotations + ): + log.warning( + "External annotation is enabled, but '%s' contains no non-placeholder " + "labels matching this sample. No external-annotation plots will be made.", + EXT_ANNOT_CFG.get("column", ""), + ) + + # Apply custom palettes if configured + if isinstance(ANNOTATION_COLORS, dict): + for obs_key in active_annotations: + cd = ANNOTATION_COLORS.get(obs_key, {}) + if cd and obs_key in adata.obs.columns: + adata.obs[obs_key] = adata.obs[obs_key].astype("category") + cats = adata.obs[obs_key].cat.categories + adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] + + if REGION_COLORS and "region_annotation" in adata.obs.columns: + adata.obs["region_annotation"] = adata.obs["region_annotation"].astype("category") + cats = adata.obs["region_annotation"].cat.categories + adata.uns["region_annotation_colors"] = [ + REGION_COLORS.get(str(c), "#cccccc") for c in cats + ] + + annotation_labels = { + "cell_type_tsv": "tsv", + "cell_type_ingest": "ingest", + "cell_type_external": "external", + } + for annot_col in active_annotations: + generate_annotation_plots( + adata, annot_col, annotation_labels.get(annot_col, annot_col), + plots_dir, sample_id, DE_N_GENES, library_id, + ) + + # Side-by-side comparison (dynamic number of panels) + annot_cols_present = active_annotations + n_panels = len(annot_cols_present) + if n_panels >= 2: + labels_by_panel = [category_labels(adata, col) for col in annot_cols_present] + fig, plot_axes, legend_axes = panel_figure( + n_panels, + labels_by_panel, + panel_width=8, + plot_height=6, + wspace=0.18, + ) + axes = plot_axes[0] + for ax, legend_ax, col in zip(axes, legend_axes, annot_cols_present): + title = col.replace("cell_type_", "") + sc.pl.umap(adata, color=col, size=UMAP_POINT_SIZE, frameon=False, + title=title, ax=ax, show=False, + legend_fontsize=12, na_in_legend=False) + move_legend_to_axis(ax, legend_ax, title=title) + plt.savefig(os.path.join(plots_dir, f"UMAP_comparison_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + # Side-by-side SPATIAL comparison (only if >1 annotation method) + if n_panels >= 2: + try: + labels_by_panel = [ + category_labels(adata, col) for col in annot_cols_present + ] + fig, plot_axes, legend_axes = panel_figure( + n_panels, + labels_by_panel, + panel_width=8, + plot_height=6, + wspace=0.18, + ) + axes = plot_axes[0] + for ax, legend_ax, col in zip(axes, legend_axes, annot_cols_present): + title = col.replace("cell_type_", "") + sc.pl.spatial(adata, color=col, spot_size=SPATIAL_POINT_SIZE, frameon=False, + title=title, + library_id=library_id, ax=ax, show=False, + legend_fontsize=12, na_in_legend=False) + move_legend_to_axis(ax, legend_ax, title=title) + plt.savefig(os.path.join(plots_dir, f"spatial_comparison_{sample_id}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + except Exception as e: + log.warning("Spatial comparison plot failed: %s", e) + + # ── Per-sample composition barplots (sample / region / niche) ──────── + annot_cols_present = active_annotations + + # sample column (one bar per sample; here a single sample). Per-sample adatas + # carry "sample"; "sample_batch" is an integration-time concat label and does + # not exist here, so it is not consulted. + sample_col = "sample" if "sample" in adata.obs.columns else None + if sample_col is None: + adata.obs["sample"] = sample_id + sample_col = "sample" + + has_regions = ("region_annotation" in adata.obs.columns + and adata.obs["region_annotation"].nunique() > 1 + and not all(adata.obs["region_annotation"] == "Unlabeled")) + niche_col = find_niche_column(adata, NICHE_COLUMN) # NICHE_COLUMN may be "" + + bar_dir = os.path.join(plots_dir, "composition") + for annot_col in annot_cols_present: + cmap = (ANNOTATION_COLORS.get(annot_col, {}) + if isinstance(ANNOTATION_COLORS, dict) else {}) + label = annot_col.replace("cell_type_", "") + composition_pair(adata, sample_col, annot_col, cmap, bar_dir, + f"{label}_by_sample", group_label="Sample", + cat_label=label, dpi=DPI) + if has_regions: + composition_pair(adata, "region_annotation", annot_col, cmap, + bar_dir, f"{label}_by_region", + group_label="Region", cat_label=label, dpi=DPI) + if niche_col: + composition_pair(adata, niche_col, annot_col, cmap, bar_dir, + f"{label}_by_niche", group_label="Niche", + cat_label=label, dpi=DPI) + + # ── 4. Save ────────────────────────────────────────────────────────── + log.info("Saving annotated adata → %s", out_adata_path) + if leiden_key is None: # external-only / no TSV mapping + _lc = [c for c in adata.obs.columns if c.startswith("leiden_")] + leiden_key = _lc[0] if _lc else "" + adata.uns["annotation_leiden_key"] = leiden_key # TSV-annotation res, or fallback + Path(out_adata_path).parent.mkdir(parents=True, exist_ok=True) + adata.write(out_adata_path) + + log.info("Annotation complete for %s.", sample_id) + +except Exception: + log.error("FAILED for %s:\n%s", sample_id, traceback.format_exc()) + raise diff --git a/workflow/scripts/annotation_utils.py b/workflow/scripts/annotation_utils.py new file mode 100644 index 0000000..49b6b86 --- /dev/null +++ b/workflow/scripts/annotation_utils.py @@ -0,0 +1,158 @@ +"""Shared helpers for deciding which cell-type annotations are meaningful. + +The names below are SpaceBlocks' canonical ``adata.obs`` columns. In particular, +``external_annotation.column`` may name any column in the source metadata TSV; +``annotate_cells.py`` copies that source column to ``cell_type_external`` before +these helpers are called. +""" + +from __future__ import annotations + + +ACTIVE_ANNOTATION_COLUMNS_KEY = "active_annotation_columns" +PRIMARY_ANNOTATION_COLUMN_KEY = "primary_annotation_column" +CANONICAL_ANNOTATION_COLUMNS = ( + "cell_type_tsv", + "cell_type_ingest", + "cell_type_external", +) +PRIMARY_ANNOTATION_PRIORITY = ( + "cell_type_external", + "cell_type_tsv", + "cell_type_ingest", +) +_PLACEHOLDER_LABELS = { + "", "", "n/a", "na", "nan", "none", "null", "unannotated", +} + + +def non_placeholder_annotation_mask(labels): + """Return a boolean mask selecting real labels from a pandas Series.""" + normalized = labels.astype("string").str.strip().str.casefold() + return labels.notna() & ~normalized.isin(_PLACEHOLDER_LABELS) + + +def normalize_annotation_labels(labels, placeholder="Unannotated"): + """Return stripped, object-backed labels with placeholders normalized. + + Pandas' nullable string dtype is useful while normalizing because it preserves + missing values through string operations. Do not expose that dtype to callers, + though: nullable-string categories use a newer h5ad encoding that older AnnData + readers cannot consume. + """ + normalized = labels.astype("string").str.strip() + normalized = normalized.where( + non_placeholder_annotation_mask(labels), placeholder + ) + return normalized.astype(object) + + +def meaningful_annotation_labels(adata, column): + """Return distinct, non-placeholder labels from an ``obs`` column.""" + if not column or column not in adata.obs.columns: + return [] + labels = adata.obs[column] + return ( + labels[non_placeholder_annotation_mask(labels)] + .astype(str) + .str.strip() + .unique() + .tolist() + ) + + +def has_meaningful_annotation(adata, column, min_labels=1): + """Whether ``column`` contains at least ``min_labels`` real labels.""" + return len(meaningful_annotation_labels(adata, column)) >= min_labels + + +def _select_primary_annotation(active, preferred=None, recorded=None): + """Choose one primary column while retaining every active annotation.""" + for column in (preferred, recorded, *PRIMARY_ANNOTATION_PRIORITY): + if column and column in active: + return column + return active[0] if active else None + + +def record_active_annotation_columns( + adata, + candidates=CANONICAL_ANNOTATION_COLUMNS, + preferred=None, +): + """Store and return canonical annotation columns carrying at least one real label. + + Placeholder labels are ignored for this availability decision only. Thus, a + column containing both ``Unannotated`` and real cell types remains active and + retains its unannotated cells in downstream plots. + """ + active = [column for column in candidates if has_meaningful_annotation(adata, column)] + adata.uns[ACTIVE_ANNOTATION_COLUMNS_KEY] = active + primary = _select_primary_annotation(active, preferred=preferred) + if primary: + adata.uns[PRIMARY_ANNOTATION_COLUMN_KEY] = primary + else: + adata.uns.pop(PRIMARY_ANNOTATION_COLUMN_KEY, None) + return active + + +def active_annotation_columns(adata, candidates=CANONICAL_ANNOTATION_COLUMNS): + """Return usable annotation columns, with a fallback for older h5ad files. + + New outputs record the active columns explicitly. Objects written before that + metadata existed are inspected semantically, so an all-``Unannotated`` column + never becomes a report panel merely because it is present in ``obs``. + """ + candidates = list(candidates) + if ACTIVE_ANNOTATION_COLUMNS_KEY not in adata.uns: + return [ + column + for column in candidates + if has_meaningful_annotation(adata, column) + ] + + recorded = adata.uns[ACTIVE_ANNOTATION_COLUMNS_KEY] + if recorded is None: + recorded = [] + elif isinstance(recorded, str): + recorded = [recorded] + else: + recorded = [str(column) for column in recorded] + return [ + column + for column in candidates + if column in recorded and has_meaningful_annotation(adata, column) + ] + + +def primary_annotation_column( + adata, + preferred=None, + candidates=CANONICAL_ANNOTATION_COLUMNS, +): + """Return the preferred meaningful annotation, external first by default.""" + active = active_annotation_columns(adata, candidates=candidates) + recorded = adata.uns.get(PRIMARY_ANNOTATION_COLUMN_KEY) + if recorded is not None: + recorded = str(recorded) + return _select_primary_annotation( + active, + preferred=preferred, + recorded=recorded, + ) + + +def ordered_annotation_columns( + adata, + preferred=None, + candidates=CANONICAL_ANNOTATION_COLUMNS, +): + """Return all meaningful annotations with the primary column first.""" + active = active_annotation_columns(adata, candidates=candidates) + primary = primary_annotation_column( + adata, + preferred=preferred, + candidates=candidates, + ) + if not primary: + return active + return [primary, *[column for column in active if column != primary]] diff --git a/workflow/scripts/explore_genes_common.py b/workflow/scripts/explore_genes_common.py index d44f0fb..b0c4787 100644 --- a/workflow/scripts/explore_genes_common.py +++ b/workflow/scripts/explore_genes_common.py @@ -12,6 +12,7 @@ import matplotlib.pyplot as plt from matplotlib.patches import Patch from PIL import Image +from plotting_legends import legend_layout log = logging.getLogger(__name__) @@ -75,24 +76,6 @@ def make_score_adata(adata, score_col): ) -def _compact_legend(ax, title="", per_col=20, fontsize=5): - """Recast an axis legend into multiple narrow columns so a high niche count - does not blow up the panel (~per_col entries per column).""" - leg = ax.get_legend() - if leg is None: - return - handles = (leg.legend_handles if hasattr(leg, "legend_handles") - else getattr(leg, "legendHandles", [])) - labels = [t.get_text() for t in leg.get_texts()] - if not handles: - return - ncol = max(1, (len(labels) + per_col - 1) // per_col) - ax.legend(handles, labels, loc="center left", bbox_to_anchor=(1.0, 0.5), - ncol=ncol, fontsize=fontsize, frameon=False, title=title, - title_fontsize=fontsize + 1, handletextpad=0.3, - columnspacing=0.6, labelspacing=0.25, borderaxespad=0.2) - - def annotate_ct_region_dotplot(annot_key, annotation_colors, region_colors): """Add cell type and region colour bars BETWEEN the row labels and the dot grid, with colour legends below. tick_params(pad) creates space @@ -181,8 +164,12 @@ def annotate_ct_region_dotplot(annot_key, annotation_colors, region_colors): def create_annotation_legend(annot_key, annotation_colors, region_colors, out_path, dpi): - """Create a standalone legend image for cell type + region colour bars. - Legends are stacked vertically so they never overlap.""" + """Create a temporary legend band composited beneath its dotplots. + + Cell-type and region legends use dedicated rows with adaptive height, so long + labels cannot overlap. The caller embeds this image in the same output PNG and + deletes the temporary file; it is never a separate report page. + """ try: from matplotlib.patches import Patch @@ -213,29 +200,41 @@ def create_annotation_legend(annot_key, annotation_colors, region_colors, if not unique_cts and not unique_regs: return - # Two axes stacked vertically — one legend per row, no overlap - n_rows = (1 if unique_cts else 0) + (1 if unique_regs else 0) - fig_leg, axes_leg = plt.subplots(n_rows, 1, figsize=(12, 1.5 * n_rows), - squeeze=False) - row = 0 + groups = [] if unique_cts: - ax = axes_leg[row, 0] - ax.set_axis_off() - ct_patches = [Patch(facecolor=c, label=n) for n, c in unique_cts.items()] - ax.legend(handles=ct_patches, title="Cell type", - loc="center", fontsize=12, title_fontsize=13, - frameon=True, edgecolor="lightgray", - ncol=max(1, len(unique_cts) // 4 + 1)) - row += 1 - + groups.append(("Cell type", unique_cts)) if unique_regs: - ax = axes_leg[row, 0] + groups.append(("Region", unique_regs)) + + layouts = [legend_layout(list(entries), preferred_rows=4) + for _, entries in groups] + heights = [height for _, _, height in layouts] + fig_leg = plt.figure(figsize=(12, sum(heights))) + grid = fig_leg.add_gridspec(len(groups), 1, height_ratios=heights, hspace=0.15) + for row, ((title, entries), (labels, ncol, _)) in enumerate( + zip(groups, layouts) + ): + ax = fig_leg.add_subplot(grid[row, 0]) ax.set_axis_off() - reg_patches = [Patch(facecolor=c, label=n) for n, c in unique_regs.items()] - ax.legend(handles=reg_patches, title="Region", - loc="center", fontsize=12, title_fontsize=13, - frameon=True, edgecolor="lightgray", - ncol=max(1, len(unique_regs) // 3 + 1)) + patches = [ + Patch(facecolor=color, label=label) + for label, color in entries.items() + ] + ax.legend( + handles=patches, + labels=labels, + title=title, + loc="upper center", + fontsize=12, + title_fontsize=13, + frameon=True, + edgecolor="lightgray", + ncol=ncol, + handletextpad=0.35, + columnspacing=0.8, + labelspacing=0.35, + borderaxespad=0.0, + ) fig_leg.savefig(out_path, dpi=dpi, bbox_inches="tight") plt.close(fig_leg) diff --git a/workflow/scripts/explore_genes_integrated.py b/workflow/scripts/explore_genes_integrated.py index 51d8de1..d87d30a 100644 --- a/workflow/scripts/explore_genes_integrated.py +++ b/workflow/scripts/explore_genes_integrated.py @@ -28,7 +28,12 @@ from explore_genes_common import ( read_tsv_to_dict, classify_entries, apply_annotation_palette, apply_region_palette, make_score_adata, annotate_ct_region_dotplot, create_annotation_legend, - composite_vertical, _compact_legend, + composite_vertical, +) +from plotting_legends import ( + category_labels, + move_legend_to_axis, + panel_figure, ) # Large cell type × region dotplots can exceed PIL's default pixel limit @@ -165,7 +170,8 @@ def generate_dotplots(adata, var_names, annot_key, has_regions, out_dir, plt.savefig(tmp, dpi=dpi, bbox_inches="tight") tmp_files.append(tmp) - # Standalone annotation legend (before plt.close so gcf() works) + # Temporary annotation-legend band (before plt.close so gcf() works). + # It is composited into the same output PNG below and then deleted. if annotation_colors or region_colors: tmp_leg = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name create_annotation_legend(annot_key, @@ -202,31 +208,45 @@ def generate_umap_composite(adata, color_col, annot_key, has_regions, out_path, multi-column legend for high niche counts.""" has_niche = bool(niche_col and niche_col in adata.obs.columns) n_panels = 2 + (1 if has_regions else 0) + (1 if has_niche else 0) - fig, axes = plt.subplots(1, n_panels, figsize=(8 * n_panels, 7), - gridspec_kw={"wspace": 0.5}) + panel_keys = [None, annot_key] + if has_regions: + panel_keys.append("region_annotation") + if has_niche: + panel_keys.append(niche_col) + labels_by_panel = [category_labels(adata, key) for key in panel_keys] + fig, plot_axes, legend_axes = panel_figure( + n_panels, + labels_by_panel, + panel_width=8, + plot_height=6, + wspace=0.18, + ) + axes = plot_axes[0] try: - sc.pl.umap(adata, color=color_col, size=2, frameon=False, + sc.pl.umap(adata, color=color_col, size=UMAP_POINT_SIZE, frameon=False, vmin=vmin, vmax=vmax, cmap="viridis", title=title, ax=axes[0], show=False) except Exception as e: log.warning(" UMAP %s failed: %s", title, e) try: - sc.pl.umap(adata, color=annot_key, size=2, frameon=False, - title="Cell types", legend_fontsize=6, + sc.pl.umap(adata, color=annot_key, size=UMAP_POINT_SIZE, frameon=False, + title="Cell types", legend_fontsize=12, na_in_legend=False, ax=axes[1], show=False) + move_legend_to_axis(axes[1], legend_axes[1], title="Cell types") except Exception as e: log.warning(" UMAP celltype failed: %s", e) idx = 2 if has_regions: try: - sc.pl.umap(adata, color="region_annotation", size=2, frameon=False, - title="Regions", legend_fontsize=6, + sc.pl.umap(adata, color="region_annotation", size=UMAP_POINT_SIZE, frameon=False, + title="Regions", legend_fontsize=12, na_in_legend=False, ax=axes[idx], show=False) + move_legend_to_axis(axes[idx], legend_axes[idx], title="Regions") except Exception as e: log.warning(" UMAP region failed: %s", e) idx += 1 @@ -239,10 +259,12 @@ def generate_umap_composite(adata, color_col, annot_key, has_regions, out_path, if niche_palette: adata.uns[f"{niche_col}_colors"] = [ niche_palette.get(str(c), "#cccccc") for c in cats] - sc.pl.umap(adata, color=niche_col, size=2, frameon=False, - title="Spatial niches", legend_fontsize=5, + sc.pl.umap(adata, color=niche_col, size=UMAP_POINT_SIZE, frameon=False, + title="Spatial niches", legend_fontsize=12, na_in_legend=False, ax=axes[idx], show=False) - _compact_legend(axes[idx], title="Niche") + move_legend_to_axis( + axes[idx], legend_axes[idx], title="Spatial niches", fontsize=12 + ) except Exception as e: log.warning(" UMAP niche failed: %s", e) idx += 1 @@ -261,6 +283,7 @@ def generate_umap_composite(adata, color_col, annot_key, has_regions, out_path, AUCELL_FRACTION = float(snakemake.params.aucell_fraction) NICHE_COLUMN = str(snakemake.params.niche_column) if snakemake.params.niche_column else "" DPI = int(snakemake.params.dpi) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) ANNOTATION_COLORS = snakemake.params.annotation_colors REGION_COLORS = snakemake.params.region_colors EXTRA_ANNOT_COLUMNS = list(getattr(snakemake.params, "extra_annot_columns", []) or []) @@ -299,7 +322,7 @@ def generate_umap_composite(adata, color_col, annot_key, has_regions, out_path, adata.uns[f"{_dc}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] _dd = os.path.join(base_dir, "_design") os.makedirs(_dd, exist_ok=True) - sc.pl.umap(adata, color=[_dc], size=2, frameon=False, + sc.pl.umap(adata, color=[_dc], size=UMAP_POINT_SIZE, frameon=False, title=f"Integrated – by {_dc}", show=False) plt.savefig(os.path.join(_dd, f"UMAP_by_{_dc}.png"), dpi=DPI, bbox_inches="tight") diff --git a/workflow/scripts/explore_genes_sample.py b/workflow/scripts/explore_genes_sample.py index b58d98e..8b31700 100644 --- a/workflow/scripts/explore_genes_sample.py +++ b/workflow/scripts/explore_genes_sample.py @@ -33,7 +33,12 @@ from explore_genes_common import ( read_tsv_to_dict, classify_entries, apply_annotation_palette, apply_region_palette, make_score_adata, annotate_ct_region_dotplot, create_annotation_legend, - composite_vertical, _compact_legend, + composite_vertical, +) +from plotting_legends import ( + category_labels, + move_legend_to_axis, + panel_figure, ) # Large cell type × region dotplots can exceed PIL's default pixel limit @@ -72,13 +77,26 @@ def generate_spatial_composite(adata, color_col, annot_key, has_regions, multi-column legend so a high niche count does not distort the composite.""" has_niche = bool(niche_col and niche_col in adata.obs.columns) n_panels = 2 + (1 if has_regions else 0) + (1 if has_niche else 0) - fig, axes = plt.subplots(1, n_panels, figsize=(8 * n_panels, 7), - gridspec_kw={"wspace": 0.5}) + panel_keys = [None, annot_key] + if has_regions: + panel_keys.append("region_annotation") + if has_niche: + panel_keys.append(niche_col) + labels_by_panel = [category_labels(adata, key) for key in panel_keys] + fig, plot_axes, legend_axes = panel_figure( + n_panels, + labels_by_panel, + panel_width=8, + plot_height=6, + wspace=0.18, + top_margin=0.8, + ) + axes = plot_axes[0] fig.suptitle(sample_id, fontsize=16, fontweight="bold", y=1.02) # (1) Expression / AUCell try: - sc.pl.spatial(adata, color=color_col, spot_size=20, frameon=False, + sc.pl.spatial(adata, color=color_col, spot_size=SPATIAL_POINT_SIZE, frameon=False, vmin=vmin, vmax=vmax, cmap="viridis", title=title, library_id=library_id, ax=axes[0], show=False) @@ -88,10 +106,11 @@ def generate_spatial_composite(adata, color_col, annot_key, has_regions, # (2) Cell type try: - sc.pl.spatial(adata, color=annot_key, spot_size=20, frameon=False, + sc.pl.spatial(adata, color=annot_key, spot_size=SPATIAL_POINT_SIZE, frameon=False, title="Cell types", library_id=library_id, - legend_fontsize=6, na_in_legend=False, + legend_fontsize=12, na_in_legend=False, ax=axes[1], show=False) + move_legend_to_axis(axes[1], legend_axes[1], title="Cell types") except Exception as e: log.warning(" Spatial celltype failed: %s", e) axes[1].set_title("Cell types (failed)") @@ -100,11 +119,12 @@ def generate_spatial_composite(adata, color_col, annot_key, has_regions, # (3) Region if has_regions: try: - sc.pl.spatial(adata, color="region_annotation", spot_size=20, + sc.pl.spatial(adata, color="region_annotation", spot_size=SPATIAL_POINT_SIZE, frameon=False, title="Regions", library_id=library_id, - legend_fontsize=6, na_in_legend=False, + legend_fontsize=12, na_in_legend=False, ax=axes[idx], show=False) + move_legend_to_axis(axes[idx], legend_axes[idx], title="Regions") except Exception as e: log.warning(" Spatial region failed: %s", e) axes[idx].set_title("Regions (failed)") @@ -120,11 +140,13 @@ def generate_spatial_composite(adata, color_col, annot_key, has_regions, if niche_palette: adata.uns[f"{niche_col}_colors"] = [ niche_palette.get(str(c), "#cccccc") for c in cats] - sc.pl.spatial(adata, color=niche_col, spot_size=20, frameon=False, + sc.pl.spatial(adata, color=niche_col, spot_size=SPATIAL_POINT_SIZE, frameon=False, title="Spatial niches", library_id=library_id, - legend_fontsize=5, na_in_legend=False, + legend_fontsize=12, na_in_legend=False, ax=axes[idx], show=False) - _compact_legend(axes[idx], title="Niche") + move_legend_to_axis( + axes[idx], legend_axes[idx], title="Spatial niches", fontsize=12 + ) except Exception as e: log.warning(" Spatial niche failed: %s", e) axes[idx].set_title("Spatial niches (failed)") @@ -197,8 +219,9 @@ def generate_dotplot_composite(adata, var_names, annot_key, has_regions, plt.savefig(tmp, dpi=dpi, bbox_inches="tight") tmp_files.append(tmp) - # Standalone annotation legend (before plt.close so gcf() can - # still read the dotplot's ytick labels) + # Temporary annotation-legend band (before plt.close so gcf() can + # still read the dotplot's ytick labels). It is composited into the + # same output PNG below and then deleted. if annotation_colors or region_colors: tmp_leg = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name create_annotation_legend(annot_key, @@ -235,6 +258,7 @@ def generate_dotplot_composite(adata, var_names, annot_key, has_regions, ANNOT_KEY = str(snakemake.params.annot_key) AUCELL_FRACTION = float(snakemake.params.aucell_fraction) DPI = int(snakemake.params.dpi) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) ANNOTATION_COLORS = snakemake.params.annotation_colors REGION_COLORS = snakemake.params.region_colors NICHE_COLUMN = str(getattr(snakemake.params, "niche_column", "") or "") diff --git a/workflow/scripts/external_metadata.py b/workflow/scripts/external_metadata.py new file mode 100644 index 0000000..1d8b8e7 --- /dev/null +++ b/workflow/scripts/external_metadata.py @@ -0,0 +1,77 @@ +"""Read cell metadata without changing the user-provided TSV.""" +from __future__ import annotations + +from collections.abc import Iterable +from os import PathLike + +import pandas as pd + + +_MISSING_LABELS = {"", "na", "n/a", "nan", "none", "null", "unannotated"} + + +def optional_input_path(value) -> str: + """Return a path from an optional Snakemake input value.""" + if value is None: + return "" + if isinstance(value, (str, PathLike)): + return str(value) + return str(value[0]) if len(value) else "" + + +def read_cell_metadata( + path: str, + *, + id_column: str = "cell_id", + required_columns: Iterable[str] = (), +) -> pd.DataFrame: + """Load a TSV and use ``cell_id`` as its index when that column is present. + + All input columns are retained. Empty and duplicate cell identifiers are rejected + because either case would make downstream joins ambiguous. + """ + metadata = pd.read_csv(path, sep="\t", index_col=0, comment="#", dtype=str) + if metadata.empty: + raise ValueError(f"Cell metadata is empty: {path}") + + missing_columns = [column for column in required_columns if column not in metadata] + if missing_columns: + raise ValueError( + f"Required column(s) {missing_columns} not found in {path}; " + f"available columns: {list(metadata.columns)}" + ) + + if id_column in metadata.columns: + cell_ids = metadata[id_column].astype("string").str.strip() + else: + cell_ids = pd.Series(metadata.index, index=metadata.index, dtype="string").str.strip() + + missing_ids = cell_ids.isna() | cell_ids.eq("") + if bool(missing_ids.any()): + raise ValueError( + f"Cell metadata {path} contains {int(missing_ids.sum())} empty " + f"'{id_column}' value(s)." + ) + + duplicate_ids = cell_ids.duplicated(keep=False) + if bool(duplicate_ids.any()): + examples = cell_ids[duplicate_ids].drop_duplicates().head(5).tolist() + raise ValueError( + f"Cell metadata {path} contains {int(duplicate_ids.sum())} rows with " + f"duplicate '{id_column}' values (examples: {examples})." + ) + + metadata.index = pd.Index(cell_ids.astype(str), name=id_column) + return metadata + + +def normalized_labels(metadata: pd.DataFrame, label_column: str) -> pd.Series: + """Return trimmed labels, representing blank/unannotated values as missing.""" + labels = metadata[label_column].astype("string").str.strip() + missing = labels.isna() | labels.str.casefold().isin(_MISSING_LABELS) + return labels.mask(missing) + + +def annotated_cell_ids(metadata: pd.DataFrame, label_column: str) -> set[str]: + """Return IDs whose external annotation is non-empty and not ``Unannotated``.""" + return set(normalized_labels(metadata, label_column).dropna().index.astype(str)) diff --git a/workflow/scripts/ingest_ref.py b/workflow/scripts/ingest_ref.py index 480b217..e4b5f24 100644 --- a/workflow/scripts/ingest_ref.py +++ b/workflow/scripts/ingest_ref.py @@ -47,6 +47,8 @@ ref_label_key = snakemake.params.ref_label_key DE_N_GENES = int(snakemake.params.de_n_genes) ANNOTATION_COLORS = snakemake.params.annotation_colors +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) adata_path = str(snakemake.input.adata) ref_path = str(snakemake.input.ingest_ref) @@ -139,7 +141,7 @@ library_id = list(adata.uns["spatial"].keys())[0] # UMAP (original embedding) coloured by ingest labels - sc.pl.umap(adata, color=["cell_type_ingest"], size=2, frameon=False, + sc.pl.umap(adata, color=["cell_type_ingest"], size=UMAP_POINT_SIZE, frameon=False, title="Ingest annotation (original UMAP)") plt.savefig(os.path.join(plots_dir, f"UMAP_ingest_{sample_id}.png"), dpi=300, bbox_inches="tight") @@ -148,7 +150,7 @@ # UMAP from ingest projection adata.obsm["X_umap_orig"] = adata.obsm["X_umap"].copy() adata.obsm["X_umap"] = adata.obsm["X_umap_ingest"] - sc.pl.umap(adata, color=["cell_type_ingest"], size=2, frameon=False, + sc.pl.umap(adata, color=["cell_type_ingest"], size=UMAP_POINT_SIZE, frameon=False, title="Ingest annotation (projected UMAP)") plt.savefig(os.path.join(plots_dir, f"UMAP_ingest_projected_{sample_id}.png"), dpi=300, bbox_inches="tight") @@ -159,7 +161,7 @@ # Spatial overview try: - sc.pl.spatial(adata, color="cell_type_ingest", spot_size=20, + sc.pl.spatial(adata, color="cell_type_ingest", spot_size=SPATIAL_POINT_SIZE, frameon=False, title="Ingest – spatial", library_id=library_id) plt.savefig(os.path.join(plots_dir, f"spatial_ingest_{sample_id}.png"), @@ -176,7 +178,7 @@ lambda x, c=ct: c if x == c else "Other" ) try: - sc.pl.spatial(adata, color="_hl", spot_size=20, frameon=False, + sc.pl.spatial(adata, color="_hl", spot_size=SPATIAL_POINT_SIZE, frameon=False, palette={"Other": "#d3d3d3", ct: "#e41a1c"}, title=ct, library_id=library_id) safe = ct.replace("/", "_").replace(" ", "_") diff --git a/workflow/scripts/integrate_samples.py b/workflow/scripts/integrate_samples.py index fd3ddb2..4fa931f 100644 --- a/workflow/scripts/integrate_samples.py +++ b/workflow/scripts/integrate_samples.py @@ -1,414 +1,478 @@ -""" -integrate_samples.py – Multi-sample integration -================================================= -1. Concatenate all annotated samples -2. PCA + UMAP (no batch correction) → concatenated.h5ad -3. Harmony integration + UMAP → harmony_integrated.h5ad -4. Geosketch 25% of the concatenated data, cluster the sketch, - ingest-project labels onto the full dataset → sketched.h5ad -""" - -import gc -import logging -import os -import sys -import traceback -from pathlib import Path - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -import scanpy as sc -import scanpy.external as sce - -# Shared composition-barplot helpers (black edge, legend==stack order, config colours) -try: - _here = os.path.dirname(os.path.abspath(__file__)) -except NameError: # very old Snakemake - _here = os.getcwd() -sys.path.insert(0, _here) -from composition_barplots import ( - composition_pair, find_niche_column, composition_grouped, - build_niche_palette, -) - -try: - import geosketch as sketch - HAS_GEOSKETCH = True -except ImportError: - HAS_GEOSKETCH = False - - -# ── Logging ────────────────────────────────────────────────────────────────── -log_handlers = [logging.StreamHandler(sys.stderr)] -if hasattr(snakemake, "log"): - if snakemake.log.out: - Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) - log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) - if snakemake.log.err: - Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) - sys.stderr = open(snakemake.log.err, "w") - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-8s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - handlers=log_handlers, +""" +integrate_samples.py – Multi-sample integration +================================================= +1. Concatenate all annotated samples +2. PCA + UMAP (no batch correction) → concatenated.h5ad +3. Harmony integration + UMAP → harmony_integrated.h5ad +4. Geosketch 25% of the concatenated data, cluster the sketch, + ingest-project labels onto the full dataset → sketched.h5ad +""" + +import gc +import logging +import os +import sys +import traceback +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import scanpy as sc +import scanpy.external as sce + +# Shared composition-barplot helpers (black edge, legend==stack order, config colours) +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from composition_barplots import ( + composition_pair, find_niche_column, composition_grouped, + build_niche_palette, +) +from annotation_utils import ( + has_meaningful_annotation, + ordered_annotation_columns, + record_active_annotation_columns, ) -log = logging.getLogger("integrate_samples") - -# ── Parameters ─────────────────────────────────────────────────────────────── -annotated_paths = [str(p) for p in snakemake.input.annotated] -sample_ids = list(snakemake.params.sample_ids) -N_NEIGHBORS = int(snakemake.params.n_neighbors) + +try: + import geosketch as sketch + HAS_GEOSKETCH = True +except ImportError: + HAS_GEOSKETCH = False + + +# ── Logging ────────────────────────────────────────────────────────────────── +log_handlers = [logging.StreamHandler(sys.stderr)] +if hasattr(snakemake, "log"): + if snakemake.log.out: + Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) + log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) + if snakemake.log.err: + Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) + sys.stderr = open(snakemake.log.err, "w") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=log_handlers, +) +log = logging.getLogger("integrate_samples") + +# ── Parameters ─────────────────────────────────────────────────────────────── +annotated_paths = [str(p) for p in snakemake.input.annotated] +sample_ids = list(snakemake.params.sample_ids) +N_NEIGHBORS = int(snakemake.params.n_neighbors) SKETCH_FRAC = float(snakemake.params.sketch_fraction) +PRIMARY_ANNOTATION_COLUMN = str( + getattr(snakemake.params, "primary_annotation_column", "") or "" +) ANNOTATION_COLORS = snakemake.params.annotation_colors -REGION_COLORS = snakemake.params.region_colors -DPI = int(getattr(snakemake.params, "dpi", 300)) -NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") -EXTRA_ANNOT_COLUMNS = list(getattr(snakemake.params, "extra_annot_columns", []) or []) -SAMPLE_COLORS = getattr(snakemake.params, "sample_colors", {}) or {} +REGION_COLORS = snakemake.params.region_colors +DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") +EXTRA_ANNOT_COLUMNS = list(getattr(snakemake.params, "extra_annot_columns", []) or []) +SAMPLE_COLORS = getattr(snakemake.params, "sample_colors", {}) or {} INTEGRATE_KEY = str(getattr(snakemake.params, "integrate_key", "sample") or "sample") - - -def _has_real_annotation(ad, col): - """True when an annotation column carries at least one meaningful label. External-only - runs leave cell_type_tsv all-'Unannotated', so colouring by it yields a single grey, - meaningless UMAP/barplot — skip those rather than emit them.""" - if not col or col not in ad.obs.columns: - return False - vals = set(ad.obs[col].astype(str).str.strip().unique()) - return len(vals - {"Unannotated", "", "nan", "NA", "None"}) > 0 - - -def _apply_design_palette(ad, col): - """Colour an obs design column from SAMPLE_COLORS. Values absent from a CONFIGURED - palette fall back to grey (#cccccc); when NO palette is configured for the column, - leave the colours to scanpy (drop any stale *_colors) instead of painting everything - grey — matching the project-wide palette convention.""" - if col not in ad.obs.columns: - return False - ad.obs[col] = ad.obs[col].astype(str).astype("category") - cats = ad.obs[col].cat.categories - pal = SAMPLE_COLORS.get(col, {}) if isinstance(SAMPLE_COLORS, dict) else {} - if not pal: - ad.uns.pop(f"{col}_colors", None) # no palette -> scanpy default colours - else: - ad.uns[f"{col}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] - return True - - +RANDOM_SEED = int(getattr(snakemake.params, "random_seed", 0)) + +# Scanpy recommends the igraph backend with two iterations for faster Leiden +# clustering, especially on large datasets. The igraph backend requires an +# undirected graph. +LEIDEN_KWARGS = { + "flavor": "igraph", + "n_iterations": 2, + "directed": False, + "random_state": RANDOM_SEED, +} + + +def _apply_design_palette(ad, col): + """Colour an obs design column from SAMPLE_COLORS. Values absent from a CONFIGURED + palette fall back to grey (#cccccc); when NO palette is configured for the column, + leave the colours to scanpy (drop any stale *_colors) instead of painting everything + grey — matching the project-wide palette convention.""" + if col not in ad.obs.columns: + return False + ad.obs[col] = ad.obs[col].astype(str).astype("category") + cats = ad.obs[col].cat.categories + pal = SAMPLE_COLORS.get(col, {}) if isinstance(SAMPLE_COLORS, dict) else {} + if not pal: + ad.uns.pop(f"{col}_colors", None) # no palette -> scanpy default colours + else: + ad.uns[f"{col}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] + return True + + def _niche_umap(ad, title, out_path): - """UMAP coloured by spatial niche (config/deterministic niche palette), if the niche - column is present. Skipped silently when spatial niches were not identified.""" - niche_col = find_niche_column(ad, NICHE_COLUMN) - if not niche_col or niche_col not in ad.obs.columns: - return - try: - ad.obs[niche_col] = ad.obs[niche_col].astype(str).astype("category") - cats = list(ad.obs[niche_col].cat.categories) - niche_cfg = (ANNOTATION_COLORS.get("spatial_niche", {}) - if isinstance(ANNOTATION_COLORS, dict) else {}) - pal = build_niche_palette(cats, niche_cfg) - ad.uns[f"{niche_col}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] - sc.pl.umap(ad, color=[niche_col], size=2, frameon=False, title=title) - plt.savefig(out_path, dpi=DPI, bbox_inches="tight") - plt.close() + """UMAP coloured by spatial niche (config/deterministic niche palette), if the niche + column is present. Skipped silently when spatial niches were not identified.""" + niche_col = find_niche_column(ad, NICHE_COLUMN) + if not niche_col or niche_col not in ad.obs.columns: + return + try: + ad.obs[niche_col] = ad.obs[niche_col].astype(str).astype("category") + cats = list(ad.obs[niche_col].cat.categories) + niche_cfg = (ANNOTATION_COLORS.get("spatial_niche", {}) + if isinstance(ANNOTATION_COLORS, dict) else {}) + pal = build_niche_palette(cats, niche_cfg) + ad.uns[f"{niche_col}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] + sc.pl.umap(ad, color=[niche_col], size=UMAP_POINT_SIZE, frameon=False, title=title) + plt.savefig(out_path, dpi=DPI, bbox_inches="tight") + plt.close() except Exception as e: log.warning(" niche UMAP failed (%s): %s", out_path, e) -out_concat = str(snakemake.output.concatenated) -out_harmony = str(snakemake.output.harmony) -out_sketched = str(snakemake.output.sketched) -output_dir = str(Path(out_concat).parent) - -try: - log.info("=" * 70) - log.info("Integrating %d samples", len(annotated_paths)) - log.info("=" * 70) - - Path(output_dir).mkdir(parents=True, exist_ok=True) - - # ── 1. Load and concatenate ────────────────────────────────────────── - adatas = [] - for path, sid in zip(annotated_paths, sample_ids): - log.info(" Loading %s …", sid) - ad = sc.read_h5ad(path) - # Ensure sample label is present - if "sample" not in ad.obs.columns: - ad.obs["sample"] = sid - adatas.append(ad) - - log.info("Concatenating …") - adata = sc.concat(adatas, join="inner", label="sample_batch", keys=sample_ids) - log.info(" Concatenated: %d cells, %d genes", adata.n_obs, adata.n_vars) - - # Carry over raw_counts if available - if "raw_counts" in adatas[0].layers: - # sc.concat preserves layers if they exist in all objects - if "raw_counts" not in adata.layers: - log.warning(" raw_counts layer lost during concatenation") - - del adatas - gc.collect() - - # ── 2. PCA + UMAP (no batch correction) ────────────────────────────── - log.info("PCA + UMAP (uncorrected) …") - sc.pp.pca(adata, use_highly_variable=False) - sc.pp.neighbors(adata, n_neighbors=N_NEIGHBORS) - sc.tl.umap(adata) - - # Leiden on uncorrected - sc.tl.leiden(adata, resolution=0.5, key_added="leiden_uncorrected") - - log.info("Saving concatenated → %s", out_concat) - adata.write(out_concat) - - # Apply custom palettes if configured - if isinstance(ANNOTATION_COLORS, dict): - for obs_key in ["sample_batch", "cell_type_tsv", - "cell_type_ingest", "cell_type_external", "leiden_uncorrected"]: - cd = ANNOTATION_COLORS.get(obs_key, {}) - if cd and obs_key in adata.obs.columns: - cats = adata.obs[obs_key].cat.categories if hasattr(adata.obs[obs_key], "cat") else [] - if len(cats) > 0: - adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] - - # Plot uncorrected - sc.pl.umap(adata, color=["sample_batch"], size=2, frameon=False, - title="Uncorrected – by sample") - plt.savefig(os.path.join(output_dir, "UMAP_uncorrected_by_sample.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - if _has_real_annotation(adata, "cell_type_tsv"): - sc.pl.umap(adata, color=["cell_type_tsv"], size=2, frameon=False, - title="Uncorrected – by cell type (TSV)") - plt.savefig(os.path.join(output_dir, "UMAP_uncorrected_by_celltype.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - # Uncorrected UMAPs by each design column (grey for palette-less values) - for _dc in EXTRA_ANNOT_COLUMNS: - if _apply_design_palette(adata, _dc): - try: - sc.pl.umap(adata, color=[_dc], size=2, frameon=False, - title=f"Uncorrected – by {_dc}") - plt.savefig(os.path.join(output_dir, f"UMAP_uncorrected_by_{_dc}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - except Exception as e: - log.warning(" design UMAP (uncorrected) %s failed: %s", _dc, e) - _niche_umap(adata, "Uncorrected – by spatial niche", - os.path.join(output_dir, "UMAP_uncorrected_by_spatial_niche.png")) - - # ── 3. Harmony integration ─────────────────────────────────────────── - log.info("Harmony integration …") - adata_harmony = adata.copy() - adata_harmony.obsm["X_pca_uncorrected"] = adata_harmony.obsm["X_pca"].copy() - - # Integration key: "sample" (default) maps to the concat label "sample_batch"; - # any other value is treated as a design/obs column to correct on. - harmony_key = "sample_batch" if INTEGRATE_KEY == "sample" else INTEGRATE_KEY - if harmony_key not in adata_harmony.obs.columns: - log.warning(" integrate_key '%s' not in obs; falling back to 'sample_batch'", - harmony_key) - harmony_key = "sample_batch" - log.info(" Harmony key: %s", harmony_key) - sc.external.pp.harmony_integrate(adata_harmony, key=harmony_key) - adata_harmony.obsm["X_pca"] = adata_harmony.obsm["X_pca_harmony"] - - sc.pp.neighbors(adata_harmony, n_neighbors=N_NEIGHBORS) - sc.tl.umap(adata_harmony) - sc.tl.leiden(adata_harmony, resolution=0.5, key_added="leiden_harmony") - - log.info("Saving Harmony integrated → %s", out_harmony) - adata_harmony.write(out_harmony) - - # Copy palettes to harmony object - for key in list(adata.uns.keys()): - if key.endswith("_colors") and key not in adata_harmony.uns: - adata_harmony.uns[key] = adata.uns[key] - - # Plots - sc.pl.umap(adata_harmony, color=["sample_batch"], size=2, frameon=False, - title="Harmony – by sample") - plt.savefig(os.path.join(output_dir, "UMAP_harmony_by_sample.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - if _has_real_annotation(adata_harmony, "cell_type_tsv"): - sc.pl.umap(adata_harmony, color=["cell_type_tsv"], size=2, frameon=False, - title="Harmony – by cell type (TSV)") - plt.savefig(os.path.join(output_dir, "UMAP_harmony_by_celltype.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - # Harmony UMAPs by each design column (grey for palette-less values) - for _dc in EXTRA_ANNOT_COLUMNS: - if _apply_design_palette(adata_harmony, _dc): - try: - sc.pl.umap(adata_harmony, color=[_dc], size=2, frameon=False, - title=f"Harmony – by {_dc}") - plt.savefig(os.path.join(output_dir, f"UMAP_harmony_by_{_dc}.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - except Exception as e: - log.warning(" design UMAP (harmony) %s failed: %s", _dc, e) - - _niche_umap(adata_harmony, "Harmony – by spatial niche", - os.path.join(output_dir, "UMAP_harmony_by_spatial_niche.png")) - - # ── Composition barplots (sample / region / niche) ─────────────────── - log.info("Composition barplots …") - bar_dir = os.path.join(output_dir, "barplots") - os.makedirs(bar_dir, exist_ok=True) - annot_cols = [c for c in ["cell_type_tsv", "cell_type_ingest", - "cell_type_external"] - if _has_real_annotation(adata, c)] - has_regions = ("region_annotation" in adata.obs.columns - and adata.obs["region_annotation"].nunique() > 1 - and not all(adata.obs["region_annotation"] == "Unlabeled")) - niche_col = find_niche_column(adata, NICHE_COLUMN) - - for annot_col in annot_cols: - cmap = (ANNOTATION_COLORS.get(annot_col, {}) - if isinstance(ANNOTATION_COLORS, dict) else {}) - label = annot_col.replace("cell_type_", "") - composition_pair(adata, "sample_batch", annot_col, cmap, bar_dir, - f"barplot_{label}_by_sample", group_label="Sample", - cat_label=label, dpi=DPI) - if has_regions: - composition_pair(adata, "region_annotation", annot_col, cmap, - bar_dir, f"barplot_{label}_by_region", - group_label="Region", cat_label=label, dpi=DPI) - if niche_col: - composition_pair(adata, niche_col, annot_col, cmap, bar_dir, - f"barplot_{label}_by_niche", group_label="Niche", - cat_label=label, dpi=DPI) - - # Sample × region cell-type composition — two single-axis grouped layouts, - # absolute + relative: - # • grouped by sample (sample headers, regions within each group) - # • grouped by region (region headers, samples within each group) - if has_regions and annot_cols: - primary = annot_cols[0] - pcmap = (ANNOTATION_COLORS.get(primary, {}) - if isinstance(ANNOTATION_COLORS, dict) else {}) - plabel = primary.replace("cell_type_", "") - region_order = (list(REGION_COLORS.keys()) - if isinstance(REGION_COLORS, dict) and REGION_COLORS else None) - sample_order = sorted(adata.obs["sample_batch"].astype(str).unique()) - for norm, suffix, yl in [(False, "absolute", "Number of cells"), - (True, "relative", "Percentage (%)")]: - composition_grouped( - adata, "sample_batch", "region_annotation", primary, pcmap, - os.path.join(bar_dir, f"barplot_{plabel}_grouped_by_sample_{suffix}.png"), - normalize=norm, outer_order=sample_order, inner_order=region_order, - cat_label=plabel, ylabel=yl, dpi=DPI) - composition_grouped( - adata, "region_annotation", "sample_batch", primary, pcmap, - os.path.join(bar_dir, f"barplot_{plabel}_grouped_by_region_{suffix}.png"), - normalize=norm, outer_order=region_order, inner_order=sample_order, - cat_label=plabel, ylabel=yl, dpi=DPI) - - # ── Spatial-niche composition (niche as the stack) ─────────────────── - # Niche colours come from the shared value-deterministic palette so they - # match the spatial_niches rule. (Cell-type-by-niche, with niche as the - # x-axis, is already produced inside the annot loop above.) - if niche_col: - nvals = adata.obs[niche_col].astype(str) +def _annotation_umaps(ad, stage, annotation_columns, primary_column): + """Plot every active annotation and keep the legacy primary output name.""" + stage_title = "Harmony" if stage == "harmony" else "Uncorrected" + for column in annotation_columns: + if not has_meaningful_annotation(ad, column): + continue + label = column.removeprefix("cell_type_") try: - ncats = sorted(nvals.unique(), key=lambda x: int(x)) - except ValueError: - ncats = sorted(nvals.unique()) - niche_cfg = (ANNOTATION_COLORS.get("spatial_niche", {}) - if isinstance(ANNOTATION_COLORS, dict) else {}) - niche_pal = build_niche_palette(ncats, niche_cfg) - - # (1) spatial-niche composition per sample (absolute + relative) - composition_pair(adata, "sample_batch", niche_col, niche_pal, bar_dir, - "barplot_spatial_niche_by_sample", - group_label="Sample", cat_label="Niche", dpi=DPI) - - # (2) grouped by area (region), composition by spatial niche - # (absolute + relative; mirrors barplot_*_grouped_by_region) - if has_regions: - region_order2 = (list(REGION_COLORS.keys()) - if isinstance(REGION_COLORS, dict) and REGION_COLORS - else None) - sample_order2 = sorted(adata.obs["sample_batch"].astype(str).unique()) - for norm, suffix, yl in [(False, "absolute", "Number of cells"), - (True, "relative", "Percentage (%)")]: - composition_grouped( - adata, "region_annotation", "sample_batch", niche_col, - niche_pal, - os.path.join(bar_dir, - f"barplot_spatial_niche_grouped_by_region_{suffix}.png"), - normalize=norm, outer_order=region_order2, - inner_order=sample_order2, cat_label="Niche", ylabel=yl, - dpi=DPI) - - # ── 4. Geosketch ───────────────────────────────────────────────────── - if not HAS_GEOSKETCH: - log.warning("geosketch not installed — skipping sketching. " - "Saving Harmony object as sketch placeholder.") - adata_harmony.write(out_sketched) - else: - log.info("Geosketching %.0f%% of %d cells …", SKETCH_FRAC * 100, adata.n_obs) - n_sketch = max(100, int(adata.n_obs * SKETCH_FRAC)) - - # Sketch from PCA embedding - pca_data = adata.obsm["X_pca"] - sketch_indices = sketch.gs(pca_data, n_sketch, replace=False) - - sketched_adata = adata[sketch_indices].copy() - log.info(" Sketched: %d cells", sketched_adata.n_obs) - - # Cluster the sketch - sc.pp.neighbors(sketched_adata, n_neighbors=N_NEIGHBORS) - sc.tl.umap(sketched_adata) - sc.tl.leiden(sketched_adata, resolution=0.5, key_added="clusters") - - # Project labels back to full dataset via ingest (in chunks) - log.info(" Projecting sketch clusters onto full dataset …") - chunk_size = 100000 - n_chunks = (adata.n_obs + chunk_size - 1) // chunk_size - ingested_chunks = [] - - for i in range(n_chunks): - start = i * chunk_size - end = min((i + 1) * chunk_size, adata.n_obs) - chunk = adata[start:end].copy() - sc.tl.ingest(chunk, sketched_adata, obs="clusters") - ingested_chunks.append(chunk) - log.info(" Ingested chunk %d/%d (%d cells)", i + 1, n_chunks, chunk.n_obs) - - adata_ingested = sc.concat(ingested_chunks) - - log.info("Saving sketched → %s", out_sketched) - adata_ingested.write(out_sketched) - - # Plot - sc.pl.umap(adata_ingested, color=["clusters"], size=2, frameon=False, - title="Geosketch – ingested clusters") - plt.savefig(os.path.join(output_dir, "UMAP_sketched_clusters.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - sc.pl.umap(adata_ingested, color=["sample_batch"], size=2, frameon=False, - title="Geosketch – by sample") - plt.savefig(os.path.join(output_dir, "UMAP_sketched_by_sample.png"), - dpi=DPI, bbox_inches="tight") - plt.close() - - del ingested_chunks, sketched_adata - gc.collect() - - log.info("Integration complete.") - -except Exception: - log.error("FAILED:\n%s", traceback.format_exc()) - raise + sc.pl.umap( + ad, + color=[column], + size=UMAP_POINT_SIZE, + frameon=False, + title=f"{stage_title} – by cell type ({label})", + ) + plt.savefig( + os.path.join(output_dir, f"UMAP_{stage}_by_{label}.png"), + dpi=DPI, + bbox_inches="tight", + ) + if column == primary_column: + plt.savefig( + os.path.join(output_dir, f"UMAP_{stage}_by_celltype.png"), + dpi=DPI, + bbox_inches="tight", + ) + plt.close() + except Exception as e: + log.warning(" annotation UMAP (%s, %s) failed: %s", stage, column, e) + +out_concat = str(snakemake.output.concatenated) +out_harmony = str(snakemake.output.harmony) +out_sketched = str(snakemake.output.sketched) +output_dir = str(Path(out_concat).parent) + +try: + log.info("=" * 70) + log.info("Integrating %d samples", len(annotated_paths)) + log.info("=" * 70) + + Path(output_dir).mkdir(parents=True, exist_ok=True) + + # ── 1. Load and concatenate ────────────────────────────────────────── + adatas = [] + for path, sid in zip(annotated_paths, sample_ids): + log.info(" Loading %s …", sid) + ad = sc.read_h5ad(path) + # Ensure sample label is present + if "sample" not in ad.obs.columns: + ad.obs["sample"] = sid + adatas.append(ad) + + log.info("Concatenating …") + adata = sc.concat(adatas, join="inner", label="sample_batch", keys=sample_ids) + log.info(" Concatenated: %d cells, %d genes", adata.n_obs, adata.n_vars) + record_active_annotation_columns( + adata, + preferred=PRIMARY_ANNOTATION_COLUMN, + ) + active_annotations = ordered_annotation_columns( + adata, + preferred=PRIMARY_ANNOTATION_COLUMN, + ) + primary_annotation = active_annotations[0] if active_annotations else None + log.info(" Active annotation columns: %s", active_annotations or "none") + log.info(" Primary annotation column: %s", primary_annotation or "none") + + # Carry over raw_counts if available + if "raw_counts" in adatas[0].layers: + # sc.concat preserves layers if they exist in all objects + if "raw_counts" not in adata.layers: + log.warning(" raw_counts layer lost during concatenation") + + del adatas + gc.collect() + + # ── 2. PCA + UMAP (no batch correction) ────────────────────────────── + log.info("PCA + UMAP (uncorrected) …") + sc.pp.pca(adata, use_highly_variable=False) + sc.pp.neighbors(adata, n_neighbors=N_NEIGHBORS) + sc.tl.umap(adata) + + # Leiden on uncorrected + sc.tl.leiden( + adata, + resolution=0.5, + key_added="leiden_uncorrected", + **LEIDEN_KWARGS, + ) + + log.info("Saving concatenated → %s", out_concat) + adata.write(out_concat) + + # Apply custom palettes if configured + if isinstance(ANNOTATION_COLORS, dict): + for obs_key in ["sample_batch", *active_annotations, "leiden_uncorrected"]: + cd = ANNOTATION_COLORS.get(obs_key, {}) + if cd and obs_key in adata.obs.columns: + cats = adata.obs[obs_key].cat.categories if hasattr(adata.obs[obs_key], "cat") else [] + if len(cats) > 0: + adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] + + # Plot uncorrected + sc.pl.umap(adata, color=["sample_batch"], size=UMAP_POINT_SIZE, frameon=False, + title="Uncorrected – by sample") + plt.savefig(os.path.join(output_dir, "UMAP_uncorrected_by_sample.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + _annotation_umaps( + adata, + "uncorrected", + active_annotations, + primary_annotation, + ) + + # Uncorrected UMAPs by each design column (grey for palette-less values) + for _dc in EXTRA_ANNOT_COLUMNS: + if _apply_design_palette(adata, _dc): + try: + sc.pl.umap(adata, color=[_dc], size=UMAP_POINT_SIZE, frameon=False, + title=f"Uncorrected – by {_dc}") + plt.savefig(os.path.join(output_dir, f"UMAP_uncorrected_by_{_dc}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + except Exception as e: + log.warning(" design UMAP (uncorrected) %s failed: %s", _dc, e) + + _niche_umap(adata, "Uncorrected – by spatial niche", + os.path.join(output_dir, "UMAP_uncorrected_by_spatial_niche.png")) + + # ── 3. Harmony integration ─────────────────────────────────────────── + log.info("Harmony integration …") + adata_harmony = adata.copy() + adata_harmony.obsm["X_pca_uncorrected"] = adata_harmony.obsm["X_pca"].copy() + + # Integration key: "sample" (default) maps to the concat label "sample_batch"; + # any other value is treated as a design/obs column to correct on. + harmony_key = "sample_batch" if INTEGRATE_KEY == "sample" else INTEGRATE_KEY + if harmony_key not in adata_harmony.obs.columns: + log.warning(" integrate_key '%s' not in obs; falling back to 'sample_batch'", + harmony_key) + harmony_key = "sample_batch" + log.info(" Harmony key: %s", harmony_key) + sc.external.pp.harmony_integrate(adata_harmony, key=harmony_key) + adata_harmony.obsm["X_pca"] = adata_harmony.obsm["X_pca_harmony"] + + sc.pp.neighbors(adata_harmony, n_neighbors=N_NEIGHBORS) + sc.tl.umap(adata_harmony) + sc.tl.leiden( + adata_harmony, + resolution=0.5, + key_added="leiden_harmony", + **LEIDEN_KWARGS, + ) + + log.info("Saving Harmony integrated → %s", out_harmony) + adata_harmony.write(out_harmony) + + # Copy palettes to harmony object + for key in list(adata.uns.keys()): + if key.endswith("_colors") and key not in adata_harmony.uns: + adata_harmony.uns[key] = adata.uns[key] + + # Plots + sc.pl.umap(adata_harmony, color=["sample_batch"], size=UMAP_POINT_SIZE, frameon=False, + title="Harmony – by sample") + plt.savefig(os.path.join(output_dir, "UMAP_harmony_by_sample.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + _annotation_umaps( + adata_harmony, + "harmony", + active_annotations, + primary_annotation, + ) + + # Harmony UMAPs by each design column (grey for palette-less values) + for _dc in EXTRA_ANNOT_COLUMNS: + if _apply_design_palette(adata_harmony, _dc): + try: + sc.pl.umap(adata_harmony, color=[_dc], size=UMAP_POINT_SIZE, frameon=False, + title=f"Harmony – by {_dc}") + plt.savefig(os.path.join(output_dir, f"UMAP_harmony_by_{_dc}.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + except Exception as e: + log.warning(" design UMAP (harmony) %s failed: %s", _dc, e) + + _niche_umap(adata_harmony, "Harmony – by spatial niche", + os.path.join(output_dir, "UMAP_harmony_by_spatial_niche.png")) + + # ── Composition barplots (sample / region / niche) ─────────────────── + log.info("Composition barplots …") + bar_dir = os.path.join(output_dir, "barplots") + os.makedirs(bar_dir, exist_ok=True) + annot_cols = active_annotations + has_regions = ("region_annotation" in adata.obs.columns + and adata.obs["region_annotation"].nunique() > 1 + and not all(adata.obs["region_annotation"] == "Unlabeled")) + niche_col = find_niche_column(adata, NICHE_COLUMN) + + for annot_col in annot_cols: + cmap = (ANNOTATION_COLORS.get(annot_col, {}) + if isinstance(ANNOTATION_COLORS, dict) else {}) + label = annot_col.replace("cell_type_", "") + composition_pair(adata, "sample_batch", annot_col, cmap, bar_dir, + f"barplot_{label}_by_sample", group_label="Sample", + cat_label=label, dpi=DPI) + if has_regions: + composition_pair(adata, "region_annotation", annot_col, cmap, + bar_dir, f"barplot_{label}_by_region", + group_label="Region", cat_label=label, dpi=DPI) + if niche_col: + composition_pair(adata, niche_col, annot_col, cmap, bar_dir, + f"barplot_{label}_by_niche", group_label="Niche", + cat_label=label, dpi=DPI) + + # Sample × region cell-type composition — two single-axis grouped layouts, + # absolute + relative: + # • grouped by sample (sample headers, regions within each group) + # • grouped by region (region headers, samples within each group) + if has_regions and annot_cols: + primary = annot_cols[0] + pcmap = (ANNOTATION_COLORS.get(primary, {}) + if isinstance(ANNOTATION_COLORS, dict) else {}) + plabel = primary.replace("cell_type_", "") + region_order = (list(REGION_COLORS.keys()) + if isinstance(REGION_COLORS, dict) and REGION_COLORS else None) + sample_order = sorted(adata.obs["sample_batch"].astype(str).unique()) + for norm, suffix, yl in [(False, "absolute", "Number of cells"), + (True, "relative", "Percentage (%)")]: + composition_grouped( + adata, "sample_batch", "region_annotation", primary, pcmap, + os.path.join(bar_dir, f"barplot_{plabel}_grouped_by_sample_{suffix}.png"), + normalize=norm, outer_order=sample_order, inner_order=region_order, + cat_label=plabel, ylabel=yl, dpi=DPI) + composition_grouped( + adata, "region_annotation", "sample_batch", primary, pcmap, + os.path.join(bar_dir, f"barplot_{plabel}_grouped_by_region_{suffix}.png"), + normalize=norm, outer_order=region_order, inner_order=sample_order, + cat_label=plabel, ylabel=yl, dpi=DPI) + + # ── Spatial-niche composition (niche as the stack) ─────────────────── + # Niche colours come from the shared value-deterministic palette so they + # match the spatial_niches rule. (Cell-type-by-niche, with niche as the + # x-axis, is already produced inside the annot loop above.) + if niche_col: + nvals = adata.obs[niche_col].astype(str) + try: + ncats = sorted(nvals.unique(), key=lambda x: int(x)) + except ValueError: + ncats = sorted(nvals.unique()) + niche_cfg = (ANNOTATION_COLORS.get("spatial_niche", {}) + if isinstance(ANNOTATION_COLORS, dict) else {}) + niche_pal = build_niche_palette(ncats, niche_cfg) + + # (1) spatial-niche composition per sample (absolute + relative) + composition_pair(adata, "sample_batch", niche_col, niche_pal, bar_dir, + "barplot_spatial_niche_by_sample", + group_label="Sample", cat_label="Niche", dpi=DPI) + + # (2) grouped by area (region), composition by spatial niche + # (absolute + relative; mirrors barplot_*_grouped_by_region) + if has_regions: + region_order2 = (list(REGION_COLORS.keys()) + if isinstance(REGION_COLORS, dict) and REGION_COLORS + else None) + sample_order2 = sorted(adata.obs["sample_batch"].astype(str).unique()) + for norm, suffix, yl in [(False, "absolute", "Number of cells"), + (True, "relative", "Percentage (%)")]: + composition_grouped( + adata, "region_annotation", "sample_batch", niche_col, + niche_pal, + os.path.join(bar_dir, + f"barplot_spatial_niche_grouped_by_region_{suffix}.png"), + normalize=norm, outer_order=region_order2, + inner_order=sample_order2, cat_label="Niche", ylabel=yl, + dpi=DPI) + + # ── 4. Geosketch ───────────────────────────────────────────────────── + if not HAS_GEOSKETCH: + log.warning("geosketch not installed — skipping sketching. " + "Saving Harmony object as sketch placeholder.") + adata_harmony.write(out_sketched) + else: + log.info("Geosketching %.0f%% of %d cells …", SKETCH_FRAC * 100, adata.n_obs) + n_sketch = max(100, int(adata.n_obs * SKETCH_FRAC)) + + # Sketch from PCA embedding + pca_data = adata.obsm["X_pca"] + sketch_indices = sketch.gs(pca_data, n_sketch, replace=False) + + sketched_adata = adata[sketch_indices].copy() + log.info(" Sketched: %d cells", sketched_adata.n_obs) + + # Cluster the sketch + sc.pp.neighbors(sketched_adata, n_neighbors=N_NEIGHBORS) + sc.tl.umap(sketched_adata) + sc.tl.leiden( + sketched_adata, + resolution=0.5, + key_added="clusters", + **LEIDEN_KWARGS, + ) + + # Project labels back to full dataset via ingest (in chunks) + log.info(" Projecting sketch clusters onto full dataset …") + chunk_size = 100000 + n_chunks = (adata.n_obs + chunk_size - 1) // chunk_size + ingested_chunks = [] + + for i in range(n_chunks): + start = i * chunk_size + end = min((i + 1) * chunk_size, adata.n_obs) + chunk = adata[start:end].copy() + sc.tl.ingest(chunk, sketched_adata, obs="clusters") + ingested_chunks.append(chunk) + log.info(" Ingested chunk %d/%d (%d cells)", i + 1, n_chunks, chunk.n_obs) + + adata_ingested = sc.concat(ingested_chunks) + + log.info("Saving sketched → %s", out_sketched) + adata_ingested.write(out_sketched) + + # Plot + sc.pl.umap(adata_ingested, color=["clusters"], size=UMAP_POINT_SIZE, frameon=False, + title="Geosketch – ingested clusters") + plt.savefig(os.path.join(output_dir, "UMAP_sketched_clusters.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + sc.pl.umap(adata_ingested, color=["sample_batch"], size=UMAP_POINT_SIZE, frameon=False, + title="Geosketch – by sample") + plt.savefig(os.path.join(output_dir, "UMAP_sketched_by_sample.png"), + dpi=DPI, bbox_inches="tight") + plt.close() + + del ingested_chunks, sketched_adata + gc.collect() + + log.info("Integration complete.") + +except Exception: + log.error("FAILED:\n%s", traceback.format_exc()) + raise diff --git a/workflow/scripts/leiden_analysis.py b/workflow/scripts/leiden_analysis.py index 708c12d..4ca9332 100644 --- a/workflow/scripts/leiden_analysis.py +++ b/workflow/scripts/leiden_analysis.py @@ -16,13 +16,26 @@ import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt +from matplotlib.colors import is_color_like, to_hex import numpy as np import pandas as pd from pyclustree import clustree import scanpy as sc +from scanpy.plotting import palettes as scanpy_palettes from sklearn.metrics import silhouette_samples, silhouette_score from sklearn.preprocessing import StandardScaler +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from plotting_legends import ( + category_labels, + grid_figure, + move_legend_to_axis, +) + log_handlers = [logging.StreamHandler(sys.stderr)] if hasattr(snakemake, "log"): @@ -52,6 +65,45 @@ ANNOTATION_COLORS = snakemake.params.annotation_colors REGION_COLORS = snakemake.params.region_colors DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) + + +def _resolution_range(rmin, rmax, step): + """Return the same inclusive resolution ladder used during preprocessing.""" + n = round((rmax - rmin) / step) + return [round(rmin + i * step, 10) for i in range(n + 1)] + + +def _ensure_scanpy_palette(adata, obs_key): + """Return category-to-colour mapping used by subsequent Scanpy plots.""" + categories = list(adata.obs[obs_key].cat.categories) + color_key = f"{obs_key}_colors" + stored = list(adata.uns.get(color_key, [])) + valid = ( + len(stored) == len(categories) + and all(is_color_like(color) for color in stored) + ) + + if valid: + colors = [to_hex(color, keep_alpha=True) for color in stored] + else: + n_categories = len(categories) + prop_cycle = plt.rcParams["axes.prop_cycle"].by_key()["color"] + if len(prop_cycle) >= n_categories: + colors = prop_cycle[:n_categories] + elif n_categories <= 20: + colors = scanpy_palettes.default_20[:n_categories] + elif n_categories <= 28: + colors = scanpy_palettes.default_28[:n_categories] + elif n_categories <= len(scanpy_palettes.default_102): + colors = scanpy_palettes.default_102[:n_categories] + else: + colors = ["grey"] * n_categories + colors = [to_hex(color) for color in colors] + + adata.uns[color_key] = colors + return {str(category): color for category, color in zip(categories, colors)} def read_tsv_to_dict(tsv_path): @@ -67,10 +119,21 @@ def read_tsv_to_dict(tsv_path): def split_umap(adata, split_by, ncol=None, nrow=None, **kwargs): categories = adata.obs[split_by].cat.categories - ncol = ncol or len(categories) + ncol = ncol or min(4, len(categories)) nrow = nrow or int(np.ceil(len(categories) / ncol)) - fig, axs = plt.subplots(nrow, ncol, figsize=(5 * ncol, 4 * nrow)) - axs = np.atleast_1d(axs).flatten() + color_key = kwargs.get("color") + if isinstance(color_key, (list, tuple)): + color_key = color_key[0] if color_key else None + labels = category_labels(adata, color_key) + fig, axes, legend_ax = grid_figure( + nrow, + ncol, + labels, + cell_width=5, + cell_height=4, + max_legend_columns=min(8, 2 * ncol), + ) + axs = axes.ravel() for i, cat in enumerate(categories): sc.pl.umap( adata[adata.obs[split_by] == cat], @@ -78,7 +141,12 @@ def split_umap(adata, split_by, ncol=None, nrow=None, **kwargs): ) for j in range(i + 1, len(axs)): axs[j].set_visible(False) - plt.tight_layout() + move_legend_to_axis( + axs[:len(categories)], + legend_ax, + title=str(color_key).replace("_", " ").title() if color_key else None, + max_columns=min(8, 2 * ncol), + ) try: @@ -125,6 +193,7 @@ def _set_palette(adata, obs_key, colors_cfg): adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] _set_palette(adata, "leiden", ANNOTATION_COLORS) + leiden_palette = _ensure_scanpy_palette(adata, "leiden") # Apply custom region palette if configured if REGION_COLORS and "region_annotation" in adata.obs.columns: @@ -143,20 +212,32 @@ def _set_palette(adata, obs_key, colors_cfg): # ── Clustering evaluation (silhouette) ─────────────────────────────── log.info("Silhouette analysis …") embedding_scaled = StandardScaler().fit_transform(adata.obsm["X_pca"]) - labels = adata.obs["leiden"].astype(int) + labels = adata.obs["leiden"].astype(str).to_numpy() sil_values = silhouette_samples(embedding_scaled, labels) sil_avg = silhouette_score(embedding_scaled, labels) plt.figure(figsize=(12, 6)) - unique_labels = np.unique(labels) - colours = plt.cm.tab10(np.linspace(0, 1, len(unique_labels))) + unique_labels = [str(label) for label in adata.obs["leiden"].cat.categories] x_pos = 0 - for label, colour in zip(unique_labels, colours): + for label in unique_labels: vals = np.sort(sil_values[labels == label]) + if not len(vals): + continue xs = np.arange(len(vals)) + x_pos - plt.bar(xs, vals, color=colour, label=f"Cluster {label}", width=1, alpha=0.7) + plt.bar( + xs, + vals, + color=leiden_palette[label], + label=f"Cluster {label}", + width=1, + ) x_pos = xs[-1] + 5 - plt.axhline(y=sil_avg, color="red", linestyle="--", label="Average Silhouette") + plt.axhline( + y=sil_avg, + color="black", + linestyle="--", + label="Average Silhouette", + ) plt.ylabel("Silhouette Score") plt.xlabel("Cluster and Cell Index") plt.title(f"Silhouette (resolution {resolution}) – avg {sil_avg:.3f}") @@ -168,14 +249,22 @@ def _set_palette(adata, obs_key, colors_cfg): plt.close() # Clustree (uses all pre-computed leiden columns) - resolutions = np.arange(RES_SCAN_MIN, RES_SCAN_MAX, RES_SCAN_STEP).round(1) + resolutions = _resolution_range(RES_SCAN_MIN, RES_SCAN_MAX, RES_SCAN_STEP) leiden_keys = [f"leiden_{str(r).replace('.', '_')}" for r in resolutions] available_keys = [k for k in leiden_keys if k in adata.obs.columns] + missing_keys = [k for k in leiden_keys if k not in adata.obs.columns] + if missing_keys: + log.warning("Clustree is missing screened columns: %s", missing_keys) if len(available_keys) >= 2: fig = clustree(adata, available_keys, edge_weight_threshold=0.00, show_fraction=True) fig.savefig(os.path.join(clust_eval_dir, f"clustree_{sample_id}.png"), dpi=DPI, bbox_inches="tight") plt.close() + else: + log.warning( + "Clustree requires at least two available screened resolutions; found %s", + available_keys, + ) # ── QC plots ───────────────────────────────────────────────────────── log.info("QC plots …") @@ -193,7 +282,7 @@ def _set_palette(adata, obs_key, colors_cfg): sc.pl.umap( adata, color=["n_genes_by_counts", "total_counts", "pct_counts_mt", "pct_counts_hb"], - size=2, wspace=0.25, frameon=False, + size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, ) plt.savefig(os.path.join(qc_dir, f"QC_UMAPs_{sample_id}.png"), dpi=DPI, bbox_inches="tight") @@ -213,18 +302,20 @@ def _set_palette(adata, obs_key, colors_cfg): # ── UMAPs ──────────────────────────────────────────────────────────── log.info("UMAP plots …") - sc.pl.umap(adata, color=["leiden"], size=2, wspace=0.25, frameon=False) + sc.pl.umap(adata, color=["leiden"], size=UMAP_POINT_SIZE, wspace=0.25, frameon=False) plt.savefig(os.path.join(res_dir, f"UMAP_clusters_{sample_id}.png"), dpi=DPI, bbox_inches="tight") plt.close() if has_annotations: - sc.pl.umap(adata, color=["region_annotation"], size=2, wspace=0.25, frameon=False) + sc.pl.umap(adata, color=["region_annotation"], size=UMAP_POINT_SIZE, + wspace=0.25, frameon=False) plt.savefig(os.path.join(umap_dir, f"UMAP_region_{sample_id}.png"), dpi=DPI, bbox_inches="tight") plt.close() - split_umap(adata, color="leiden", split_by="region_annotation", size=10) + split_umap(adata, color="leiden", split_by="region_annotation", + size=max(UMAP_POINT_SIZE, 10.0)) plt.savefig(os.path.join(umap_dir, f"UMAP_split_{sample_id}.png"), dpi=DPI, bbox_inches="tight") plt.close() @@ -245,7 +336,8 @@ def _set_palette(adata, obs_key, colors_cfg): # ── Spatial cluster maps ───────────────────────────────────────────── log.info("Spatial plots …") - sc.pl.spatial(adata, color="leiden", spot_size=20, title="Leiden Clusters", frameon=False) + sc.pl.spatial(adata, color="leiden", spot_size=SPATIAL_POINT_SIZE, + title="Leiden Clusters", frameon=False) plt.savefig(os.path.join(res_dir, f"spatial_all_{sample_id}.png"), dpi=DPI, bbox_inches="tight") plt.close() @@ -253,7 +345,7 @@ def _set_palette(adata, obs_key, colors_cfg): for cluster in adata.obs["leiden"].unique(): adata_sub = adata[adata.obs["leiden"] == cluster, :] sc.pl.spatial( - adata_sub, color="leiden", spot_size=20, + adata_sub, color="leiden", spot_size=SPATIAL_POINT_SIZE, title=f"Cluster {cluster}", frameon=False, palette=["black"], alpha_img=0.5, ) @@ -269,7 +361,7 @@ def _set_palette(adata, obs_key, colors_cfg): categories=["Other", str(cluster)], ) sc.pl.umap( - adata, color="_highlight", size=2, wspace=0.25, frameon=False, + adata, color="_highlight", size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, palette={"Other": "gray", str(cluster): "red"}, title=f"Cluster {cluster}", ) @@ -327,7 +419,7 @@ def _set_palette(adata, obs_key, colors_cfg): plt.close() for gene in genes: - sc.pl.umap(adata, color=gene, show=False) + sc.pl.umap(adata, color=gene, size=UMAP_POINT_SIZE, show=False) plt.savefig(os.path.join(ct_dir, f"UMAP_{gene}.png"), dpi=DPI, bbox_inches="tight") plt.close() diff --git a/workflow/scripts/neighbourhood_analysis.py b/workflow/scripts/neighbourhood_analysis.py index 6c25e34..e6ceafe 100644 --- a/workflow/scripts/neighbourhood_analysis.py +++ b/workflow/scripts/neighbourhood_analysis.py @@ -44,7 +44,7 @@ ANNOT_COL_MAP = { "tsv_annotation": "cell_type_tsv", - "refined_annotation": "cell_type_refined", + "ingest_annotation": "cell_type_ingest", "external_annotation": "cell_type_external", } diff --git a/workflow/scripts/plotting_legends.py b/workflow/scripts/plotting_legends.py new file mode 100644 index 0000000..a00650d --- /dev/null +++ b/workflow/scripts/plotting_legends.py @@ -0,0 +1,219 @@ +"""Helpers for keeping categorical legends inside multi-panel figures.""" + +from __future__ import annotations + +import math +import textwrap + +import matplotlib.pyplot as plt +import numpy as np + + +DEFAULT_LEGEND_FONTSIZE = 12 + + +def category_labels(adata, obs_key): + """Return displayed category labels in their plotting order.""" + if not obs_key or obs_key not in adata.obs.columns: + return [] + values = adata.obs[obs_key] + categories = getattr(values.dtype, "categories", None) + if categories is not None: + labels = categories + else: + labels = values.dropna().unique() + return [str(label) for label in labels] + + +def _wrap_label(label, width=24): + """Wrap only the displayed legend text; underlying category names are unchanged.""" + return textwrap.fill( + str(label), + width=width, + break_long_words=True, + break_on_hyphens=False, + ) + + +def legend_layout(labels, wrap_width=24, preferred_rows=5, max_columns=4, + fontsize=DEFAULT_LEGEND_FONTSIZE): + """Return wrapped labels, column count, and estimated height in inches.""" + wrapped = [_wrap_label(label, wrap_width) for label in labels] + if not wrapped: + return wrapped, 1, 0.0 + + ncol = min(max_columns, max(1, math.ceil(len(wrapped) / preferred_rows))) + rows = math.ceil(len(wrapped) / ncol) + line_counts = [label.count("\n") + 1 for label in wrapped] + + # Matplotlib fills legends column-first. Estimate each displayed row using the + # tallest wrapped entry that can land on it, then reserve a title/margin band. + row_lines = [] + for row in range(rows): + row_lines.append( + max( + (line_counts[col * rows + row] + for col in range(ncol) + if col * rows + row < len(line_counts)), + default=1, + ) + ) + # Reserve physical space in proportion to the rendered font size. The + # multiplier includes Matplotlib's default line spacing plus row padding. + line_height = 0.30 * (fontsize / 10) + title_height = 0.55 * (fontsize / 10) + height = title_height + sum(line_height * lines for lines in row_lines) + return wrapped, ncol, max(0.8, height) + + +def legend_height(labels_by_panel, minimum=0.8): + """Height needed by the largest panel legend.""" + heights = [legend_layout(labels)[2] for labels in labels_by_panel] + return max([minimum, *heights]) + + +def panel_figure(n_panels, labels_by_panel, plot_rows=1, panel_width=8.0, + plot_height=6.0, wspace=0.18, hspace=0.25, + top_margin=0.0): + """Create plot axes plus a dedicated legend row in the same figure. + + The returned legend axes are part of the figure itself. Consequently, a PDF + writer always keeps each legend on the same page as its plots. + """ + legend_h = legend_height(labels_by_panel) + fig = plt.figure( + figsize=(panel_width * n_panels, + plot_height * plot_rows + legend_h + top_margin) + ) + grid = fig.add_gridspec( + plot_rows + 1, + n_panels, + height_ratios=[plot_height] * plot_rows + [legend_h], + wspace=wspace, + hspace=hspace, + ) + plot_axes = np.asarray( + [[fig.add_subplot(grid[row, col]) for col in range(n_panels)] + for row in range(plot_rows)] + ) + legend_axes = [fig.add_subplot(grid[plot_rows, col]) for col in range(n_panels)] + for ax in legend_axes: + ax.set_axis_off() + return fig, plot_axes, legend_axes + + +def grid_figure(nrows, ncols, legend_labels, cell_width=5.0, cell_height=4.0, + wspace=0.18, hspace=0.25, top_margin=0.0, + max_legend_columns=8): + """Create a plot grid with one shared legend band on the same figure.""" + _, _, legend_h = legend_layout( + legend_labels, + max_columns=max_legend_columns, + ) + legend_h = max(0.8, legend_h) + fig = plt.figure( + figsize=(cell_width * ncols, + cell_height * nrows + legend_h + top_margin) + ) + grid = fig.add_gridspec( + nrows + 1, + ncols, + height_ratios=[cell_height] * nrows + [legend_h], + wspace=wspace, + hspace=hspace, + ) + axes = np.asarray( + [[fig.add_subplot(grid[row, col]) for col in range(ncols)] + for row in range(nrows)] + ) + legend_ax = fig.add_subplot(grid[nrows, :]) + legend_ax.set_axis_off() + return fig, axes, legend_ax + + +def draw_legend_on_axis( + legend_ax, + handles, + labels, + title=None, + fontsize=DEFAULT_LEGEND_FONTSIZE, + wrap_width=24, + preferred_rows=5, + max_columns=4, +): + """Draw handles in a reserved legend axis using adaptive rows and wrapping.""" + legend_ax.set_axis_off() + if not handles: + return + wrapped, ncol, _ = legend_layout( + labels, + wrap_width=wrap_width, + preferred_rows=preferred_rows, + max_columns=max_columns, + fontsize=fontsize, + ) + legend_ax.legend( + handles, + wrapped, + title=title, + loc="upper center", + bbox_to_anchor=(0.5, 1.0), + ncol=ncol, + fontsize=fontsize, + title_fontsize=fontsize + 1, + frameon=False, + handletextpad=0.35, + columnspacing=0.8, + labelspacing=0.35, + borderaxespad=0.0, + ) + + +def move_legend_to_axis( + source_axes, + legend_ax, + title=None, + fontsize=DEFAULT_LEGEND_FONTSIZE, + wrap_width=24, + preferred_rows=5, + max_columns=4, +): + """Move unique entries from Scanpy legends into a dedicated in-figure axis. + + Any duplicate legends on the supplied source axes are removed. ``source_axes`` + can therefore contain matching UMAP and spatial panels while only one legend + is rendered below them. + """ + if not isinstance(source_axes, (list, tuple, np.ndarray)): + source_axes = [source_axes] + + handles = [] + labels = [] + seen_labels = set() + for ax in source_axes: + legend = ax.get_legend() + if legend is None: + continue + axis_handles = ( + list(legend.legend_handles) + if hasattr(legend, "legend_handles") + else list(getattr(legend, "legendHandles", [])) + ) + axis_labels = [text.get_text() for text in legend.get_texts()] + for handle, label in zip(axis_handles, axis_labels): + if label not in seen_labels: + handles.append(handle) + labels.append(label) + seen_labels.add(label) + legend.remove() + + draw_legend_on_axis( + legend_ax, + handles, + labels, + title=title, + fontsize=fontsize, + wrap_width=wrap_width, + preferred_rows=preferred_rows, + max_columns=max_columns, + ) diff --git a/workflow/scripts/prepare_input_ate.py b/workflow/scripts/prepare_input_ate.py index ee1a4f5..d09af1f 100644 --- a/workflow/scripts/prepare_input_ate.py +++ b/workflow/scripts/prepare_input_ate.py @@ -79,8 +79,21 @@ adata.obs["sample"] = sample_id if "cell_id" not in adata.obs.columns: adata.obs["cell_id"] = adata.obs_names.astype(str) - # cell_id must equal obs_names as strings — downstream joins key on it. + # Contract invariant: obs_names == obs['cell_id']. Every downstream join (external + # annotation, spatial niches, region mapping) keys on obs_names, but the SpatialData + # zarr table carries the real cell_id in a column while obs_names is a positional + # integer index — so the two silently diverge and those joins match nothing (all cells + # fall through to 'Unannotated'). Stringify cell_id and align obs_names to it here. adata.obs["cell_id"] = adata.obs["cell_id"].astype(str) + adata.obs_names = adata.obs["cell_id"].values + if not adata.obs_names.is_unique: + n_dup = int(adata.obs_names.duplicated().sum()) + examples = adata.obs_names[adata.obs_names.duplicated(keep=False)].unique()[:5] + raise ValueError( + f"cell_id has {n_dup} duplicate value(s) (examples: {list(examples)}). " + "Cell identifiers must be unique; suffixing them would break external " + "metadata and region joins." + ) log.info("AnnData: %d cells × %d genes (raw, unfiltered)", adata.n_obs, adata.n_vars) # ── 2b. Contract dtype hygiene ─────────────────────────────────────── diff --git a/workflow/scripts/prepare_input_x5k.py b/workflow/scripts/prepare_input_x5k.py index 319dd27..08d67b7 100644 --- a/workflow/scripts/prepare_input_x5k.py +++ b/workflow/scripts/prepare_input_x5k.py @@ -59,6 +59,21 @@ adata.obs["sample"] = sample_id if "cell_id" not in adata.obs.columns: adata.obs["cell_id"] = adata.obs_names.astype(str) + # Contract invariant: obs_names == obs['cell_id']. Every downstream join (external + # annotation, spatial niches, region mapping) keys on obs_names, but the SpatialData + # zarr table carries the real Xenium cell_id in a column while obs_names is a positional + # integer index — so the two silently diverge and those joins match nothing (all cells + # fall through to 'Unannotated'). Align obs_names to cell_id here. + adata.obs["cell_id"] = adata.obs["cell_id"].astype(str) + adata.obs_names = adata.obs["cell_id"].values + if not adata.obs_names.is_unique: + n_dup = int(adata.obs_names.duplicated().sum()) + examples = adata.obs_names[adata.obs_names.duplicated(keep=False)].unique()[:5] + raise ValueError( + f"cell_id has {n_dup} duplicate value(s) (examples: {list(examples)}). " + "Cell identifiers must be unique; suffixing them would break external " + "metadata and region joins." + ) log.info("AnnData: %d cells × %d genes (raw, unfiltered)", adata.n_obs, adata.n_vars) # ── 3. Embed greyscale morphology composite in uns['spatial'] ──────── diff --git a/workflow/scripts/preprocess_umap.py b/workflow/scripts/preprocess_umap.py index a72f82d..7934403 100644 --- a/workflow/scripts/preprocess_umap.py +++ b/workflow/scripts/preprocess_umap.py @@ -23,6 +23,14 @@ import pandas as pd import scanpy as sc +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from annotation_utils import non_placeholder_annotation_mask +from external_metadata import optional_input_path, read_cell_metadata + # ── Logging ────────────────────────────────────────────────────────────────── log_handlers = [logging.StreamHandler(sys.stderr)] if hasattr(snakemake, "log"): @@ -50,8 +58,8 @@ RES_SCAN_STEP = float(snakemake.params.resolution_scan_step) RANDOM_SEED = int(snakemake.params.random_seed) USE_PRECOMPUTED = bool(snakemake.params.use_precomputed) -PRECOMPUTED_DIR = str(snakemake.params.precomputed_metadata_dir) REGION_COLORS = snakemake.params.region_colors +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) # Mitochondrial gene prefixes. Honour contract.mito_prefix when the rule passes it, # else default to human + mouse. The old hardcoded "MT-" silently disabled the # max_pct_mt filter on mouse ("mt-") data, even though the config advertises the key. @@ -63,7 +71,8 @@ EXTERNAL_COLUMN = str(getattr(snakemake.params, "external_column", "") or "") KEEP_UNANNOTATED = bool(getattr(snakemake.params, "keep_unannotated", True)) _ext_meta_in = getattr(snakemake.input, "external_meta", None) -EXTERNAL_MASK = bool(EXTERNAL_ENABLED and not KEEP_UNANNOTATED and _ext_meta_in) +_precomputed_meta_in = getattr(snakemake.input, "precomputed_meta", None) +EXTERNAL_MASK = bool(EXTERNAL_ENABLED and not KEEP_UNANNOTATED) out_adata = str(snakemake.output.adata) out_metadata = str(snakemake.output.metadata) @@ -192,25 +201,38 @@ def _range(series): # and SKIP the pipeline QC thresholds (the user's external labels ARE the QC # decision). Genes expressed in no kept cell are dropped (data hygiene, not a # cell-QC threshold, so PCA/normalisation stay well-behaved). - ext_path = str(_ext_meta_in if isinstance(_ext_meta_in, str) else _ext_meta_in[0]) - _saved = pd.read_csv(ext_path, sep="\t", index_col=0, comment="#") - if EXTERNAL_COLUMN not in _saved.columns: - raise ValueError(f"external_annotation column '{EXTERNAL_COLUMN}' not in {ext_path}") - _lab = _saved[EXTERNAL_COLUMN].dropna().astype(str).str.strip() - _annot_bc = set(_lab[(_lab != "") & (_lab.str.lower() != "nan") - & (_lab.str.lower() != "unannotated")].index.astype(str)) + if _ext_meta_in: + ext_path = optional_input_path(_ext_meta_in) + _saved = read_cell_metadata( + ext_path, required_columns=(EXTERNAL_COLUMN,) + ) + _lab = _saved[EXTERNAL_COLUMN] + external_source = ext_path + elif EXTERNAL_COLUMN in adata.obs.columns: + _lab = adata.obs[EXTERNAL_COLUMN] + external_source = f"contract h5ad obs['{EXTERNAL_COLUMN}']" + else: + raise ValueError( + f"external_annotation column '{EXTERNAL_COLUMN}' is absent from " + "the contract h5ad obs and no external metadata TSV was supplied" + ) + _annot_bc = set( + _lab[non_placeholder_annotation_mask(_lab)].index.astype(str) + ) _keep = adata.obs_names.astype(str).isin(_annot_bc) adata = adata[_keep].copy() sc.pp.filter_genes(adata, min_cells=1) THRESHOLDS_SOURCE = "external_annotation (keep_unannotated=false; pipeline QC skipped)" MIN_COUNTS = MAX_COUNTS = MIN_GENES = MIN_CELLS = MAX_PCT_MT = None - log.info("External-driven cell set for %s: kept %d / %d cells (%d externally " - "annotated); pipeline QC thresholds SKIPPED.", - sample_id, adata.n_obs, n_cells_before, len(_annot_bc)) + log.info("External-driven cell set for %s from %s: kept %d / %d cells " + "(%d externally annotated); pipeline QC thresholds SKIPPED.", + sample_id, external_source, adata.n_obs, n_cells_before, + len(_annot_bc)) if adata.n_obs == 0: raise ValueError( f"No externally-annotated cells matched the contract barcodes for " - f"'{sample_id}'. Check that {ext_path} barcodes match adata.obs_names.") + f"'{sample_id}'. Check that {external_source} contains real labels " + "whose barcodes match adata.obs_names.") else: log.info("QC (source: %s): min_counts=%s min_cells=%s min_genes=%s " "max_counts=%s max_pct_mt=%s", THRESHOLDS_SOURCE, @@ -244,7 +266,7 @@ def _range(series): # the scalefactor-derived Visium spot size, so without this the dots render # invisibly and only the tissue image shows); add library_id to draw them # over the embedded image when one is present. - spatial_kw = {"spot_size": 20} + spatial_kw = {"spot_size": SPATIAL_POINT_SIZE} if isinstance(adata.uns.get("spatial"), dict) and adata.uns["spatial"]: spatial_kw["library_id"] = list(adata.uns["spatial"].keys())[0] sc.pl.spatial(adata, color="region_annotation", title="Annotated Regions", @@ -279,8 +301,7 @@ def _compute_all(): log.info(" res=%.1f → %d clusters", res, adata.obs[key].nunique()) if USE_PRECOMPUTED: - ext_meta = (os.path.join(PRECOMPUTED_DIR, f"metadata_{sample_id}.tsv") - if PRECOMPUTED_DIR else "") + ext_meta = optional_input_path(_precomputed_meta_in) if ext_meta and os.path.isfile(ext_meta): meta_source = ext_meta log.info("Reloading precomputed clusters from EXTERNAL: %s", meta_source) @@ -292,11 +313,7 @@ def _compute_all(): log.warning("use_precomputed is true but no metadata file found. Computing fresh.") if meta_source: - saved_meta = pd.read_csv(meta_source, sep="\t", index_col=0, comment="#") - # obs_names are strings; an all-numeric cell-id index (e.g. Xenium) is inferred as - # int by read_csv and would misalign on reindex -> force str (as the external path - # above does) so the saved clusters line up with the current cells. - saved_meta.index = saved_meta.index.astype(str) + saved_meta = read_cell_metadata(meta_source) saved_res = [c for c in saved_meta.columns if c.startswith("leiden_")] for key in leiden_keys: if key not in saved_meta.columns: diff --git a/workflow/scripts/process_geojson.py b/workflow/scripts/process_geojson.py new file mode 100644 index 0000000..6f786d9 --- /dev/null +++ b/workflow/scripts/process_geojson.py @@ -0,0 +1,78 @@ +"""Validate an optional region-annotation GeoJSON without modifying it.""" +import json +import logging +import sys +import traceback +from pathlib import Path + +import geopandas as gpd + +# ── Logging ────────────────────────────────────────────────────────────────── +log_handlers = [logging.StreamHandler(sys.stderr)] +if hasattr(snakemake, "log"): + if snakemake.log.out: + Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) + log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) + if snakemake.log.err: + Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) + sys.stderr = open(snakemake.log.err, "w") +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", handlers=log_handlers) +log = logging.getLogger("process_geojson") + +# ── Parameters ─────────────────────────────────────────────────────────────── +in_geojson = str(snakemake.input.geojson) +sample_id = str(snakemake.params.sample_id) +out_report = str(snakemake.output.report) + +try: + log.info("=" * 70) + log.info("Processing region GeoJSON for sample: %s", sample_id) + log.info(" input: %s", in_geojson) + log.info("=" * 70) + + # ── 1. Load ──────────────────────────────────────────────────────── + gdf = gpd.read_file(in_geojson) + n_total = len(gdf) + log.info("Loaded %d feature(s)", n_total) + + if n_total == 0: + raise ValueError(f"'{in_geojson}' contains no features.") + + # ── 2. Validate ───────────────────────────────────────────────────── + if "classification" not in gdf.columns: + raise ValueError( + f"'{in_geojson}' has no 'classification' property. Assign a QuPath " + f"class to the annotations before exporting. Columns: {list(gdf.columns)}" + ) + + missing_mask = gdf.geometry.isna() + n_missing = int(missing_mask.sum()) + empty_mask = gdf.geometry.is_empty & ~missing_mask + n_empty = int(empty_mask.sum()) + invalid_mask = ~gdf.geometry.is_valid & ~missing_mask & ~empty_mask + n_invalid = int(invalid_mask.sum()) + if n_missing or n_empty or n_invalid: + raise ValueError( + f"'{in_geojson}' contains unusable geometries: missing={n_missing}, " + f"empty={n_empty}, invalid={n_invalid}. Fix them in QuPath and re-export; " + "the workflow does not rewrite source annotations." + ) + + # ── 3. Write success report ───────────────────────────────────────── + report = { + "sample": sample_id, + "passed": True, + "file": str(Path(in_geojson).resolve()), + "feature_count": n_total, + "geometry_types": sorted(gdf.geometry.geom_type.unique().tolist()), + "classification_present": True, + } + Path(out_report).parent.mkdir(parents=True, exist_ok=True) + Path(out_report).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + log.info("GeoJSON validation passed; report written to %s", out_report) + +except Exception: + log.error("FAILED for %s:\n%s", sample_id, traceback.format_exc()) + raise diff --git a/workflow/scripts/pseudobulk_aggregate.py b/workflow/scripts/pseudobulk_aggregate.py index 9a61ef4..af27b06 100644 --- a/workflow/scripts/pseudobulk_aggregate.py +++ b/workflow/scripts/pseudobulk_aggregate.py @@ -11,6 +11,7 @@ import logging import os +import re import sys import traceback from pathlib import Path @@ -216,134 +217,249 @@ def _plot_pseudobulk_qc(pdata, condition_col, sample_col, prefix, plots_dir, plt.close() +def _safe_name(value): + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value)).strip("_") + return safe or "group" + + +def _group_label(row, columns): + if len(columns) == 1: + return str(row[columns[0]]) + return " | ".join(f"{column}={row[column]}" for column in columns) + + +class DesignMetadataError(ValueError): + """Requested model metadata is not constant within a count unit.""" + + +def _attach_design_metadata(pdata, source, sample_col, groups_col, columns): + """Attach metadata that is constant within every pseudobulk count unit. + + decoupler retains grouping columns, but preservation of unrelated obs columns + is version-dependent. Reconstructing them explicitly makes the DE contract + stable and detects invalid designs where a requested value varies inside one + pseudobulk observation. + """ + columns = list(dict.fromkeys(c for c in columns if c not in {sample_col, groups_col})) + if not columns: + return pdata + + keys = [sample_col, groups_col] + frame = source.obs[keys + columns].copy() + for column in keys + columns: + frame[column] = frame[column].astype(str) + + grouped = frame.groupby(keys, observed=True, dropna=False) + for column in columns: + variable = grouped[column].nunique(dropna=False) + if (variable > 1).any(): + examples = [" / ".join(map(str, idx if isinstance(idx, tuple) else [idx])) + for idx in variable[variable > 1].index[:5]] + raise DesignMetadataError( + f"Design column '{column}' varies within pseudobulk unit(s): {examples}. " + "Use an aggregation that keeps this variable separate." + ) + + lookup = grouped[columns].first() + pdata_keys = pdata.obs[keys].astype(str) + lookup_index = pd.MultiIndex.from_frame(pdata_keys) + for column in columns: + pdata.obs[column] = lookup[column].reindex(lookup_index).to_numpy() + return pdata + + +def _aggregate(source, groups_col, design_columns): + pdata = dc.get_pseudobulk( + source, + sample_col="sample", + groups_col=groups_col, + layer="raw_counts", + mode="sum", + min_cells=MIN_CELLS, + min_counts=MIN_COUNTS, + ) + return _attach_design_metadata( + pdata, source, "sample", groups_col, design_columns + ) + + # ── Parameters ─────────────────────────────────────────────────────────────── -annot_type = snakemake.params.annot_type -analysis_level = snakemake.params.analysis_level -MIN_CELLS = int(snakemake.params.min_cells_per_pseudobulk) -MIN_COUNTS = int(snakemake.params.min_counts_per_pseudobulk) +annot_type = str(snakemake.params.annot_type) +analysis_spec = dict(snakemake.params.analysis_spec) +analysis_name = str(analysis_spec["name"]) +aggregation = str(analysis_spec["aggregation"]) +group_by = list(analysis_spec["group_by"]) +paired_by = str(analysis_spec.get("paired_by", "") or "") +covariates = list(analysis_spec.get("covariates") or []) +exclude_levels = dict(analysis_spec.get("exclude_levels") or {}) +MIN_CELLS = int(snakemake.params.min_cells_per_pseudobulk) +MIN_COUNTS = int(snakemake.params.min_counts_per_pseudobulk) ANNOTATION_COLORS = snakemake.params.annotation_colors -REGION_COLORS = snakemake.params.region_colors -DPI = int(getattr(snakemake.params, "dpi", 300)) +REGION_COLORS = snakemake.params.region_colors +DPI = int(getattr(snakemake.params, "dpi", 300)) EXTRA_ANNOT_COLUMNS = list(getattr(snakemake.params, "extra_annot_columns", []) or []) -SAMPLE_COLORS = getattr(snakemake.params, "sample_colors", {}) or {} -agg_dir = str(snakemake.output.agg_dir) +SAMPLE_COLORS = getattr(snakemake.params, "sample_colors", {}) or {} +agg_dir = str(snakemake.output.agg_dir) try: log.info("=" * 70) - log.info("Pseudobulk aggregation: %s / %s", annot_type, analysis_level) + log.info("Pseudobulk aggregation: %s / %s", annot_type, analysis_name) + log.info(" aggregation=%s, group_by=%s", aggregation, group_by) log.info(" min_cells=%d, min_counts=%d", MIN_CELLS, MIN_COUNTS) log.info("=" * 70) matrices_dir = os.path.join(agg_dir, "matrices") metadata_dir = os.path.join(agg_dir, "metadata") - plots_dir = os.path.join(agg_dir, "plots") - for d in [agg_dir, matrices_dir, metadata_dir, plots_dir]: - os.makedirs(d, exist_ok=True) + plots_dir = os.path.join(agg_dir, "plots") + for directory in [agg_dir, matrices_dir, metadata_dir, plots_dir]: + os.makedirs(directory, exist_ok=True) adata = sc.read_h5ad(str(snakemake.input.adata)) log.info("Loaded: %d cells, %d genes", adata.n_obs, adata.n_vars) - cell_type_col = ANNOT_COL_MAP.get(annot_type) - if cell_type_col and cell_type_col not in adata.obs.columns: - log.warning("Column '%s' not found. Skipping.", cell_type_col) - Path(os.path.join(agg_dir, f"SKIPPED_no_{cell_type_col}.txt")).write_text( - f"Column {cell_type_col} not found.\n" - ) - sys.exit(0) + # sample_batch is created by integrate_samples from the authoritative core + # sample sheet. Prefer it over a potentially renamed contract obs['sample']. + if "sample_batch" in adata.obs.columns: + adata.obs["sample"] = adata.obs["sample_batch"].astype(str).to_numpy() + elif "sample" not in adata.obs.columns: + raise ValueError("No canonical sample column found.") - sample_col = None - for c in ["sample", "sample_batch"]: - if c in adata.obs.columns: - sample_col = c - break - if sample_col is None: - raise ValueError("No sample column found.") - - region_col = "region_annotation" - if region_col not in adata.obs.columns: - log.warning("No region_annotation. Skipping.") + cell_type_col = ANNOT_COL_MAP.get(annot_type) + referenced_columns = set(group_by + covariates + list(exclude_levels)) + if paired_by: + referenced_columns.add(paired_by) + needs_cell_type = ( + aggregation in {"by_celltype", "by_celltype_region"} + or "cell_type" in referenced_columns + ) + if needs_cell_type: + if not cell_type_col or cell_type_col not in adata.obs.columns: + log.warning("Column '%s' not found. Skipping.", cell_type_col) + Path(os.path.join(agg_dir, f"SKIPPED_no_{cell_type_col}.txt")).write_text( + f"Column {cell_type_col} not found.\n" + ) + sys.exit(0) + adata.obs["cell_type"] = adata.obs[cell_type_col].astype(str).to_numpy() + + needs_region = aggregation in {"by_region", "by_celltype_region"} + if needs_region and "region_annotation" not in adata.obs.columns: + log.warning("No region_annotation for region-based aggregation. Skipping.") Path(os.path.join(agg_dir, "SKIPPED_no_regions.txt")).write_text("") sys.exit(0) - for col in [region_col, sample_col]: - adata = _clean_nan(adata, col) - if cell_type_col: - adata = _clean_nan(adata, cell_type_col) - - valid_regions = [r for r in adata.obs[region_col].unique() - if r not in ("Unlabeled", "Bubble")] - adata = adata[adata.obs[region_col].isin(valid_regions)].copy() - log.info("Regions: %s (%d cells)", valid_regions, adata.n_obs) + required_columns = list(dict.fromkeys( + ["sample"] + group_by + covariates + ([paired_by] if paired_by else []) + )) + missing = [column for column in required_columns if column not in adata.obs.columns] + if missing: + raise ValueError( + f"Pseudobulk analysis '{analysis_name}' requires missing obs column(s): {missing}. " + "Add them to core_samples.tsv or correct group_by/covariates/paired_by." + ) - if len(valid_regions) < 2: - Path(os.path.join(agg_dir, "SKIPPED_fewer_than_2_regions.txt")).write_text("") + for column in required_columns: + adata = _clean_nan(adata, column) + + # Exclusions are shared by aggregation, explicit Wald contrasts, and LRT. + for column, excluded in exclude_levels.items(): + if column not in adata.obs.columns: + raise ValueError( + f"exclude_levels references missing column '{column}' in analysis '{analysis_name}'." + ) + excluded = {str(value) for value in (excluded or [])} + if excluded: + values = adata.obs[column].astype(str) + keep = ~values.isin(excluded) + log.info(" Excluding %d cells from %s levels: %s", + int((~keep).sum()), column, sorted(excluded)) + adata = adata[keep].copy() + + if adata.n_obs == 0: + Path(os.path.join(agg_dir, "SKIPPED_no_cells_after_exclusion.txt")).write_text("") sys.exit(0) - if "raw_counts" not in adata.layers: raise ValueError("raw_counts layer missing.") + group_palette = {} + if len(group_by) == 1: + group_column = group_by[0] + if group_column == "region_annotation": + group_palette = REGION_COLORS if isinstance(REGION_COLORS, dict) else {} + elif isinstance(SAMPLE_COLORS, dict): + group_palette = SAMPLE_COLORS.get(group_column, {}) or {} + + # Plot-only annotations are optional. Model columns were validated above and + # remain mandatory, while absent plotting annotations should not abort DE. + present_extra_annotations = [ + column for column in EXTRA_ANNOT_COLUMNS if column in adata.obs.columns + ] + design_columns = list(dict.fromkeys( + group_by + covariates + ([paired_by] if paired_by else []) + present_extra_annotations + )) manifest_rows = [] + used_prefixes = set() + + if aggregation in {"all_cells", "by_celltype"}: + adata.obs["__all__"] = "all" + groups_col = "__all__" + else: + groups_col = "region_annotation" - if analysis_level == "by_region": - pdata = dc.get_pseudobulk( - adata, sample_col=sample_col, groups_col=region_col, - layer="raw_counts", mode="sum", - min_cells=MIN_CELLS, min_counts=MIN_COUNTS, + if aggregation in {"all_cells", "by_region"}: + subgroups = [("pooled", "all_cells", adata)] + else: + subgroups = [] + for cell_type in sorted(adata.obs["cell_type"].astype(str).unique()): + prefix = _safe_name(cell_type) + if prefix in used_prefixes: + raise ValueError( + f"Cell-type names collide after filename sanitization: '{cell_type}' -> '{prefix}'." + ) + used_prefixes.add(prefix) + subset = adata[adata.obs["cell_type"].astype(str) == cell_type].copy() + subgroups.append((prefix, cell_type, subset)) + + for prefix, grouping, subset in subgroups: + if subset.n_obs < MIN_CELLS: + log.warning(" '%s': too few cells (%d). Skipping.", grouping, subset.n_obs) + continue + try: + pdata = _aggregate(subset, groups_col, design_columns) + except DesignMetadataError: + raise + except Exception as exc: + log.warning(" '%s': aggregation failed: %s", grouping, exc) + continue + if pdata.n_obs == 0: + log.warning(" '%s': no pseudobulk observations passed QC. Skipping.", grouping) + continue + + pdata.obs[".comparison_group"] = [ + _group_label(row, group_by) for _, row in pdata.obs.iterrows() + ] + _save_pseudobulk(pdata, matrices_dir, metadata_dir, prefix) + _plot_pseudobulk_qc( + pdata, ".comparison_group", "sample", prefix, plots_dir, + group_palette, ANNOTATION_COLORS, + EXTRA_ANNOT_COLUMNS, SAMPLE_COLORS, ) - _save_pseudobulk(pdata, matrices_dir, metadata_dir, "pooled") - _plot_pseudobulk_qc(pdata, region_col, sample_col, "pooled", plots_dir, - REGION_COLORS, ANNOTATION_COLORS, - EXTRA_ANNOT_COLUMNS, SAMPLE_COLORS) manifest_rows.append({ - "prefix": "pooled", "grouping": "all_cells", - "n_samples": pdata.n_obs, "n_genes": pdata.n_vars, - "condition_col": region_col, "sample_col": sample_col, + "prefix": prefix, + "grouping": grouping, + "n_samples": pdata.n_obs, + "n_genes": pdata.n_vars, + "condition_col": ".comparison_group", + "sample_col": "sample", + "analysis_name": analysis_name, + "aggregation": aggregation, }) - elif analysis_level == "by_celltype_region": - adata = adata[adata.obs[cell_type_col] != "Unannotated"].copy() - cell_types = sorted(adata.obs[cell_type_col].unique()) - - for ct in cell_types: - safe_ct = ct.replace("/", "_").replace(" ", "_") - ct_adata = adata[adata.obs[cell_type_col] == ct].copy() - - if ct_adata.n_obs < MIN_CELLS: - log.warning(" '%s': too few cells (%d). Skipping.", ct, ct_adata.n_obs) - continue - - try: - pdata = dc.get_pseudobulk( - ct_adata, sample_col=sample_col, groups_col=region_col, - layer="raw_counts", mode="sum", - min_cells=MIN_CELLS, min_counts=MIN_COUNTS, - ) - except Exception as e: - log.warning(" '%s': aggregation failed: %s", ct, e) - continue - - if pdata.n_obs < 4: - log.warning(" '%s': too few pseudobulk samples (%d). Skipping.", ct, pdata.n_obs) - continue - - _save_pseudobulk(pdata, matrices_dir, metadata_dir, safe_ct) - _plot_pseudobulk_qc(pdata, region_col, sample_col, safe_ct, plots_dir, - REGION_COLORS, ANNOTATION_COLORS, - EXTRA_ANNOT_COLUMNS, SAMPLE_COLORS) - manifest_rows.append({ - "prefix": safe_ct, "grouping": ct, - "n_samples": pdata.n_obs, "n_genes": pdata.n_vars, - "condition_col": region_col, "sample_col": sample_col, - }) - - elif analysis_level == "by_niche_region": - log.warning("by_niche_region not yet implemented.") - Path(os.path.join(agg_dir, "SKIPPED_niche_not_implemented.txt")).write_text("") - if manifest_rows: pd.DataFrame(manifest_rows).to_csv( os.path.join(agg_dir, "manifest.tsv"), sep="\t", index=False ) + else: + Path(os.path.join(agg_dir, "SKIPPED_no_pseudobulks.txt")).write_text("") log.info("Aggregation complete.") diff --git a/workflow/scripts/pseudobulk_de.R b/workflow/scripts/pseudobulk_de.R index 6316fcd..570af5f 100644 --- a/workflow/scripts/pseudobulk_de.R +++ b/workflow/scripts/pseudobulk_de.R @@ -1,833 +1,893 @@ -################################################################################ -## pseudobulk_de.R – DESeq2 Wald + LRT, ComplexHeatmap, DEGpatterns, -## DEGpatterns -################################################################################ - -# ── Redirect logs FIRST — before anything else ────────────────────────────── -# This ensures any crash during param parsing is captured in the log files. -log_out <- tryCatch(snakemake@log[["out"]], error = function(e) "") -log_err <- tryCatch(snakemake@log[["err"]], error = function(e) "") - -if (!is.null(log_out) && nzchar(log_out)) { - dir.create(dirname(log_out), recursive = TRUE, showWarnings = FALSE) - log_out_con <- file(log_out, open = "wt") - sink(log_out_con, split = TRUE) -} -if (!is.null(log_err) && nzchar(log_err)) { - dir.create(dirname(log_err), recursive = TRUE, showWarnings = FALSE) - log_err_con <- file(log_err, open = "wt") - sink(log_err_con, type = "message") +################################################################################ +## pseudobulk_de.R – explicit DESeq2 contrasts, one-vs-rest biomarkers, and LRT +################################################################################ + +# Redirect logs before parsing parameters so early failures are captured. +log_out <- tryCatch(snakemake@log[["out"]], error = function(e) "") +log_err <- tryCatch(snakemake@log[["err"]], error = function(e) "") +if (!is.null(log_out) && nzchar(log_out)) { + dir.create(dirname(log_out), recursive = TRUE, showWarnings = FALSE) + log_out_con <- file(log_out, open = "wt") + sink(log_out_con, split = TRUE) +} +if (!is.null(log_err) && nzchar(log_err)) { + dir.create(dirname(log_err), recursive = TRUE, showWarnings = FALSE) + log_err_con <- file(log_err, open = "wt") + sink(log_err_con, type = "message") +} + +message("=== pseudobulk_de.R starting ===") +suppressPackageStartupMessages({ + library(DESeq2) + library(ComplexHeatmap) + library(circlize) + library(dplyr) + library(tibble) + library(ggplot2) + library(ggrepel) +}) + +agg_dir <- snakemake@input[["agg_dir"]] +results_dir <- snakemake@output[["results_dir"]] +analysis_name <- as.character(snakemake@params[["analysis_name"]]) +min_replicates <- as.integer(snakemake@params[["min_replicates"]]) +de_n_genes <- as.integer(snakemake@params[["de_n_genes"]]) +padj_thr <- as.numeric(snakemake@params[["padj_threshold"]]) +lfc_thr <- as.numeric(snakemake@params[["lfc_threshold"]]) +res_dpi <- as.integer(snakemake@params[["dpi"]]) +group_by_columns <- as.character(snakemake@params[["group_by_columns"]]) +paired_by <- as.character(snakemake@params[["paired_by"]]) +allow_repeated_unpaired <- isTRUE( + as.logical(snakemake@params[["allow_repeated_unpaired"]]) +) +covariates <- as.character(snakemake@params[["covariates"]]) +contrast_names <- as.character(snakemake@params[["contrast_names"]]) +contrast_numerators <- as.character(snakemake@params[["contrast_numerators"]]) +contrast_denominators <- as.character(snakemake@params[["contrast_denominators"]]) +one_vs_rest_enabled <- isTRUE(as.logical(snakemake@params[["one_vs_rest_enabled"]])) +lrt_enabled <- isTRUE(as.logical(snakemake@params[["lrt_enabled"]])) +paired_by <- paired_by[nzchar(paired_by)] +covariates <- covariates[nzchar(covariates)] +condition_level_order <- tryCatch( + as.character(snakemake@params[["condition_level_order"]]), + error = function(e) character(0) +) +condition_level_order <- unique(condition_level_order[nzchar(condition_level_order)]) +px_scale <- res_dpi / 150 +heatmap_font_size <- 8 +min_heatmap_width_in <- 10 +min_heatmap_height_in <- 6 +heatmap_side_space_in <- 4 +heatmap_padding <- grid::unit(c(2, 2, 2, 10), "mm") + +if (!(length(contrast_names) == length(contrast_numerators) && + length(contrast_names) == length(contrast_denominators))) { + stop("Contrast parameter lists have inconsistent lengths.") +} + +# Reconstruct configured palettes from parallel lists. +group_colors <- tryCatch({ + nms <- as.character(snakemake@params[["group_color_names"]]) + vals <- as.character(snakemake@params[["group_color_values"]]) + if (length(nms) > 0 && length(nms) == length(vals)) setNames(vals, nms) + else character(0) +}, error = function(e) character(0)) + +extra_annot_columns <- tryCatch( + as.character(snakemake@params[["extra_annot_columns"]]), + error = function(e) character(0) +) +extra_annot_columns <- extra_annot_columns[nzchar(extra_annot_columns)] +extra_palettes <- tryCatch({ + cols <- as.character(snakemake@params[["extra_anno_col_names"]]) + vals <- as.character(snakemake@params[["extra_anno_values"]]) + hex <- as.character(snakemake@params[["extra_anno_colors"]]) + palettes <- list() + if (length(cols) > 0 && length(cols) == length(vals) && length(vals) == length(hex)) { + for (column in unique(cols)) { + idx <- which(cols == column) + palettes[[column]] <- setNames(hex[idx], vals[idx]) + } + } + palettes +}, error = function(e) list()) + +message(" analysis: ", analysis_name) +message(" group_by: [", paste(group_by_columns, collapse = ", "), "]") +message(" paired_by: ", ifelse(length(paired_by), paired_by, "")) +message(" allow repeated unpaired: ", allow_repeated_unpaired) +message(" covariates: [", paste(covariates, collapse = ", "), "]") +message(" contrasts: [", paste(contrast_names, collapse = ", "), "]") +message(" one-vs-rest: ", one_vs_rest_enabled) +message(" LRT: ", lrt_enabled) +dir.create(results_dir, recursive = TRUE, showWarnings = FALSE) + + +safe_name <- function(value) { + safe <- gsub("[^A-Za-z0-9_.-]+", "_", as.character(value)) + safe <- gsub("^_+|_+$", "", safe) + ifelse(nzchar(safe), safe, "contrast") +} + + +heatmap_condition_factor <- function(values) { + observed <- unique(as.character(values)) + preferred <- condition_level_order[condition_level_order %in% observed] + factor(as.character(values), levels = unique(c(preferred, observed))) } -message("=== pseudobulk_de.R starting ===") -message("Loading libraries …") - -suppressPackageStartupMessages({ - library(DESeq2) - library(ComplexHeatmap) - library(circlize) - library(RColorBrewer) - library(tidyverse) - library(ggrepel) - library(DEGreport) -}) - -message("Libraries loaded OK") - -# ── Snakemake params (with diagnostics) ────────────────────────────────────── -message("Parsing snakemake params …") - -agg_dir <- snakemake@input[["agg_dir"]] -results_dir <- snakemake@output[["results_dir"]] -message(" agg_dir: ", agg_dir) -message(" results_dir: ", results_dir) - -min_replicates <- as.integer(snakemake@params[["min_replicates"]]) -de_n_genes <- as.integer(snakemake@params[["de_n_genes"]]) -padj_thr <- as.numeric(snakemake@params[["padj_threshold"]]) -lfc_thr <- as.numeric(snakemake@params[["lfc_threshold"]]) -# Plot resolution (config param); defaults to 300 if the rule doesn't pass it. -.dpi_param <- snakemake@params[["dpi"]] -res_dpi <- if (is.null(.dpi_param)) 300L else as.integer(.dpi_param) -px_scale <- res_dpi / 150 # keep physical size, scale pixels -message(" min_reps: ", min_replicates, ", de_n_genes: ", de_n_genes, - ", padj: ", padj_thr, ", lfc: ", lfc_thr) - -# Region levels -message(" Parsing region_levels …") -region_levels <- tryCatch({ - rl <- snakemake@params[["region_levels"]] - if (is.null(rl)) character(0) else as.character(rl) -}, error = function(e) { - message(" WARNING: Failed to parse region_levels: ", conditionMessage(e)) - character(0) -}) -message(" region_levels: [", paste(region_levels, collapse = ", "), "]") - -# Region colors — reconstruct named vector from parallel lists -message(" Parsing region_colors …") -region_colors <- tryCatch({ - rc_names <- snakemake@params[["region_color_names"]] - rc_values <- snakemake@params[["region_color_values"]] - message(" rc_names type: ", class(rc_names), " length: ", length(rc_names)) - message(" rc_values type: ", class(rc_values), " length: ", length(rc_values)) - if (length(rc_names) > 0 && length(rc_names) == length(rc_values)) { - setNames(as.character(rc_values), as.character(rc_names)) - } else { - character(0) - } -}, error = function(e) { - message(" WARNING: Failed to parse region_colors: ", conditionMessage(e)) - character(0) -}) -message(" region_colors: ", paste(names(region_colors), region_colors, sep = "=", collapse = ", ")) - -# ── Design annotation columns + palettes (parallel lists → named vectors) ───── -extra_annot_columns <- tryCatch(as.character(snakemake@params[["extra_annot_columns"]]), - error = function(e) character(0)) -extra_annot_columns <- extra_annot_columns[nzchar(extra_annot_columns)] -extra_palettes <- tryCatch({ - dc <- as.character(snakemake@params[["extra_anno_col_names"]]) - dv <- as.character(snakemake@params[["extra_anno_values"]]) - dh <- as.character(snakemake@params[["extra_anno_colors"]]) - pals <- list() - if (length(dc) > 0 && length(dc) == length(dv) && length(dv) == length(dh)) { - for (u in unique(dc)) { - idx <- which(dc == u) - pals[[u]] <- setNames(dh[idx], dv[idx]) - } - } - pals -}, error = function(e) { - message(" WARNING: Failed to parse design palettes: ", conditionMessage(e)) - list() -}) -message(" extra_annot_columns: [", paste(extra_annot_columns, collapse = ", "), "]") - -# Build a ComplexHeatmap top annotation: Region (existing behaviour) plus one -# track per design column present in `meta_df`, coloured from extra_palettes with -# a grey (#cccccc) fallback for values absent from the config palette. Design -# columns are annotation-only — they are NOT added to the DESeq2 model. -build_top_annotation <- function(meta_df, condition_col, region_cols) { - anno_args <- list(Region = meta_df[[condition_col]]) - anno_cols <- list() - avail <- region_cols[names(region_cols) %in% unique(as.character(meta_df[[condition_col]]))] - if (length(avail) > 0) anno_cols[["Region"]] <- avail - for (dc in extra_annot_columns) { - if (dc %in% colnames(meta_df)) { - vals <- as.character(meta_df[[dc]]) - anno_args[[dc]] <- vals - lv <- unique(vals) - full <- setNames(rep("#cccccc", length(lv)), lv) - pal <- extra_palettes[[dc]] - if (!is.null(pal)) { - common <- intersect(names(pal), lv) - if (length(common) > 0) full[common] <- pal[common] - } - anno_cols[[dc]] <- full - } - } - if (length(anno_cols) > 0) anno_args[["col"]] <- anno_cols - anno_args[["show_legend"]] <- TRUE - do.call(HeatmapAnnotation, anno_args) + +group_annotation_label <- function() { + paste0("Group (", paste(group_by_columns, collapse = " + "), ")") } -message("Params parsed OK") -dir.create(results_dir, recursive = TRUE, showWarnings = FALSE) +heatmap_device_width <- function(n_columns, width_per_column = 55) { + max( + round(min_heatmap_width_in * res_dpi), + round(n_columns * width_per_column * px_scale + heatmap_side_space_in * res_dpi) + ) +} -# ── ComplexHeatmap helper ──────────────────────────────────────────────────── -draw_complex_heatmaps <- function(mat_scaled, meta, condition_col, top_genes, - contrast_name, out_dir, region_lvls, region_cols) { - # Reorder columns by region level - if (length(region_lvls) > 0) { - col_order_factor <- factor(meta[[condition_col]], levels = region_lvls, ordered = TRUE) - } else { - col_order_factor <- factor(meta[[condition_col]]) - } - ord <- order(col_order_factor) - mat_ordered <- mat_scaled[, ord, drop = FALSE] - meta_ordered <- meta[ord, , drop = FALSE] - split_factor <- factor(meta_ordered[[condition_col]], - levels = if (length(region_lvls) > 0) region_lvls else unique(meta_ordered[[condition_col]]), - ordered = TRUE) - - # Drop genes that became all-NaN after scaling (zero variance) — they - # collapse the layout and break row clustering. - finite_rows <- apply(mat_ordered, 1, function(z) all(is.finite(z))) - if (any(!finite_rows)) { - message(" Dropping ", sum(!finite_rows), - " zero-variance gene(s) from heatmap") - mat_ordered <- mat_ordered[finite_rows, , drop = FALSE] - } - if (nrow(mat_ordered) < 2) { - message(" <2 finite genes left — skipping heatmap") - return(invisible(NULL)) +heatmap_device_height <- function(body_mm, extra_height_in = 3) { + max( + round(min_heatmap_height_in * res_dpi), + round((body_mm / 25.4 + extra_height_in) * res_dpi) + ) +} + + +formula_for <- function(condition_col, paired = character(0), covars = character(0), + include_condition = TRUE) { + terms <- c(covars, paired) + if (include_condition) terms <- c(terms, condition_col) + terms <- unique(terms[nzchar(terms)]) + if (length(terms) == 0) return(~1) + as.formula(paste("~", paste(sprintf("`%s`", terms), collapse = " + "))) +} + + +assert_full_rank <- function(meta, design_formula, label) { + matrix <- model.matrix(design_formula, data = meta) + rank <- qr(matrix)$rank + if (rank < ncol(matrix)) { + stop( + label, " model matrix is not full rank (rank ", rank, "/", ncol(matrix), + "). Check for confounded covariates or pairing variables." + ) + } + invisible(matrix) +} + + +build_top_annotation <- function(meta, condition_col) { + group_label <- group_annotation_label() + group_values <- heatmap_condition_factor(meta[[condition_col]]) + annotation_args <- setNames(list(group_values), group_label) + annotation_colors <- list() + present_groups <- levels(group_values) + available <- group_colors[names(group_colors) %in% present_groups] + if (length(available) > 0) annotation_colors[[group_label]] <- available + + for (column in setdiff(extra_annot_columns, group_by_columns)) { + if (column %in% colnames(meta)) { + values <- as.character(meta[[column]]) + annotation_args[[column]] <- values + levels <- unique(values) + palette <- setNames(rep("#cccccc", length(levels)), levels) + configured <- extra_palettes[[column]] + if (!is.null(configured)) { + common <- intersect(names(configured), levels) + palette[common] <- configured[common] + } + annotation_colors[[column]] <- palette + } } - - # Pin the heatmap BODY so each gene row has a fixed physical height. - n_row <- nrow(mat_ordered); n_col <- ncol(mat_ordered) - row_mm <- 5 - body_mm <- max(n_row * row_mm, 40) - body_h <- unit(body_mm, "mm") - dev_h_px <- round((body_mm / 25.4 + 2.6) * res_dpi) # +2.6in for title/anno/legend - - # Region + design-column annotation tracks (grey fallback for missing values) - col_anno <- build_top_annotation(meta_ordered, condition_col, region_cols) - - col_fun <- colorRamp2(c(-2, 0, 2), c("blue", "white", "red")) - - # 1. Unsplit heatmap - tryCatch({ - ht_unsplit <- Heatmap( - mat_ordered, - name = "Z-score", - col = col_fun, - top_annotation = col_anno, - height = body_h, - cluster_columns = TRUE, - cluster_rows = TRUE, - show_row_names = TRUE, - show_column_names = TRUE, - row_names_gp = gpar(fontsize = 7), - column_names_gp = gpar(fontsize = 7), + if (length(annotation_colors) > 0) annotation_args[["col"]] <- annotation_colors + annotation_args[["annotation_name_gp"]] <- grid::gpar(fontsize = heatmap_font_size) + annotation_args[["annotation_legend_param"]] <- lapply( + names(annotation_colors), + function(name) list( + title_gp = grid::gpar(fontsize = heatmap_font_size), + labels_gp = grid::gpar(fontsize = heatmap_font_size) + ) + ) |> setNames(names(annotation_colors)) + # The legend already names each track; hiding row labels avoids collisions + # with right-side legends on short heatmaps. + annotation_args[["show_annotation_name"]] <- FALSE + annotation_args[["show_legend"]] <- TRUE + do.call(HeatmapAnnotation, annotation_args) +} + + +draw_complex_heatmaps <- function(mat_scaled, meta, condition_col, contrast_name, out_dir) { + finite_rows <- apply(mat_scaled, 1, function(values) all(is.finite(values))) + mat_scaled <- mat_scaled[finite_rows, , drop = FALSE] + if (nrow(mat_scaled) < 2) return(invisible(NULL)) + + condition <- heatmap_condition_factor(meta[[condition_col]]) + order_idx <- order(condition) + mat_ordered <- mat_scaled[, order_idx, drop = FALSE] + meta_ordered <- meta[order_idx, , drop = FALSE] + split_factor <- droplevels(condition[order_idx]) + annotation <- build_top_annotation(meta_ordered, condition_col) + color_function <- colorRamp2(c(-2, 0, 2), c("blue", "white", "red")) + + n_row <- nrow(mat_ordered) + n_col <- ncol(mat_ordered) + body_mm <- max(n_row * 5, 40) + body_height <- grid::unit(body_mm, "mm") + device_height <- heatmap_device_height(body_mm) + device_width <- heatmap_device_width(n_col) + legend_settings <- list( + title_gp = grid::gpar(fontsize = heatmap_font_size), + labels_gp = grid::gpar(fontsize = heatmap_font_size) + ) + + tryCatch({ + heatmap <- Heatmap( + mat_ordered, name = "Z-score", col = color_function, + top_annotation = annotation, height = body_height, + cluster_columns = TRUE, cluster_rows = TRUE, + show_row_names = TRUE, show_column_names = TRUE, + row_names_gp = grid::gpar(fontsize = 7), column_names_gp = grid::gpar(fontsize = 7), column_title = paste0("Top DE genes — ", contrast_name), - heatmap_legend_param = list(title = "Z-score") + heatmap_legend_param = legend_settings + ) + png( + file.path(out_dir, paste0("heatmap_unsplit_", safe_name(contrast_name), ".png")), + width = device_width, + height = device_height, res = res_dpi + ) + draw( + heatmap, merge_legend = TRUE, adjust_annotation_extension = TRUE, + padding = heatmap_padding ) - png(file.path(out_dir, paste0("heatmap_unsplit_", contrast_name, ".png")), - width = round(max(800, n_col * 50) * px_scale), - height = dev_h_px, res = res_dpi) - draw(ht_unsplit, merge_legend = TRUE) - dev.off() - }, error = function(e) message(" Unsplit heatmap failed: ", conditionMessage(e))) - - # 2. Split heatmap by region level - tryCatch({ - ht_split <- Heatmap( - mat_ordered, - name = "Z-score", - col = col_fun, - top_annotation = col_anno, - column_split = split_factor, - height = body_h, - cluster_columns = FALSE, - cluster_column_slices = TRUE, - cluster_rows = TRUE, - show_row_names = TRUE, - show_column_names = TRUE, - row_names_gp = gpar(fontsize = 7), - column_names_gp = gpar(fontsize = 7), + dev.off() + }, error = function(e) message(" Unsplit heatmap failed: ", conditionMessage(e))) + + tryCatch({ + heatmap <- Heatmap( + mat_ordered, name = "Z-score", col = color_function, + top_annotation = annotation, column_split = split_factor, + height = body_height, cluster_columns = FALSE, + cluster_column_slices = FALSE, cluster_rows = TRUE, + show_row_names = TRUE, show_column_names = TRUE, + row_names_gp = grid::gpar(fontsize = 7), column_names_gp = grid::gpar(fontsize = 7), column_title = paste0("Top DE genes — ", contrast_name, " (split)"), - heatmap_legend_param = list(title = "Z-score") + heatmap_legend_param = legend_settings + ) + png( + file.path(out_dir, paste0("heatmap_split_", safe_name(contrast_name), ".png")), + width = device_width, + height = device_height, res = res_dpi + ) + draw( + heatmap, merge_legend = TRUE, adjust_annotation_extension = TRUE, + padding = heatmap_padding + ) + dev.off() + }, error = function(e) message(" Split heatmap failed: ", conditionMessage(e))) +} + + +write_volcano <- function(results_df, contrast_name, prefix, out_dir) { + volcano <- results_df %>% + filter(!is.na(padj) & !is.na(log2FoldChange)) %>% + mutate( + sig_group = case_when( + padj < padj_thr & log2FoldChange > lfc_thr ~ "Up", + padj < padj_thr & log2FoldChange < -lfc_thr ~ "Down", + TRUE ~ "NS" + ), + neg_log10p = -log10(pmax(padj, 1e-300)) + ) + labels <- bind_rows( + volcano %>% filter(sig_group == "Up") %>% arrange(desc(log2FoldChange)) %>% head(10), + volcano %>% filter(sig_group == "Down") %>% arrange(log2FoldChange) %>% head(10) + ) + plot <- ggplot(volcano, aes(x = log2FoldChange, y = neg_log10p, color = sig_group)) + + geom_point(size = 0.8, alpha = 0.6) + + scale_color_manual(values = c("NS" = "grey70", "Up" = "#e41a1c", "Down" = "#377eb8"), name = "") + + geom_hline(yintercept = -log10(padj_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + + geom_vline(xintercept = c(-lfc_thr, lfc_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + + ggrepel::geom_text_repel( + data = labels, aes(label = gene), size = 2.5, max.overlaps = 20, + color = "black", segment.size = 0.2, segment.color = "grey50" + ) + + labs(title = contrast_name, subtitle = paste0(prefix, " — Wald test"), + x = "log2 Fold Change", y = "-log10(padj)") + + theme_bw() + ggsave( + file.path(out_dir, paste0("volcano_", safe_name(contrast_name), ".png")), + plot = plot, width = 9, height = 7, dpi = res_dpi + ) +} + + +write_de_heatmap <- function(results_df, vsd, meta, condition_col, contrast_name, out_dir) { + significant <- results_df %>% filter(padj < padj_thr) + top_up <- significant %>% + filter(log2FoldChange > 0) %>% arrange(desc(log2FoldChange)) %>% + head(de_n_genes) %>% pull(gene) + top_down <- significant %>% + filter(log2FoldChange < 0) %>% arrange(log2FoldChange) %>% + head(de_n_genes) %>% pull(gene) + genes <- unique(c(top_up, top_down)) + genes <- genes[genes %in% rownames(assay(vsd))] + if (length(genes) >= 2) { + scaled <- t(scale(t(assay(vsd)[genes, , drop = FALSE]))) + draw_complex_heatmaps(scaled, meta, condition_col, contrast_name, out_dir) + } +} + + +fit_wald <- function(counts, meta, condition_col, paired = character(0), transform = TRUE) { + design_formula <- formula_for(condition_col, paired, covariates, TRUE) + assert_full_rank(meta, design_formula, "Wald") + counts_t <- t(counts) + counts_t <- counts_t[, rownames(meta), drop = FALSE] + dds <- DESeqDataSetFromMatrix(countData = counts_t, colData = meta, design = design_formula) + dds <- DESeq(dds, test = "Wald") + vsd <- NULL + if (transform) { + vsd <- tryCatch( + vst(dds, blind = FALSE), + error = function(e) { + message(" vst failed, using normTransform: ", conditionMessage(e)) + normTransform(dds) + } ) - png(file.path(out_dir, paste0("heatmap_split_", contrast_name, ".png")), - width = round(max(900, n_col * 55) * px_scale), - height = dev_h_px, res = res_dpi) - draw(ht_split, merge_legend = TRUE) - dev.off() - }, error = function(e) message(" Split heatmap failed: ", conditionMessage(e))) -} - - -# ── Helper: run DE on one pseudobulk subgroup ──────────────────────────────── -run_de_for_prefix <- function(prefix, condition_col, sample_col, out_base) { - - counts_file <- file.path(agg_dir, "matrices", paste0("counts_", prefix, ".tsv")) - metadata_file <- file.path(agg_dir, "metadata", paste0("metadata_", prefix, ".tsv")) - - if (!file.exists(counts_file) || !file.exists(metadata_file)) { - message(" Skipping '", prefix, "': files not found.") - return(NULL) } + list(dds = dds, vsd = vsd) +} - dir.create(out_base, recursive = TRUE, showWarnings = FALSE) - - counts <- read.delim(counts_file, row.names = 1, check.names = FALSE) - meta <- read.delim(metadata_file, row.names = 1, check.names = FALSE) - - counts <- round(counts) - counts <- as.matrix(counts) - storage.mode(counts) <- "integer" - - common <- intersect(rownames(counts), rownames(meta)) - if (length(common) == 0) { - message(" '", prefix, "': no common samples. Skipping.") - return(NULL) - } - counts <- counts[common, , drop = FALSE] - meta <- meta[common, , drop = FALSE] - - # Filter to configured region levels if provided - if (length(region_levels) > 0) { - keep <- meta[[condition_col]] %in% region_levels - if (sum(!keep) > 0) { - dropped <- unique(meta[[condition_col]][!keep]) - message(" Filtering out region levels not in config: ", paste(dropped, collapse = ", ")) - } - counts <- counts[keep, , drop = FALSE] - meta <- meta[keep, , drop = FALSE] - meta[[condition_col]] <- factor(meta[[condition_col]], levels = region_levels) - } else { - meta[[condition_col]] <- factor(meta[[condition_col]]) - } - meta[[condition_col]] <- droplevels(meta[[condition_col]]) - conditions <- levels(meta[[condition_col]]) +eligible_multilevel_data <- function(counts, meta, condition_col, sample_col) { + unit_col <- if (length(paired_by)) paired_by else sample_col + replicate_counts <- meta %>% + mutate(.unit = as.character(.data[[unit_col]])) %>% + group_by(.data[[condition_col]]) %>% + summarise(n = n_distinct(.unit), .groups = "drop") + valid <- as.character(replicate_counts[[condition_col]][replicate_counts$n >= min_replicates]) + eligible_meta <- meta[as.character(meta[[condition_col]]) %in% valid, , drop = FALSE] + eligible_counts <- counts[rownames(eligible_meta), , drop = FALSE] + eligible_meta[[condition_col]] <- droplevels( + heatmap_condition_factor(eligible_meta[[condition_col]]) + ) - cond_counts <- table(meta[[condition_col]]) - valid_conds <- names(cond_counts[cond_counts >= min_replicates]) - if (length(valid_conds) < 2) { - message(" '", prefix, "': < 2 conditions with >= ", min_replicates, " replicates. Skipping.") - writeLines("Insufficient replicates.", file.path(out_base, "SKIPPED_insufficient.txt")) - return(NULL) + if (length(paired_by)) { + informative <- eligible_meta %>% + mutate(.row = rownames(eligible_meta)) %>% + group_by(.data[[paired_by]]) %>% + filter(n_distinct(.data[[condition_col]]) >= 2) %>% + pull(.row) + eligible_meta <- eligible_meta[informative, , drop = FALSE] + eligible_counts <- eligible_counts[informative, , drop = FALSE] + eligible_meta[[paired_by]] <- droplevels(factor(eligible_meta[[paired_by]])) } - keep_samples <- meta[[condition_col]] %in% valid_conds - counts <- counts[keep_samples, , drop = FALSE] - meta <- meta[keep_samples, , drop = FALSE] - meta[[condition_col]] <- droplevels(meta[[condition_col]]) - conditions <- levels(meta[[condition_col]]) - gene_sums <- colSums(counts) - counts <- counts[, gene_sums > 0, drop = FALSE] + eligible_meta[[condition_col]] <- droplevels( + heatmap_condition_factor(eligible_meta[[condition_col]]) + ) + list(counts = eligible_counts, meta = eligible_meta) +} - message(" '", prefix, "': ", nrow(counts), " samples, ", ncol(counts), " genes, ", - length(conditions), " conditions: ", paste(conditions, collapse = " -> ")) - counts_t <- t(counts) +run_lrt <- function(counts, meta, condition_col, sample_col, prefix, out_base) { + if (!lrt_enabled) return(invisible(NULL)) + message(" Evaluating omnibus LRT eligibility …") + + eligible <- eligible_multilevel_data(counts, meta, condition_col, sample_col) + lrt_counts <- eligible$counts + lrt_meta <- eligible$meta + eligible_levels <- levels(lrt_meta[[condition_col]]) + n_eligible_levels <- length(eligible_levels) + if (n_eligible_levels < 3L) { + lrt_dir <- file.path(out_base, "LRT") + dir.create(lrt_dir, recursive = TRUE, showWarnings = FALSE) + reason <- paste0( + "LRT and DEGpatterns skipped for '", prefix, "': ", n_eligible_levels, + " eligible comparison-group level(s) remain after exclusions, replicate ", + "filtering, and pairing; at least 3 are required. Levels: ", + if (n_eligible_levels > 0L) paste(eligible_levels, collapse = ", ") else "", + "." + ) + message(" ", reason) + writeLines(reason, file.path(lrt_dir, "SKIPPED_insufficient.txt")) + return(invisible(NULL)) + } + message( + " LRT eligible comparison-group levels (", n_eligible_levels, "): ", + paste(eligible_levels, collapse = ", ") + ) + message(" Running explicit omnibus LRT …") + + full_formula <- formula_for(condition_col, paired_by, covariates, TRUE) + reduced_formula <- formula_for(condition_col, paired_by, covariates, FALSE) + assert_full_rank(lrt_meta, full_formula, "LRT full") + assert_full_rank(lrt_meta, reduced_formula, "LRT reduced") + counts_t <- t(lrt_counts) + counts_t <- counts_t[, rownames(lrt_meta), drop = FALSE] + dds <- DESeqDataSetFromMatrix(countData = counts_t, colData = lrt_meta, design = full_formula) + dds <- DESeq(dds, test = "LRT", reduced = reduced_formula) + result <- results(dds) + # An LRT p-value tests the group term as a whole. DESeq2 also returns one + # coefficient's fold change, which is not an omnibus effect size and is easy to + # misinterpret when there are more than two levels, so omit it here. + result_df <- as.data.frame(result) %>% + rownames_to_column("gene") %>% + select(gene, baseMean, stat, pvalue, padj) %>% + arrange(padj) + lrt_dir <- file.path(out_base, "LRT") + dir.create(lrt_dir, recursive = TRUE, showWarnings = FALSE) + write.table(result_df, file.path(lrt_dir, "LRT_results.tsv"), + sep = "\t", quote = FALSE, row.names = FALSE) + + significant <- rownames(result)[which(result$padj < padj_thr)] + message(" LRT: ", length(significant), " significant genes") + if (length(significant) < 10) return(invisible(NULL)) + + tryCatch({ + transformed <- tryCatch(vst(dds, blind = FALSE), error = function(e) normTransform(dds)) + matrix <- assay(transformed)[significant, , drop = FALSE] + clusters <- DEGreport::degPatterns( + matrix, metadata = lrt_meta, time = condition_col, plot = FALSE + ) + if (!is.null(clusters$df)) { + normalized <- clusters$normalized + old_levels <- sort(unique(normalized$cluster)) + remap <- setNames(seq_along(old_levels), as.character(old_levels)) + normalized$cluster <- as.integer(remap[as.character(normalized$cluster)]) + normalized[[condition_col]] <- heatmap_condition_factor(normalized[[condition_col]]) + cluster_df <- clusters$df + cluster_df$cluster <- as.integer(remap[as.character(cluster_df$cluster)]) + cluster_df <- cluster_df %>% arrange(cluster, genes) + + plot <- DEGreport::degPlotCluster( + normalized, time = condition_col, color = condition_col, points = TRUE + ) + + ggplot2::geom_smooth( + mapping = ggplot2::aes( + x = .data[[condition_col]], y = value, group = 1 + ), + method = "loess", color = "black", se = FALSE, linewidth = 1.2 + ) + + labs( + color = group_annotation_label(), fill = group_annotation_label() + ) + + theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5)) + if (length(group_colors) > 0) { + plot <- plot + + scale_color_manual(values = group_colors, name = group_annotation_label()) + + scale_fill_manual(values = group_colors, name = group_annotation_label()) + } + ggsave(file.path(lrt_dir, "DEGpatterns_groups.png"), plot = plot, + width = 14, height = 10, dpi = max(res_dpi, 500L)) + + grouped_genes <- split(cluster_df$genes, paste0("group_", cluster_df$cluster)) + max_length <- max(lengths(grouped_genes)) + gene_table <- as.data.frame(lapply(grouped_genes, function(genes) { + c(genes, rep("", max_length - length(genes))) + })) + write.table(gene_table, file.path(lrt_dir, "gene_groups.tsv"), + sep = "\t", quote = FALSE, row.names = FALSE) + } + }, error = function(e) message(" DEGpatterns failed: ", conditionMessage(e))) +} - if (!identical(colnames(counts_t), rownames(meta))) { - meta <- meta[colnames(counts_t), , drop = FALSE] - } - design_formula <- as.formula(paste("~", condition_col)) - dds <- DESeqDataSetFromMatrix( - countData = counts_t, colData = meta, design = design_formula - ) - - # ── LRT ──────────────────────────────────────────────────────────────── - message(" LRT …") - dds_lrt <- NULL - tryCatch({ - dds_lrt <- DESeq(dds, test = "LRT", reduced = ~ 1) - res_lrt <- results(dds_lrt) - res_lrt_df <- as.data.frame(res_lrt) %>% - rownames_to_column("gene") %>% - arrange(desc(log2FoldChange)) - # LRT results saved inside DEGpatterns dir (created later) - lrt_save_dir <- file.path(out_base, "DEGpatterns") - dir.create(lrt_save_dir, recursive = TRUE, showWarnings = FALSE) - write.table(res_lrt_df, file.path(lrt_save_dir, "LRT_results.tsv"), - sep = "\t", quote = FALSE, row.names = FALSE) - n_sig <- sum(res_lrt_df$padj < padj_thr, na.rm = TRUE) - message(" LRT: ", n_sig, " significant genes (padj < ", padj_thr, ")") - }, error = function(e) { - message(" LRT failed: ", conditionMessage(e)) +write_unique_marker_outputs <- function(ovr_results, vsd, meta, condition_col, out_dir) { + levels_tested <- names(ovr_results) + up_gene_sets <- lapply(ovr_results, function(result_df) { + result_df %>% + filter(padj < padj_thr & log2FoldChange > lfc_thr) %>% + pull(gene) }) - # ── Pairwise Wald ────────────────────────────────────────────────────── - message(" Wald pairwise tests …") - dds_wald <- DESeq(dds, test = "Wald") + unique_markers <- setNames(lapply(levels_tested, function(level) { + other_genes <- unique(unlist(up_gene_sets[setdiff(levels_tested, level)])) + ovr_results[[level]] %>% + filter(gene %in% setdiff(up_gene_sets[[level]], other_genes)) %>% + arrange(desc(log2FoldChange)) + }), levels_tested) - vsd <- tryCatch( - vst(dds_wald, blind = FALSE), - error = function(e) { - message(" vst failed, using normTransform: ", conditionMessage(e)) - normTransform(dds_wald) - } + marker_counts <- tibble( + level = factor(levels_tested, levels = levels_tested), + n_markers = vapply(unique_markers, nrow, integer(1)) + ) + barplot <- ggplot(marker_counts, aes(x = level, y = n_markers, fill = level)) + + geom_col(show.legend = FALSE) + + geom_text(aes(label = n_markers), vjust = -0.3, size = 3) + + labs(title = "Unique upregulated markers per level", x = NULL, + y = "Number of unique markers") + + theme_bw() + + theme(axis.text.x = element_text(angle = 45, hjust = 1)) + ggsave( + file.path(out_dir, "unique_markers_barplot.png"), barplot, + width = max(6, length(levels_tested) * 1.2), height = 5, dpi = res_dpi ) - pairwise_dir <- file.path(out_base, "pairwise") - dir.create(pairwise_dir, recursive = TRUE, showWarnings = FALSE) - summary_rows <- list() - pairs <- combn(conditions, 2, simplify = FALSE) - - for (pair in pairs) { - cond_a <- pair[1] - cond_b <- pair[2] - contrast_name <- paste0(cond_a, "_vs_", cond_b) - message(" Contrast: ", contrast_name) - - tryCatch({ - res <- results(dds_wald, contrast = c(condition_col, cond_a, cond_b)) - res_df <- as.data.frame(res) %>% - rownames_to_column("gene") %>% - arrange(desc(log2FoldChange)) - - write.table(res_df, file.path(pairwise_dir, paste0(contrast_name, ".tsv")), - sep = "\t", quote = FALSE, row.names = FALSE) - - n_sig <- sum(res_df$padj < padj_thr, na.rm = TRUE) - n_up <- sum(res_df$padj < padj_thr & res_df$log2FoldChange > 0, na.rm = TRUE) - n_down <- sum(res_df$padj < padj_thr & res_df$log2FoldChange < 0, na.rm = TRUE) - message(" ", n_sig, " sig (up:", n_up, ", down:", n_down, ")") - - summary_rows[[contrast_name]] <- tibble( - contrast = contrast_name, n_tested = nrow(res_df), - n_significant = n_sig, n_up = n_up, n_down = n_down - ) - - # Custom volcano plot (grey=NS, red=up, blue=down, top genes labelled) - tryCatch({ - volc_df <- res_df %>% - filter(!is.na(padj) & !is.na(log2FoldChange)) %>% - mutate( - sig_group = case_when( - padj >= padj_thr ~ "NS", - log2FoldChange > lfc_thr ~ "Up", - log2FoldChange < -lfc_thr ~ "Down", - TRUE ~ "NS" - ), - neg_log10p = -log10(pmax(padj, 1e-300)) - ) - - # Top genes to label - top_up <- volc_df %>% filter(sig_group == "Up") %>% - arrange(desc(log2FoldChange)) %>% head(10) - top_down <- volc_df %>% filter(sig_group == "Down") %>% - arrange(log2FoldChange) %>% head(10) - label_genes <- bind_rows(top_up, top_down) - - p <- ggplot(volc_df, aes(x = log2FoldChange, y = neg_log10p, color = sig_group)) + - geom_point(size = 0.8, alpha = 0.6) + - scale_color_manual(values = c("NS" = "grey70", "Up" = "#e41a1c", "Down" = "#377eb8"), - name = "") + - geom_hline(yintercept = -log10(padj_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + - geom_vline(xintercept = c(-lfc_thr, lfc_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + - ggrepel::geom_text_repel( - data = label_genes, aes(label = gene), - size = 2.5, max.overlaps = 20, color = "black", - segment.size = 0.2, segment.color = "grey50" - ) + - labs(title = contrast_name, subtitle = paste0(prefix, " — Wald test"), - x = "log2 Fold Change", y = "-log10(padj)") + - theme_bw() + - theme(legend.position = "right") - - ggsave(file.path(pairwise_dir, paste0("volcano_", contrast_name, ".png")), - plot = p, width = 9, height = 7, dpi = res_dpi) - }, error = function(e) { - message(" Volcano failed: ", conditionMessage(e)) - }) - - # ComplexHeatmap - tryCatch({ - sig_genes <- res_df %>% filter(padj < padj_thr) - top_up <- sig_genes %>% filter(log2FoldChange > 0) %>% head(de_n_genes) %>% pull(gene) - top_down <- sig_genes %>% filter(log2FoldChange < 0) %>% head(de_n_genes) %>% pull(gene) - top_genes <- c(top_up, top_down) - top_genes <- top_genes[top_genes %in% rownames(assay(vsd))] - - if (length(top_genes) >= 2) { - mat <- assay(vsd)[top_genes, , drop = FALSE] - mat_scaled <- t(scale(t(mat))) - - draw_complex_heatmaps( - mat_scaled, meta, condition_col, top_genes, - contrast_name, pairwise_dir, conditions, region_colors - ) - } - }, error = function(e) { - message(" Heatmap failed: ", conditionMessage(e)) - }) - - }, error = function(e) { - message(" Wald test failed: ", conditionMessage(e)) - summary_rows[[contrast_name]] <- tibble( - contrast = contrast_name, n_tested = 0, - n_significant = 0, n_up = 0, n_down = 0, error = conditionMessage(e) - ) - }) - } - - if (length(summary_rows) > 0) { - bind_rows(summary_rows) %>% - write.table(file.path(out_base, "pairwise_summary.tsv"), - sep = "\t", quote = FALSE, row.names = FALSE) + nonempty <- unique_markers[vapply(unique_markers, nrow, integer(1)) > 0] + if (length(nonempty) == 0) { + message(" No unique one-vs-rest markers passed the configured thresholds.") + return(invisible(NULL)) } - # ── DEGpatterns ──────────────────────────────────────────────────────── - if (!is.null(dds_lrt)) { - tryCatch({ - res_lrt_all <- results(dds_lrt) - sig_genes <- rownames(res_lrt_all)[which(res_lrt_all$padj < padj_thr)] - - if (length(sig_genes) >= 10) { - message(" DEGpatterns on ", length(sig_genes), " LRT-significant genes …") - sig_mat <- assay(vsd)[sig_genes, , drop = FALSE] - - deg_dir <- file.path(out_base, "DEGpatterns") - dir.create(deg_dir, recursive = TRUE, showWarnings = FALSE) + all_markers <- bind_rows(nonempty, .id = "level") %>% + arrange(factor(level, levels = levels_tested), desc(log2FoldChange)) + write.table(all_markers, file.path(out_dir, "unique_markers.tsv"), + sep = "\t", quote = FALSE, row.names = FALSE) + + max_length <- max(vapply(unique_markers, nrow, integer(1))) + markers_by_level <- as.data.frame(lapply(unique_markers, function(result_df) { + c(result_df$gene, rep("", max_length - nrow(result_df))) + }), check.names = FALSE) + write.table(markers_by_level, file.path(out_dir, "unique_markers_by_level.tsv"), + sep = "\t", quote = FALSE, row.names = FALSE) + + gene_level <- unlist(lapply(names(nonempty), function(level) { + setNames(rep(level, nrow(nonempty[[level]])), nonempty[[level]]$gene) + })) + genes <- names(gene_level) + genes <- genes[genes %in% rownames(assay(vsd))] + if (length(genes) < 2) return(invisible(NULL)) + + matrix_scaled <- t(scale(t(assay(vsd)[genes, , drop = FALSE]))) + finite <- apply(matrix_scaled, 1, function(values) all(is.finite(values))) + matrix_scaled <- matrix_scaled[finite, , drop = FALSE] + if (nrow(matrix_scaled) < 2) return(invisible(NULL)) + + condition <- heatmap_condition_factor(meta[[condition_col]]) + column_order <- order(condition) + matrix_scaled <- matrix_scaled[, column_order, drop = FALSE] + meta_ordered <- meta[column_order, , drop = FALSE] + row_split <- factor( + gene_level[rownames(matrix_scaled)], + levels = levels_tested + ) + column_split <- droplevels(condition[column_order]) + annotation <- build_top_annotation(meta_ordered, condition_col) + color_function <- colorRamp2(c(-2, 0, 2), c("blue", "white", "red")) + n_rows <- nrow(matrix_scaled) + n_columns <- ncol(matrix_scaled) + body_mm <- max(n_rows * 3, 40) + device_height <- min(heatmap_device_height(body_mm), 30000) + if (device_height == 30000) body_mm <- (30000 / res_dpi - 3) * 25.4 + device_width <- heatmap_device_width(n_columns) + + heatmap <- Heatmap( + matrix_scaled, name = "Z-score", col = color_function, + top_annotation = annotation, height = grid::unit(body_mm, "mm"), + row_split = row_split, cluster_row_slices = FALSE, + row_title_rot = 0, row_gap = grid::unit(3, "mm"), + column_split = column_split, cluster_column_slices = FALSE, + cluster_columns = FALSE, cluster_rows = TRUE, + show_row_names = n_rows <= 1500, show_column_names = TRUE, + row_names_gp = grid::gpar(fontsize = ifelse(n_rows > 400, 3, 5)), + column_names_gp = grid::gpar(fontsize = 7), + row_title_gp = grid::gpar(fontsize = heatmap_font_size), + column_title = "Unique markers per level (all genes)", + heatmap_legend_param = list( + title_gp = grid::gpar(fontsize = heatmap_font_size), + labels_gp = grid::gpar(fontsize = heatmap_font_size) + ) + ) + png( + file.path(out_dir, "unique_markers_heatmap.png"), + width = device_width, + height = device_height, res = res_dpi + ) + draw( + heatmap, merge_legend = TRUE, adjust_annotation_extension = TRUE, + padding = heatmap_padding + ) + dev.off() +} - clusters <- degPatterns( - sig_mat, metadata = meta, time = condition_col, plot = FALSE - ) - if (!is.null(clusters$df)) { - # ── Remap cluster ids to a contiguous 1..N (degPatterns skips ids) - norm <- clusters$normalized - old_levels <- sort(unique(norm$cluster)) - remap <- setNames(seq_along(old_levels), as.character(old_levels)) - # keep cluster as INTEGER — degPlotCluster joins on it internally and a - # factor here triggers "Can't join with ". - norm$cluster <- as.integer(remap[as.character(norm$cluster)]) - - cluster_df <- clusters$df - cluster_df$cluster <- as.integer(remap[as.character(cluster_df$cluster)]) - cluster_df <- cluster_df %>% arrange(cluster, genes) - - # Base plot from degPlotCluster (now facetted by 1..N) - p <- degPlotCluster( - norm, time = condition_col, color = condition_col, points = TRUE - ) - - # Apply custom colors if available - if (length(region_colors) > 0) { - p <- p + - ggplot2::aes(col = .data[[condition_col]]) + - ggplot2::scale_color_manual(values = region_colors) - } - - # Loess trend + rotated x labels - p <- p + - ggplot2::geom_smooth( - mapping = ggplot2::aes(x = .data[[condition_col]], y = value, group = 1), - method = "loess", color = "black", se = FALSE, linewidth = 1.2 - ) + - ggplot2::theme( - axis.text.x = ggplot2::element_text(angle = 90, hjust = 1, vjust = 0.5) - ) - - ggsave(file.path(deg_dir, "DEGpatterns_groups.png"), # renamed - plot = p, width = 14, height = 10, dpi = max(res_dpi, 500L)) - message(" DEGpatterns: ", length(old_levels), " groups (renumbered 1..", - length(old_levels), ")") - - # Gene groups TSV — use the remapped, contiguous group numbers - tryCatch({ - grouped_genes <- split(cluster_df$genes, - paste0("group_", cluster_df$cluster)) - max_len <- max(sapply(grouped_genes, length)) - gene_df <- as.data.frame(do.call(cbind, lapply(grouped_genes, function(x) { - c(x, rep("", max_len - length(x))) - }))) - colnames(gene_df) <- gsub("[^A-Za-z0-9_]", "_", colnames(gene_df)) - colnames(gene_df) <- gsub("_+", "_", colnames(gene_df)) - colnames(gene_df) <- gsub("_$", "", colnames(gene_df)) - write.table(gene_df, file.path(deg_dir, "gene_groups.tsv"), - sep = "\t", quote = FALSE, row.names = FALSE) - message(" Gene groups saved: ", ncol(gene_df), " groups") - }, error = function(e) { - message(" Gene groups export failed: ", conditionMessage(e)) - }) - - # ── Per-group heatmaps: the genes of every DEGpatterns group ───── - # Reuses the DE heatmap helper (unsplit + region-split, z-scored vsd), - # written alongside the group plots in DEGpatterns/. - tryCatch({ - for (g in sort(unique(cluster_df$cluster))) { - genes_g <- cluster_df$genes[cluster_df$cluster == g] - genes_g <- genes_g[genes_g %in% rownames(assay(vsd))] - if (length(genes_g) >= 2) { - mat_g_scaled <- t(scale(t(assay(vsd)[genes_g, , drop = FALSE]))) - draw_complex_heatmaps( - mat_g_scaled, meta, condition_col, genes_g, - paste0("group_", g), deg_dir, conditions, region_colors - ) - } - } - message(" DEGpatterns per-group heatmaps written") - }, error = function(e) { - message(" DEGpatterns per-group heatmaps failed: ", conditionMessage(e)) - }) - } - } else { - message(" Too few LRT-significant genes for DEGpatterns (", length(sig_genes), ")") - } - }, error = function(e) { - message(" DEGpatterns failed: ", conditionMessage(e)) - }) - } +run_one_vs_rest <- function(counts, meta, condition_col, sample_col, prefix, out_base) { + if (!one_vs_rest_enabled) return(invisible(NULL)) + message(" Evaluating one-vs-rest eligibility …") - # ── One-vs-rest comparisons + unique marker identification ────────── - message(" One-vs-rest comparisons …") ovr_dir <- file.path(out_base, "one_vs_rest") dir.create(ovr_dir, recursive = TRUE, showWarnings = FALSE) + eligible <- eligible_multilevel_data(counts, meta, condition_col, sample_col) + eligible_counts <- eligible$counts + eligible_meta <- eligible$meta + eligible_levels <- levels(eligible_meta[[condition_col]]) + if (length(eligible_levels) < 3L) { + reason <- paste0( + "One-vs-rest skipped for '", prefix, "': ", length(eligible_levels), + " eligible comparison-group level(s) remain after exclusions, replicate ", + "filtering, and pairing; at least 3 are required. Levels: ", + if (length(eligible_levels)) paste(eligible_levels, collapse = ", ") else "", + "." + ) + message(" ", reason) + writeLines(reason, file.path(ovr_dir, "SKIPPED_insufficient.txt")) + return(invisible(NULL)) + } + message(" One-vs-rest levels: ", paste(eligible_levels, collapse = ", ")) - ovr_results <- list() # store results for cross-comparison + # A common multilevel transformation keeps all heatmaps directly comparable. + heatmap_fit <- fit_wald( + eligible_counts, eligible_meta, condition_col, paired_by, transform = TRUE + ) + ovr_results <- list() - for (focal in conditions) { - focal_safe <- gsub("[^A-Za-z0-9_]", "_", focal) + for (focal in eligible_levels) { message(" ", focal, " vs rest") - tryCatch({ - # Create binary factor: focal vs rest - meta_ovr <- meta - meta_ovr[["ovr_group"]] <- ifelse(meta_ovr[[condition_col]] == focal, focal, "rest") - meta_ovr[["ovr_group"]] <- factor(meta_ovr[["ovr_group"]], levels = c("rest", focal)) + focal_meta <- eligible_meta + focal_counts <- eligible_counts + if (length(paired_by)) { + informative <- focal_meta %>% + mutate(.row = rownames(focal_meta)) %>% + group_by(.data[[paired_by]]) %>% + filter( + any(as.character(.data[[condition_col]]) == focal) & + any(as.character(.data[[condition_col]]) != focal) + ) %>% + pull(.row) + focal_meta <- focal_meta[informative, , drop = FALSE] + focal_counts <- focal_counts[informative, , drop = FALSE] + focal_meta[[paired_by]] <- droplevels(factor(focal_meta[[paired_by]])) + n_pairs <- n_distinct(focal_meta[[paired_by]]) + if (n_pairs < min_replicates) { + stop("only ", n_pairs, " informative pairs; need ", min_replicates) + } + } - dds_ovr <- DESeqDataSetFromMatrix( - countData = counts_t, colData = meta_ovr, design = ~ ovr_group + focal_meta[["ovr_group"]] <- factor( + ifelse(as.character(focal_meta[[condition_col]]) == focal, "focal", "rest"), + levels = c("rest", "focal") ) - dds_ovr <- DESeq(dds_ovr, test = "Wald") - res_ovr <- results(dds_ovr, contrast = c("ovr_group", focal, "rest")) - - res_ovr_df <- as.data.frame(res_ovr) %>% + fit <- fit_wald( + focal_counts, focal_meta, "ovr_group", paired_by, transform = FALSE + ) + result <- results( + fit$dds, contrast = c("ovr_group", "focal", "rest"), alpha = padj_thr + ) + result_df <- as.data.frame(result) %>% rownames_to_column("gene") %>% arrange(desc(log2FoldChange)) - - write.table(res_ovr_df, file.path(ovr_dir, paste0(focal_safe, "_vs_rest.tsv")), + output_name <- paste0(safe_name(focal), "_vs_rest") + write.table(result_df, file.path(ovr_dir, paste0(output_name, ".tsv")), sep = "\t", quote = FALSE, row.names = FALSE) - - n_sig <- sum(res_ovr_df$padj < padj_thr, na.rm = TRUE) - n_up <- sum(res_ovr_df$padj < padj_thr & res_ovr_df$log2FoldChange > 0, na.rm = TRUE) - message(" ", n_sig, " sig (", n_up, " up)") - - # Store for cross-comparison - ovr_results[[focal]] <- res_ovr_df - - # Volcano plot (same style as pairwise) - tryCatch({ - volc_df <- res_ovr_df %>% - filter(!is.na(padj) & !is.na(log2FoldChange)) %>% - mutate( - sig_group = case_when( - padj >= padj_thr ~ "NS", - log2FoldChange > lfc_thr ~ "Up", - log2FoldChange < -lfc_thr ~ "Down", - TRUE ~ "NS" - ), - neg_log10p = -log10(pmax(padj, 1e-300)) - ) - top_up <- volc_df %>% filter(sig_group == "Up") %>% arrange(desc(log2FoldChange)) %>% head(10) - top_down <- volc_df %>% filter(sig_group == "Down") %>% arrange(log2FoldChange) %>% head(10) - label_genes <- bind_rows(top_up, top_down) - - p <- ggplot(volc_df, aes(x = log2FoldChange, y = neg_log10p, color = sig_group)) + - geom_point(size = 0.8, alpha = 0.6) + - scale_color_manual(values = c("NS" = "grey70", "Up" = "#e41a1c", "Down" = "#377eb8"), name = "") + - geom_hline(yintercept = -log10(padj_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + - geom_vline(xintercept = c(-lfc_thr, lfc_thr), linetype = "dashed", color = "grey40", linewidth = 0.3) + - ggrepel::geom_text_repel(data = label_genes, aes(label = gene), size = 2.5, - max.overlaps = 20, color = "black", - segment.size = 0.2, segment.color = "grey50") + - labs(title = paste0(focal, " vs rest"), x = "log2 Fold Change", y = "-log10(padj)") + - theme_bw() + theme(legend.position = "right") - - ggsave(file.path(ovr_dir, paste0("volcano_", focal_safe, "_vs_rest.png")), - plot = p, width = 9, height = 7, dpi = res_dpi) - }, error = function(e) { - message(" OVR volcano failed: ", conditionMessage(e)) - }) - - # Top DE gene heatmap - tryCatch({ - sig_genes_ovr <- res_ovr_df %>% filter(padj < padj_thr) - top_up_genes <- sig_genes_ovr %>% filter(log2FoldChange > 0) %>% head(de_n_genes) %>% pull(gene) - top_down_genes <- sig_genes_ovr %>% filter(log2FoldChange < 0) %>% head(de_n_genes) %>% pull(gene) - top_genes_ovr <- c(top_up_genes, top_down_genes) - top_genes_ovr <- top_genes_ovr[top_genes_ovr %in% rownames(assay(vsd))] - - if (length(top_genes_ovr) >= 2) { - draw_complex_heatmaps( - t(scale(t(assay(vsd)[top_genes_ovr, , drop = FALSE]))), - meta, condition_col, top_genes_ovr, - paste0(focal_safe, "_vs_rest"), ovr_dir, conditions, region_colors - ) - } - }, error = function(e) { - message(" OVR heatmap failed: ", conditionMessage(e)) - }) - + write_volcano(result_df, paste0(focal, " vs rest"), prefix, ovr_dir) + write_de_heatmap( + result_df, heatmap_fit$vsd, eligible_meta, condition_col, + output_name, ovr_dir + ) + ovr_results[[focal]] <- result_df }, error = function(e) { - message(" OVR test failed for '", focal, "': ", conditionMessage(e)) + message(" One-vs-rest test skipped for '", focal, "': ", conditionMessage(e)) }) } - # ── Unique marker identification ─────────────────────────────────── - # A gene is a unique marker for level X if it is: - # - significantly UP in X vs rest (padj < thr, log2FC > lfc_thr) - # - NOT significantly UP in any other level vs rest - if (length(ovr_results) >= 2) { - message(" Identifying unique markers …") - tryCatch({ - # For each level, get the set of significantly upregulated genes - up_gene_sets <- lapply(ovr_results, function(df) { - df %>% filter(padj < padj_thr & log2FoldChange > lfc_thr) %>% pull(gene) - }) - - unique_markers <- list() - for (lvl in names(up_gene_sets)) { - others <- setdiff(names(up_gene_sets), lvl) - other_genes <- unique(unlist(up_gene_sets[others])) - unique_to_lvl <- setdiff(up_gene_sets[[lvl]], other_genes) - - if (length(unique_to_lvl) > 0) { - # Get the DE stats for these genes - lvl_df <- ovr_results[[lvl]] %>% - filter(gene %in% unique_to_lvl) %>% - arrange(desc(log2FoldChange)) - unique_markers[[lvl]] <- lvl_df - message(" ", lvl, ": ", nrow(lvl_df), " unique markers") - } else { - message(" ", lvl, ": 0 unique markers") - } - } + if (length(ovr_results) != length(eligible_levels)) { + reason <- paste0( + "Unique-marker summaries skipped: only ", length(ovr_results), " of ", + length(eligible_levels), " one-vs-rest tests completed." + ) + message(" ", reason) + writeLines(reason, file.path(ovr_dir, "SKIPPED_unique_markers_incomplete.txt")) + return(invisible(ovr_results)) + } + write_unique_marker_outputs( + ovr_results, heatmap_fit$vsd, eligible_meta, condition_col, ovr_dir + ) + invisible(ovr_results) +} - # Save as combined TSV - if (length(unique_markers) > 0) { - all_markers <- bind_rows(unique_markers, .id = "level") %>% - arrange(level, desc(log2FoldChange)) - write.table(all_markers, file.path(ovr_dir, "unique_markers.tsv"), - sep = "\t", quote = FALSE, row.names = FALSE) - - # Also save as column-format (one column per level, padded) - max_len <- max(sapply(unique_markers, nrow)) - marker_cols <- lapply(unique_markers, function(df) { - c(df$gene, rep("", max_len - nrow(df))) - }) - marker_df <- as.data.frame(marker_cols) - colnames(marker_df) <- gsub("[^A-Za-z0-9_]", "_", colnames(marker_df)) - write.table(marker_df, file.path(ovr_dir, "unique_markers_by_level.tsv"), - sep = "\t", quote = FALSE, row.names = FALSE) - } - # Summary barplot: number of unique markers per level - if (length(unique_markers) > 0) { - counts_bar <- tibble( - level = factor(names(unique_markers), levels = conditions), - n_markers = sapply(unique_markers, nrow) +run_de_for_prefix <- function(prefix, condition_col, sample_col, out_base) { + counts_file <- file.path(agg_dir, "matrices", paste0("counts_", prefix, ".tsv")) + metadata_file <- file.path(agg_dir, "metadata", paste0("metadata_", prefix, ".tsv")) + if (!file.exists(counts_file) || !file.exists(metadata_file)) return(NULL) + dir.create(out_base, recursive = TRUE, showWarnings = FALSE) + + counts <- read.delim(counts_file, row.names = 1, check.names = FALSE) + meta <- read.delim(metadata_file, row.names = 1, check.names = FALSE) + counts <- round(as.matrix(counts)) + storage.mode(counts) <- "integer" + common <- intersect(rownames(counts), rownames(meta)) + counts <- counts[common, , drop = FALSE] + meta <- meta[common, , drop = FALSE] + + required <- unique(c(condition_col, sample_col, paired_by, covariates)) + missing <- setdiff(required, colnames(meta)) + if (length(missing) > 0) stop("Missing pseudobulk metadata columns: ", paste(missing, collapse = ", ")) + complete <- complete.cases(meta[, required, drop = FALSE]) + counts <- counts[complete, , drop = FALSE] + meta <- meta[complete, , drop = FALSE] + meta[[condition_col]] <- heatmap_condition_factor(meta[[condition_col]]) + for (column in unique(c(paired_by, covariates))) meta[[column]] <- factor(meta[[column]]) + + unit_col <- if (length(paired_by)) paired_by else sample_col + unit_groups <- meta[, c(unit_col, condition_col), drop = FALSE] + duplicate_units <- duplicated(unit_groups) | duplicated(unit_groups, fromLast = TRUE) + if (any(duplicate_units)) { + examples <- unique(apply(unit_groups[duplicate_units, , drop = FALSE], 1, paste, collapse = " / ")) + stop( + "More than one pseudobulk observation exists for an independent unit/group: ", + paste(head(examples, 5), collapse = ", "), + ". Choose an aggregation aligned with group_by; repeated observations are not independent." + ) + } + if (!length(paired_by)) { + groups_per_sample <- tapply( + as.character(meta[[condition_col]]), as.character(meta[[sample_col]]), + function(values) length(unique(values)) + ) + if (any(groups_per_sample > 1)) { + if (!allow_repeated_unpaired) { + stop( + "At least one sample contributes multiple comparison levels. Configure paired_by: ", + sample_col, " for a paired design, or explicitly set ", + "allow_repeated_unpaired: true to treat repeated pseudobulks as independent." ) - p <- ggplot(counts_bar, aes(x = level, y = n_markers, fill = level)) + - geom_col(show.legend = FALSE) + - geom_text(aes(label = n_markers), vjust = -0.3, size = 3) + - labs(title = "Unique upregulated markers per level", - x = "", y = "Number of unique markers") + - theme_bw() + - theme(axis.text.x = element_text(angle = 45, hjust = 1)) - ggsave(file.path(ovr_dir, "unique_markers_barplot.png"), - plot = p, width = max(6, length(unique_markers) * 1.2), height = 5, dpi = res_dpi) - } - - # Heatmap: ALL unique marker genes (rows), samples (cols), row-split by - # the level each gene is unique to. May be tall — that is intended. - if (length(unique_markers) > 0) { - tryCatch({ - # gene -> level map, preserving level order in `conditions` - gene_level <- unlist(lapply(names(unique_markers), function(lvl) - setNames(rep(lvl, nrow(unique_markers[[lvl]])), - unique_markers[[lvl]]$gene))) - genes_all <- names(gene_level) - genes_all <- genes_all[genes_all %in% rownames(assay(vsd))] - - if (length(genes_all) >= 2) { - mat <- assay(vsd)[genes_all, , drop = FALSE] - mat_scaled <- t(scale(t(mat))) - - # order columns by region level - col_ord <- order(factor(meta[[condition_col]], - levels = if (length(conditions) > 0) conditions - else unique(meta[[condition_col]]))) - mat_scaled <- mat_scaled[, col_ord, drop = FALSE] - meta_ord <- meta[col_ord, , drop = FALSE] - - # drop zero-variance genes (all-NaN rows after scaling) - finite_rows <- apply(mat_scaled, 1, function(z) all(is.finite(z))) - mat_scaled <- mat_scaled[finite_rows, , drop = FALSE] - - if (nrow(mat_scaled) >= 2) { - row_split <- factor(gene_level[rownames(mat_scaled)], - levels = if (length(conditions) > 0) conditions - else unique(gene_level)) - - col_anno <- build_top_annotation(meta_ord, condition_col, region_colors) - - col_fun <- colorRamp2(c(-2, 0, 2), c("blue", "white", "red")) - n_row <- nrow(mat_scaled); n_col <- ncol(mat_scaled) - res_png <- res_dpi - extra_in <- 3.0 # title + anno + legend - max_px <- 30000 # png/cairo device ceiling - row_mm <- 3 # desired mm per gene row - body_mm <- max(n_row * row_mm, 40) - dev_h_px <- round((body_mm / 25.4 + extra_in) * res_png) - if (dev_h_px > max_px) { # too tall for the device: - dev_h_px <- max_px # cap and shrink rows to fit - body_mm <- (max_px / res_png - extra_in) * 25.4 - } - row_fs <- if (n_row > 400) 3 else 5 # smaller font when dense - - ht_uniq <- Heatmap( - mat_scaled, name = "Z-score", col = col_fun, - top_annotation = col_anno, - height = unit(body_mm, "mm"), - row_split = row_split, - cluster_row_slices = FALSE, - cluster_columns = FALSE, - cluster_rows = TRUE, - show_row_names = (n_row <= 1500), # labels unreadable beyond this - show_column_names = TRUE, - row_names_gp = gpar(fontsize = row_fs), - column_names_gp = gpar(fontsize = 7), - row_title_gp = gpar(fontsize = 8), - column_title = "Unique markers per level (all genes)", - heatmap_legend_param = list(title = "Z-score") - ) - png(file.path(ovr_dir, "unique_markers_heatmap.png"), - width = round(max(900, n_col * 55) * px_scale), - height = dev_h_px, res = res_png) - draw(ht_uniq, merge_legend = TRUE) - dev.off() - message(" Unique markers heatmap: ", n_row, " genes") - } - } - }, error = function(e) { - message(" Unique markers heatmap failed: ", conditionMessage(e)) - }) } - - }, error = function(e) { - message(" Unique marker identification failed: ", conditionMessage(e)) - }) + assumption <- paste0( + "WARNING: allow_repeated_unpaired is enabled. Multiple pseudobulks from the same ", + "sample are treated as independent observations; within-sample correlation is ", + "ignored and standard errors may be underestimated." + ) + message(" ", assumption) + writeLines(assumption, file.path(out_base, "WARNING_repeated_unpaired.txt")) + } } - - return(summary_rows) -} - - -# ── Main ───────────────────────────────────────────────────────────────────── -manifest_file <- file.path(agg_dir, "manifest.tsv") - -if (!file.exists(manifest_file)) { - message("No manifest.tsv found.") - writeLines("No pseudobulk matrices found.", file.path(results_dir, "SKIPPED_no_manifest.txt")) - if (exists("log_err_con")) { sink(type = "message"); close(log_err_con) } - if (exists("log_out_con")) { sink(); close(log_out_con) } - quit(save = "no", status = 0) -} - -manifest <- read.delim(manifest_file) -message("Manifest: ", nrow(manifest), " subgroups") - -for (i in seq_len(nrow(manifest))) { - row <- manifest[i, ] - prefix <- row$prefix - condition_col <- row$condition_col - sample_col <- row$sample_col - - message("\n--- Processing: ", prefix, " ---") - - out_base <- if (prefix == "pooled") results_dir else file.path(results_dir, prefix) + + counts <- counts[, colSums(counts) > 0, drop = FALSE] + if (nrow(counts) == 0 || ncol(counts) == 0) { + writeLines("No usable counts.", file.path(out_base, "SKIPPED_no_counts.txt")) + return(NULL) + } + + message(" '", prefix, "': ", nrow(counts), " observations, ", ncol(counts), " genes") + tryCatch( + run_lrt(counts, meta, condition_col, sample_col, prefix, out_base), + error = function(e) { + message(" LRT failed: ", conditionMessage(e)) + lrt_dir <- file.path(out_base, "LRT") + dir.create(lrt_dir, recursive = TRUE, showWarnings = FALSE) + writeLines(conditionMessage(e), file.path(lrt_dir, "ERROR.txt")) + } + ) tryCatch( - run_de_for_prefix(prefix, condition_col, sample_col, out_base), + run_one_vs_rest(counts, meta, condition_col, sample_col, prefix, out_base), error = function(e) { - message(" FAILED for '", prefix, "': ", conditionMessage(e)) - dir.create(out_base, recursive = TRUE, showWarnings = FALSE) - writeLines(conditionMessage(e), file.path(out_base, "ERROR.txt")) + message(" One-vs-rest failed: ", conditionMessage(e)) + ovr_dir <- file.path(out_base, "one_vs_rest") + dir.create(ovr_dir, recursive = TRUE, showWarnings = FALSE) + writeLines(conditionMessage(e), file.path(ovr_dir, "ERROR.txt")) } ) -} - -message("\n=== Pseudobulk DE complete ===") -if (exists("log_err_con")) { sink(type = "message"); close(log_err_con) } -if (exists("log_out_con")) { sink(); close(log_out_con) } + pairwise_dir <- file.path(out_base, "pairwise") + dir.create(pairwise_dir, recursive = TRUE, showWarnings = FALSE) + summary_rows <- list() + + # Unpaired analyses share a single fit across all sufficiently replicated levels. + shared_fit <- NULL + shared_meta <- NULL + if (!length(paired_by) && length(contrast_names) > 0) { + replicate_counts <- meta %>% + mutate(.sample = as.character(.data[[sample_col]])) %>% + group_by(.data[[condition_col]]) %>% + summarise(n = n_distinct(.sample), .groups = "drop") + valid <- as.character(replicate_counts[[condition_col]][replicate_counts$n >= min_replicates]) + shared_meta <- meta[as.character(meta[[condition_col]]) %in% valid, , drop = FALSE] + shared_meta[[condition_col]] <- droplevels( + heatmap_condition_factor(shared_meta[[condition_col]]) + ) + if (nlevels(shared_meta[[condition_col]]) >= 2) { + shared_counts <- counts[rownames(shared_meta), , drop = FALSE] + shared_fit <- fit_wald(shared_counts, shared_meta, condition_col) + } + } + + for (index in seq_along(contrast_names)) { + contrast_name <- contrast_names[index] + numerator <- contrast_numerators[index] + denominator <- contrast_denominators[index] + message(" Contrast: ", contrast_name, " (", numerator, " vs ", denominator, ")") + + tryCatch({ + if (length(paired_by)) { + pair_meta <- meta[as.character(meta[[condition_col]]) %in% c(numerator, denominator), , drop = FALSE] + pair_meta[[condition_col]] <- droplevels( + heatmap_condition_factor(pair_meta[[condition_col]]) + ) + complete_units <- pair_meta %>% + mutate(.row = rownames(pair_meta)) %>% + group_by(.data[[paired_by]]) %>% + filter(all(c(numerator, denominator) %in% as.character(.data[[condition_col]]))) %>% + pull(.row) + pair_meta <- pair_meta[complete_units, , drop = FALSE] + pair_meta[[condition_col]] <- droplevels( + heatmap_condition_factor(pair_meta[[condition_col]]) + ) + pair_meta[[paired_by]] <- droplevels(factor(pair_meta[[paired_by]])) + n_replicates <- n_distinct(pair_meta[[paired_by]]) + if (n_replicates < min_replicates) { + stop("only ", n_replicates, " complete pairs; need ", min_replicates) + } + pair_counts <- counts[rownames(pair_meta), , drop = FALSE] + fit <- fit_wald(pair_counts, pair_meta, condition_col, paired_by) + fit_meta <- pair_meta + } else { + if (is.null(shared_fit) || + !all(c(numerator, denominator) %in% levels(shared_meta[[condition_col]]))) { + stop("one or both levels have fewer than ", min_replicates, " independent samples") + } + fit <- shared_fit + fit_meta <- shared_meta + } + + result <- results( + fit$dds, + contrast = c(condition_col, numerator, denominator), + alpha = padj_thr + ) + result_df <- as.data.frame(result) %>% + rownames_to_column("gene") %>% arrange(desc(log2FoldChange)) + output_name <- safe_name(contrast_name) + write.table(result_df, file.path(pairwise_dir, paste0(output_name, ".tsv")), + sep = "\t", quote = FALSE, row.names = FALSE) + + n_sig <- sum(result_df$padj < padj_thr, na.rm = TRUE) + n_up <- sum(result_df$padj < padj_thr & result_df$log2FoldChange > 0, na.rm = TRUE) + n_down <- sum(result_df$padj < padj_thr & result_df$log2FoldChange < 0, na.rm = TRUE) + summary_rows[[contrast_name]] <- tibble( + contrast = contrast_name, numerator = numerator, denominator = denominator, + n_tested = nrow(result_df), n_significant = n_sig, n_up = n_up, n_down = n_down, + error = NA_character_ + ) + write_volcano(result_df, contrast_name, prefix, pairwise_dir) + write_de_heatmap(result_df, fit$vsd, fit_meta, condition_col, contrast_name, pairwise_dir) + }, error = function(e) { + message(" Contrast skipped: ", conditionMessage(e)) + summary_rows[[contrast_name]] <<- tibble( + contrast = contrast_name, numerator = numerator, denominator = denominator, + n_tested = 0L, n_significant = 0L, n_up = 0L, n_down = 0L, + error = conditionMessage(e) + ) + }) + } + + if (length(summary_rows) > 0) { + bind_rows(summary_rows) %>% + write.table(file.path(out_base, "pairwise_summary.tsv"), + sep = "\t", quote = FALSE, row.names = FALSE) + } + invisible(summary_rows) +} + + +manifest_file <- file.path(agg_dir, "manifest.tsv") +if (!file.exists(manifest_file)) { + writeLines("No pseudobulk matrices found.", file.path(results_dir, "SKIPPED_no_manifest.txt")) +} else { + manifest <- read.delim(manifest_file) + message("Manifest: ", nrow(manifest), " subgroup(s)") + for (index in seq_len(nrow(manifest))) { + row <- manifest[index, ] + prefix <- row$prefix + out_base <- if (prefix == "pooled") results_dir else file.path(results_dir, prefix) + tryCatch( + run_de_for_prefix(prefix, row$condition_col, row$sample_col, out_base), + error = function(e) { + message(" FAILED for '", prefix, "': ", conditionMessage(e)) + dir.create(out_base, recursive = TRUE, showWarnings = FALSE) + writeLines(conditionMessage(e), file.path(out_base, "ERROR.txt")) + } + ) + } +} + +message("=== Pseudobulk DE complete ===") +if (exists("log_err_con")) { sink(type = "message"); close(log_err_con) } +if (exists("log_out_con")) { sink(); close(log_out_con) } diff --git a/workflow/scripts/qc_sweep.py b/workflow/scripts/qc_sweep.py index 050ac1a..414cd3a 100644 --- a/workflow/scripts/qc_sweep.py +++ b/workflow/scripts/qc_sweep.py @@ -21,6 +21,13 @@ import pandas as pd import scanpy as sc +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from plotting_legends import grid_figure, move_legend_to_axis + # ── Logging ────────────────────────────────────────────────────────────────── log_handlers = [logging.StreamHandler(sys.stderr)] if hasattr(snakemake, "log"): @@ -46,6 +53,7 @@ ref_label_key = str(getattr(snakemake.params, "ref_label_key", "cell_type")) region_colors = dict(getattr(snakemake.params, "region_colors", {}) or {}) region_levels = list(getattr(snakemake.params, "region_levels", []) or []) + spatial_point_size = float(getattr(snakemake.params, "spatial_point_size", 20)) out = snakemake.output out_dir = os.path.dirname(str(out.violins_png)) @@ -96,7 +104,7 @@ # (cell centroids are far smaller than the scalefactor-derived Visium spot size, # so without it the dots render invisibly and only the tissue image shows); add # library_id to draw the dots over the embedded image when one is present. - spatial_kw = {"spot_size": 20} + spatial_kw = {"spot_size": spatial_point_size} if isinstance(adata.uns.get("spatial"), dict) and adata.uns["spatial"]: spatial_kw["library_id"] = list(adata.uns["spatial"].keys())[0] has_coords = "spatial" in adata.obsm @@ -201,9 +209,20 @@ def make_landing(label, vals, op, ths, out_path): nrows = 2 if ingest_vals is not None else 1 ncols = 1 + len(ths) - fig, axes = plt.subplots(nrows, ncols, figsize=(5.6 * ncols, 5.0 * nrows), - squeeze=False, - gridspec_kw={"wspace": 0.6, "hspace": 0.35}) + legend_labels = list(ingest_palette) + if ths: + legend_labels.append("removed") + fig, axes, legend_ax = grid_figure( + nrows, + ncols, + legend_labels, + cell_width=5.6, + cell_height=5.0, + wspace=0.18, + hspace=0.3, + top_margin=0.6, + max_legend_columns=min(8, 2 * ncols), + ) def draw(ax, color, title, **extra): try: @@ -220,7 +239,7 @@ def draw(ax, color, title, **extra): adata.uns["_qc_colors"] = [ingest_palette[c] for c in adata.obs["_qc"].cat.categories] draw(axes[r][0], "_qc", "all cells (ingest)", - legend_fontsize=5, na_in_legend=False) + legend_fontsize=12, na_in_legend=False) for j, t in enumerate(ths): mask = (vals < t) if op == "below" else (vals > t) title = f"{label} {t}\n{int(mask.sum()):,} removed" @@ -233,7 +252,7 @@ def draw(ax, color, title, **extra): adata.obs["_qc"] = pd.Categorical(tmp, categories=cats) adata.uns["_qc_colors"] = [ingest_palette[c] for c in cats] draw(axes[r][j + 1], "_qc", title, na_color="#e8e8e8", - na_in_legend=False, legend_fontsize=5) + na_in_legend=False, legend_fontsize=12) r += 1 draw(axes[r][0], None, "all cells") @@ -249,6 +268,14 @@ def draw(ax, color, title, **extra): adata.uns["_qc_colors"] = ["#e41a1c"] draw(axes[r][j + 1], "_qc", title, na_color="#e8e8e8", na_in_legend=False) + move_legend_to_axis( + axes.ravel(), + legend_ax, + title="Cell status / ingest cell type", + fontsize=12, + max_columns=min(8, 2 * ncols), + ) + if "_qc" in adata.obs: del adata.obs["_qc"] adata.uns.pop("_qc_colors", None) diff --git a/workflow/scripts/sample_report.py b/workflow/scripts/sample_report.py index a5e3d09..f14bd37 100644 --- a/workflow/scripts/sample_report.py +++ b/workflow/scripts/sample_report.py @@ -1,391 +1,435 @@ -""" -sample_report.py – Multi-page PDF report, one page per sample -================================================================ -Each page contains: - Row 1: UMAP (clusters) | UMAP (annotation) | Spatial (clusters) | Spatial (annotation) - Row 2: Dotplot (cell type markers) | Barplot (cell proportions by region) - -Memory is bounded by loading one sample at a time. -""" - -import gc -import logging -import os -import sys -import traceback -from pathlib import Path - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.backends.backend_pdf import PdfPages -import matplotlib.gridspec as gridspec -import numpy as np -import pandas as pd -import scanpy as sc - -# Shared composition-barplot helpers (black edge, legend==stack order, config colours) -try: - _here = os.path.dirname(os.path.abspath(__file__)) -except NameError: # very old Snakemake - _here = os.getcwd() -sys.path.insert(0, _here) -from composition_barplots import (draw_stacked_composition, find_niche_column, - build_niche_palette) - - -# ── Logging ────────────────────────────────────────────────────────────────── -log_handlers = [logging.StreamHandler(sys.stderr)] -if hasattr(snakemake, "log"): - if snakemake.log.out: - Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) - log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) - if snakemake.log.err: - Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) - sys.stderr = open(snakemake.log.err, "w") - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)-8s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - handlers=log_handlers, +""" +sample_report.py – Multi-page PDF report, one page per sample +================================================================ +Each sample contains: + - UMAP and spatial panels for clusters, every available annotation, regions, + and niches. + - A composition page for the primary annotation. + - A marker dotplot using the primary annotation (external when configured). + +Memory is bounded by loading one sample at a time. +""" + +import gc +import logging +import os +import sys +import traceback +from pathlib import Path + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.backends.backend_pdf import PdfPages +import matplotlib.gridspec as gridspec +import numpy as np +import pandas as pd +import scanpy as sc + +# Shared composition-barplot helpers (black edge, legend==stack order, config colours) +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from composition_barplots import (draw_stacked_composition, find_niche_column, + build_niche_palette) +from annotation_utils import ( + has_meaningful_annotation, + ordered_annotation_columns, ) -log = logging.getLogger("sample_report") - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -def _rasterize_heavy_layers(fig): - """Rasterise the heavy scatter layers (UMAP / spatial spot collections and - any embedded images) of every axis in a figure, keeping text, lines, axes - and legends as vector. This works around scanpy's ``vector_friendly`` - rasterisation being applied inconsistently (scverse/scanpy#2005), where - spatial scatters in particular stay vector and bloat the PDF (one path per - spot → hundreds of MB). When the page is then written with a finite dpi, the - rasterised layers are baked at that resolution, cutting file size by ~10x - while labels stay crisp/selectable.""" - import matplotlib.collections as mcoll - import matplotlib.image as mimage - for ax in fig.get_axes(): - for art in ax.get_children(): - if isinstance(art, (mcoll.Collection, mimage.AxesImage)): - art.set_rasterized(True) - - -def apply_palette(adata, obs_key, colors_cfg): - """Apply annotation_colors palette to an obs column.""" - if not isinstance(colors_cfg, dict): - return - if obs_key not in adata.obs.columns: - return - cd = colors_cfg.get(obs_key, {}) - if not cd: - # No palette configured for this column: behave like annotate_cells and leave the - # colours to scanpy. Any stale/incomplete *_colors carried in the h5ad is dropped, - # otherwise it would be reused here and can render every category grey. - adata.uns.pop(f"{obs_key}_colors", None) - return - adata.obs[obs_key] = adata.obs[obs_key].astype("category") - cats = adata.obs[obs_key].cat.categories - adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] - - -def apply_region_palette(adata, region_colors): - """Apply region_colors palette to region_annotation.""" - if not region_colors: - adata.uns.pop("region_annotation_colors", None) - return - if "region_annotation" in adata.obs.columns: - adata.obs["region_annotation"] = adata.obs["region_annotation"].astype("category") - cats = adata.obs["region_annotation"].cat.categories - adata.uns["region_annotation_colors"] = [ - region_colors.get(str(c), "#cccccc") for c in cats - ] - - -def _pick_keys(adata, niche_column=None): - """Return (manual_leiden, tsv, auto, niche) obs keys (any may be None).""" +from plotting_legends import ( + category_labels, + move_legend_to_axis, + panel_figure, +) + + +# ── Logging ────────────────────────────────────────────────────────────────── +log_handlers = [logging.StreamHandler(sys.stderr)] +if hasattr(snakemake, "log"): + if snakemake.log.out: + Path(snakemake.log.out).parent.mkdir(parents=True, exist_ok=True) + log_handlers.append(logging.FileHandler(snakemake.log.out, mode="w")) + if snakemake.log.err: + Path(snakemake.log.err).parent.mkdir(parents=True, exist_ok=True) + sys.stderr = open(snakemake.log.err, "w") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=log_handlers, +) +log = logging.getLogger("sample_report") + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _rasterize_heavy_layers(fig): + """Rasterise the heavy scatter layers (UMAP / spatial spot collections and + any embedded images) of every axis in a figure, keeping text, lines, axes + and legends as vector. This works around scanpy's ``vector_friendly`` + rasterisation being applied inconsistently (scverse/scanpy#2005), where + spatial scatters in particular stay vector and bloat the PDF (one path per + spot → hundreds of MB). When the page is then written with a finite dpi, the + rasterised layers are baked at that resolution, cutting file size by ~10x + while labels stay crisp/selectable.""" + import matplotlib.collections as mcoll + import matplotlib.image as mimage + for ax in fig.get_axes(): + for art in ax.get_children(): + if isinstance(art, (mcoll.Collection, mimage.AxesImage)): + art.set_rasterized(True) + + +def apply_palette(adata, obs_key, colors_cfg): + """Apply annotation_colors palette to an obs column.""" + if not isinstance(colors_cfg, dict): + return + if obs_key not in adata.obs.columns: + return + cd = colors_cfg.get(obs_key, {}) + if not cd: + # No palette configured for this column: behave like annotate_cells and leave the + # colours to scanpy. Any stale/incomplete *_colors carried in the h5ad is dropped, + # otherwise it would be reused here and can render every category grey. + adata.uns.pop(f"{obs_key}_colors", None) + return + adata.obs[obs_key] = adata.obs[obs_key].astype("category") + cats = adata.obs[obs_key].cat.categories + adata.uns[f"{obs_key}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] + + +def apply_region_palette(adata, region_colors): + """Apply region_colors palette to region_annotation.""" + if not region_colors: + adata.uns.pop("region_annotation_colors", None) + return + if "region_annotation" in adata.obs.columns: + adata.obs["region_annotation"] = adata.obs["region_annotation"].astype("category") + cats = adata.obs["region_annotation"].cat.categories + adata.uns["region_annotation_colors"] = [ + region_colors.get(str(c), "#cccccc") for c in cats + ] + + +ANNOTATION_TITLES = { + "cell_type_external": "External annotation", + "cell_type_tsv": "TSV annotation", + "cell_type_ingest": "Ingest annotation", +} + + +def _pick_keys(adata, niche_column=None, preferred_annotation=None): + """Return the cluster key, all annotations in precedence order, and niche key.""" manual = adata.uns.get("annotation_leiden_key") if manual not in adata.obs.columns: leiden_cols = sorted(c for c in adata.obs.columns if c.startswith("leiden_")) manual = leiden_cols[0] if leiden_cols else None - tsv = "cell_type_tsv" if "cell_type_tsv" in adata.obs.columns else None - auto = next((c for c in ["cell_type_ingest", "cell_type_external"] - if c in adata.obs.columns), None) + annotations = ordered_annotation_columns( + adata, + preferred=preferred_annotation, + ) niche = find_niche_column(adata, niche_column) - return manual, tsv, auto, niche - - + return manual, annotations, niche + + def _build_page1(adata, sample_id, keys, library_id, has_regions=False): - """Row 1 = UMAPs, Row 2 = spatial, for clusters / tsv / auto / region / niche.""" - manual, tsv, auto, niche = keys - panels = [(manual, "Clusters"), (tsv, "TSV annotation"), - (auto, "Auto annotation")] - if has_regions: # region annotation UMAP + spatial - panels.append(("region_annotation", "Region")) - panels.append((niche, "Spatial niche")) - panels = [(k, t) for k, t in panels if k] - n = len(panels) - fig, axes = plt.subplots(2, n, figsize=(8 * n, 14), - gridspec_kw={"wspace": 0.75, "hspace": 0.35}, - squeeze=False) - fig.suptitle(f"Sample: {sample_id}", fontsize=18, fontweight="bold", y=0.99) - for j, (key, title) in enumerate(panels): - try: - sc.pl.umap(adata, color=key, size=2, frameon=False, title=title, - legend_fontsize=6, na_in_legend=False, - ax=axes[0, j], show=False) - except Exception as e: - log.warning(" UMAP %s failed: %s", title, e); axes[0, j].set_title(f"{title} (failed)") - try: - sc.pl.spatial(adata, color=key, spot_size=20, frameon=False, - title=title, library_id=library_id, - legend_fontsize=6, na_in_legend=False, - ax=axes[1, j], show=False) - except Exception as e: - log.warning(" Spatial %s failed: %s", title, e); axes[1, j].set_title(f"{title} (failed)") - return fig - - -def _top_markers_by(adata, annot_key, n=10): - """Top-n DE markers per annotation group (deduped, order preserved).""" - try: - adata.obs[annot_key] = adata.obs[annot_key].astype("category") - adata.obs[annot_key] = adata.obs[annot_key].cat.remove_unused_categories() - if adata.obs[annot_key].nunique() < 2: - return [] - sc.tl.rank_genes_groups(adata, groupby=annot_key, method="wilcoxon") - names = adata.uns["rank_genes_groups"]["names"] - ordered = [] - for grp in names.dtype.names: - for g in list(names[grp])[:n]: - if g in adata.var_names and g not in ordered: - ordered.append(g) - return ordered - except Exception as e: - log.warning(" marker ranking failed: %s", e) - return [] - - -def _build_page2(adata, sample_id, keys, annotation_colors, sample_col, - has_regions): - """Return a list of page-2 figures. - - • Composition barplots: when the sample has region and/or spatial-niche - information, the cell-type composition is broken down by those (one - absolute + one relative panel each). Only when NEITHER is present does it - fall back to a single by-sample breakdown (the otherwise-trivial view). - • A horizontal marker dotplot (genes on X) with a compact vertical colour - bar and an accurately-sized dot-size legend. - """ - manual, tsv, auto, niche = keys - cmap = (annotation_colors.get(tsv, {}) - if (tsv and isinstance(annotation_colors, dict)) else {}) + """Row 1 = UMAPs and row 2 = spatial for every available annotation.""" + manual, annotations, niche = keys + panels = [(manual, "Clusters")] + panels.extend( + (column, ANNOTATION_TITLES.get(column, column)) + for column in annotations + ) + if has_regions: # region annotation UMAP + spatial + panels.append(("region_annotation", "Region")) + panels.append((niche, "Spatial niche")) + panels = [(k, t) for k, t in panels if k] + n = len(panels) + labels_by_panel = [category_labels(adata, key) for key, _ in panels] + fig, axes, legend_axes = panel_figure( + n, + labels_by_panel, + plot_rows=2, + panel_width=8, + plot_height=6, + wspace=0.18, + hspace=0.28, + top_margin=1.0, + ) + fig.suptitle(f"Sample: {sample_id}", fontsize=18, fontweight="bold", y=0.99) + for j, (key, title) in enumerate(panels): + try: + sc.pl.umap(adata, color=key, size=UMAP_POINT_SIZE, frameon=False, title=title, + legend_fontsize=12, na_in_legend=False, + ax=axes[0, j], show=False) + except Exception as e: + log.warning(" UMAP %s failed: %s", title, e); axes[0, j].set_title(f"{title} (failed)") + try: + sc.pl.spatial(adata, color=key, spot_size=SPATIAL_POINT_SIZE, frameon=False, + title=title, library_id=library_id, + legend_fontsize=12, na_in_legend=False, + ax=axes[1, j], show=False) + except Exception as e: + log.warning(" Spatial %s failed: %s", title, e); axes[1, j].set_title(f"{title} (failed)") + move_legend_to_axis( + [axes[0, j], axes[1, j]], + legend_axes[j], + title=title, + ) + return fig + + +def _top_markers_by(adata, annot_key, n=10): + """Top-n DE markers per annotation group (deduped, order preserved).""" + try: + adata.obs[annot_key] = adata.obs[annot_key].astype("category") + adata.obs[annot_key] = adata.obs[annot_key].cat.remove_unused_categories() + if adata.obs[annot_key].nunique() < 2: + return [] + sc.tl.rank_genes_groups(adata, groupby=annot_key, method="wilcoxon") + names = adata.uns["rank_genes_groups"]["names"] + ordered = [] + for grp in names.dtype.names: + for g in list(names[grp])[:n]: + if g in adata.var_names and g not in ordered: + ordered.append(g) + return ordered + except Exception as e: + log.warning(" marker ranking failed: %s", e) + return [] + + +def _build_page2(adata, sample_id, keys, annotation_colors, sample_col, + has_regions): + """Return a list of page-2 figures. + + • Composition barplots: when the sample has region and/or spatial-niche + information, the cell-type composition is broken down by those (one + absolute + one relative panel each). Only when NEITHER is present does it + fall back to a single by-sample breakdown (the otherwise-trivial view). + • A horizontal marker dotplot (genes on X) with a compact vertical colour + bar and an accurately-sized dot-size legend. + """ + _manual, annotations, niche = keys figs = [] - - # Group the composition by region/niche when available; only fall back to - # the (trivial) per-sample breakdown when there is no region and no niche. - bases = [] - if has_regions: - bases.append(("region_annotation", "Region")) - if niche: - bases.append((niche, "Niche")) - if not bases: - bases.append((sample_col, "Sample")) - - if tsv: + + # Group the composition by region/niche when available; only fall back to + # the (trivial) per-sample breakdown when there is no region and no niche. + bases = [] + if has_regions: + bases.append(("region_annotation", "Region")) + if niche: + bases.append((niche, "Niche")) + if not bases: + bases.append((sample_col, "Sample")) + + composition_key = annotations[0] if annotations else None + if composition_key: + cmap = (annotation_colors.get(composition_key, {}) + if isinstance(annotation_colors, dict) else {}) try: ncol = 2 * len(bases) figb = plt.figure(figsize=(6.5 * ncol, 5.5)) - figb.suptitle(f"Sample: {sample_id} — composition", + annotation_title = ANNOTATION_TITLES.get( + composition_key, + composition_key, + ) + figb.suptitle(f"Sample: {sample_id} — {annotation_title} composition", fontsize=16, fontweight="bold", y=1.02) - gs = gridspec.GridSpec(1, ncol, figure=figb, wspace=0.5) - for i, (gkey, glabel) in enumerate(bases): - ct = pd.crosstab(adata.obs[gkey], adata.obs[tsv]) - ax_abs = figb.add_subplot(gs[0, 2 * i]) - draw_stacked_composition(ax_abs, ct, cmap, normalize=False, - ylabel="Number of cells", xlabel=glabel, - title=f"by {glabel} (absolute)", - legend=False) - ax_rel = figb.add_subplot(gs[0, 2 * i + 1]) - draw_stacked_composition(ax_rel, ct, cmap, normalize=True, - ylabel="Percentage (%)", xlabel=glabel, - title=f"by {glabel} (relative)", - legend_title="Cell type", - legend=(i == len(bases) - 1)) - figs.append(figb) - except Exception as e: - log.warning(" composition barplots failed: %s", e) - - # Spatial-niche composition per region (niche AS the stack; colours match - # the spatial_niches rule via the shared value-deterministic palette). - if niche and has_regions: - try: - nvals = adata.obs[niche].astype(str) - try: - ncats = sorted(nvals.unique(), key=lambda x: int(x)) - except ValueError: - ncats = sorted(nvals.unique()) - niche_cfg = (annotation_colors.get("spatial_niche", {}) - if isinstance(annotation_colors, dict) else {}) - niche_pal = build_niche_palette(ncats, niche_cfg) - ctn = pd.crosstab(adata.obs["region_annotation"], nvals) - fign = plt.figure(figsize=(13, 5.5)) - fign.suptitle(f"Sample: {sample_id} — spatial niche composition", - fontsize=16, fontweight="bold", y=1.02) - gsn = gridspec.GridSpec(1, 2, figure=fign, wspace=0.5) - axn_abs = fign.add_subplot(gsn[0, 0]) - draw_stacked_composition(axn_abs, ctn, niche_pal, normalize=False, - ylabel="Number of cells", xlabel="Region", - title="Niche by region (absolute)", - legend=False) - axn_rel = fign.add_subplot(gsn[0, 1]) - draw_stacked_composition(axn_rel, ctn, niche_pal, normalize=True, - ylabel="Percentage (%)", xlabel="Region", - title="Niche by region (relative)", - legend_title="Niche", - legend_kwargs=dict( - ncol=max(1, len(ncats) // 15 + 1), - fontsize=6)) - figs.append(fign) - except Exception as e: - log.warning(" niche-by-region barplot failed: %s", e) - - # ── Horizontal marker dotplot (genes on X), native scanpy legend column ── - # Drive the dotplot off whichever annotation actually carries labels: the manual TSV - # mapping when present, else the auto/external annotation (e.g. vendor cell calls) — - # so external-only runs still get a meaningful top-markers-per-cell-type dotplot. - _marker_labels = {"cell_type_tsv": "TSV cell type", - "cell_type_external": "cell type (external)", - "cell_type_ingest": "cell type (ingest)"} - marker_key = next( - (k for k in (tsv, auto) - if k and k in adata.obs.columns - and len(set(adata.obs[k].astype(str).str.strip()) - - {"Unannotated", "", "nan", "NA", "None"}) >= 2), - None) - genes = _top_markers_by(adata, marker_key, n=10) if marker_key else [] - if genes and marker_key: - try: - n_g = len(genes) - n_grp = adata.obs[marker_key].astype("category").nunique() - dp = sc.pl.dotplot( - adata, var_names=genes, groupby=marker_key, standard_scale="var", - swap_axes=False, # genes on X → horizontal + gs = gridspec.GridSpec(1, ncol, figure=figb, wspace=0.5) + for i, (gkey, glabel) in enumerate(bases): + ct = pd.crosstab(adata.obs[gkey], adata.obs[composition_key]) + ax_abs = figb.add_subplot(gs[0, 2 * i]) + draw_stacked_composition(ax_abs, ct, cmap, normalize=False, + ylabel="Number of cells", xlabel=glabel, + title=f"by {glabel} (absolute)", + legend=False) + ax_rel = figb.add_subplot(gs[0, 2 * i + 1]) + draw_stacked_composition(ax_rel, ct, cmap, normalize=True, + ylabel="Percentage (%)", xlabel=glabel, + title=f"by {glabel} (relative)", + legend_title="Cell type", + legend=(i == len(bases) - 1)) + figs.append(figb) + except Exception as e: + log.warning(" composition barplots failed: %s", e) + + # Spatial-niche composition per region (niche AS the stack; colours match + # the spatial_niches rule via the shared value-deterministic palette). + if niche and has_regions: + try: + nvals = adata.obs[niche].astype(str) + try: + ncats = sorted(nvals.unique(), key=lambda x: int(x)) + except ValueError: + ncats = sorted(nvals.unique()) + niche_cfg = (annotation_colors.get("spatial_niche", {}) + if isinstance(annotation_colors, dict) else {}) + niche_pal = build_niche_palette(ncats, niche_cfg) + ctn = pd.crosstab(adata.obs["region_annotation"], nvals) + fign = plt.figure(figsize=(13, 5.5)) + fign.suptitle(f"Sample: {sample_id} — spatial niche composition", + fontsize=16, fontweight="bold", y=1.02) + gsn = gridspec.GridSpec(1, 2, figure=fign, wspace=0.5) + axn_abs = fign.add_subplot(gsn[0, 0]) + draw_stacked_composition(axn_abs, ctn, niche_pal, normalize=False, + ylabel="Number of cells", xlabel="Region", + title="Niche by region (absolute)", + legend=False) + axn_rel = fign.add_subplot(gsn[0, 1]) + draw_stacked_composition(axn_rel, ctn, niche_pal, normalize=True, + ylabel="Percentage (%)", xlabel="Region", + title="Niche by region (relative)", + legend_title="Niche", + legend_kwargs=dict( + ncol=max(1, len(ncats) // 15 + 1), + fontsize=6)) + figs.append(fign) + except Exception as e: + log.warning(" niche-by-region barplot failed: %s", e) + + # ── Horizontal marker dotplot (genes on X), native scanpy legend column ── + # Composition and markers use only the primary annotation. Other available + # annotations remain visible in the UMAP/spatial overview above. + marker_key = composition_key + if not has_meaningful_annotation(adata, marker_key, min_labels=2): + marker_key = None + genes = _top_markers_by(adata, marker_key, n=10) if marker_key else [] + if genes and marker_key: + try: + n_g = len(genes) + n_grp = adata.obs[marker_key].astype("category").nunique() + dp = sc.pl.dotplot( + adata, var_names=genes, groupby=marker_key, standard_scale="var", + swap_axes=False, # genes on X → horizontal figsize=(max(9, n_g * 0.34), max(3.0, n_grp * 0.45)), title=f"Sample: {sample_id} — top-10 markers per " - f"{_marker_labels.get(marker_key, marker_key)}", - return_fig=True) - dp.legend(width=1.2) # compact, vertical colourbar - dp.make_figure() - main_ax = dp.ax_dict.get("mainplot_ax") - if main_ax is not None: - for lbl in main_ax.get_xticklabels(): - lbl.set_rotation(45) - lbl.set_ha("right") - figs.append(dp.fig) - except Exception as e: - log.warning(" marker dotplot failed: %s", e) - ferr, axe = plt.subplots(figsize=(11, 4)) - axe.text(0.5, 0.5, f"Dotplot failed: {e}", ha="center", va="center", - transform=axe.transAxes, fontsize=8) - axe.set_axis_off() - figs.append(ferr) - - if not figs: - f, ax = plt.subplots(figsize=(11, 6)) - ax.text(0.5, 0.5, "No markers / composition to display", - ha="center", va="center", transform=ax.transAxes) - ax.set_axis_off() - figs.append(f) - return figs - - -# ── Parameters ─────────────────────────────────────────────────────────────── -annotated_paths = [str(p) for p in snakemake.input.annotated] + f"{ANNOTATION_TITLES.get(marker_key, marker_key)}", + return_fig=True) + dp.legend(width=1.2) # compact, vertical colourbar + dp.make_figure() + main_ax = dp.ax_dict.get("mainplot_ax") + if main_ax is not None: + for lbl in main_ax.get_xticklabels(): + lbl.set_rotation(45) + lbl.set_ha("right") + figs.append(dp.fig) + except Exception as e: + log.warning(" marker dotplot failed: %s", e) + ferr, axe = plt.subplots(figsize=(11, 4)) + axe.text(0.5, 0.5, f"Dotplot failed: {e}", ha="center", va="center", + transform=axe.transAxes, fontsize=8) + axe.set_axis_off() + figs.append(ferr) + + if not figs: + f, ax = plt.subplots(figsize=(11, 6)) + ax.text(0.5, 0.5, "No markers / composition to display", + ha="center", va="center", transform=ax.transAxes) + ax.set_axis_off() + figs.append(f) + return figs + + +# ── Parameters ─────────────────────────────────────────────────────────────── +annotated_paths = [str(p) for p in snakemake.input.annotated] sample_ids = list(snakemake.params.sample_ids) +PRIMARY_ANNOTATION_COLUMN = str( + getattr(snakemake.params, "primary_annotation_column", "") or "" +) ANNOTATION_COLORS = snakemake.params.annotation_colors -REGION_COLORS = snakemake.params.region_colors -DPI = int(getattr(snakemake.params, "dpi", 300)) -NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") -out_report = str(snakemake.output.report) - -try: - log.info("=" * 70) - log.info("Generating sample report PDF for %d samples", len(sample_ids)) - log.info("=" * 70) - - Path(out_report).parent.mkdir(parents=True, exist_ok=True) - - with PdfPages(out_report) as pdf: - for adata_path, sample_id in zip(annotated_paths, sample_ids): - log.info("Processing sample: %s", sample_id) - try: - adata = sc.read_h5ad(adata_path) - log.info(" Loaded: %d cells, %d genes", adata.n_obs, adata.n_vars) - - # Determine library_id for spatial plots - library_id = None - if "spatial" in adata.uns and len(adata.uns["spatial"]) == 1: - library_id = list(adata.uns["spatial"].keys())[0] - - # palettes - for obs_key in ["cell_type_tsv", "cell_type_ingest", - "cell_type_external"]: +REGION_COLORS = snakemake.params.region_colors +DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) +NICHE_COLUMN = getattr(snakemake.params, "niche_column", "") +out_report = str(snakemake.output.report) + +try: + log.info("=" * 70) + log.info("Generating sample report PDF for %d samples", len(sample_ids)) + log.info("=" * 70) + + Path(out_report).parent.mkdir(parents=True, exist_ok=True) + + with PdfPages(out_report) as pdf: + for adata_path, sample_id in zip(annotated_paths, sample_ids): + log.info("Processing sample: %s", sample_id) + try: + adata = sc.read_h5ad(adata_path) + log.info(" Loaded: %d cells, %d genes", adata.n_obs, adata.n_vars) + + # Determine library_id for spatial plots + library_id = None + if "spatial" in adata.uns and len(adata.uns["spatial"]) == 1: + library_id = list(adata.uns["spatial"].keys())[0] + + # palettes + for obs_key in ordered_annotation_columns( + adata, + preferred=PRIMARY_ANNOTATION_COLUMN, + ): apply_palette(adata, obs_key, ANNOTATION_COLORS) - apply_region_palette(adata, REGION_COLORS) - - sample_col = next((c for c in ["sample", "sample_batch"] - if c in adata.obs.columns), None) - if sample_col is None: - adata.obs["sample"] = sample_id - sample_col = "sample" - - keys = _pick_keys(adata, NICHE_COLUMN) - - # Order spatial-niche categories numerically (like clusters), so the - # page-1 legends/colours read 0,1,2,…,10 rather than lexicographically. - niche_key = keys[3] - if niche_key and niche_key in adata.obs.columns: - _nv = adata.obs[niche_key].astype(str) - try: - _ord = [str(v) for v in sorted(_nv.unique(), key=lambda x: int(x))] - adata.obs[niche_key] = pd.Categorical(_nv, categories=_ord) - except (ValueError, TypeError): - pass # non-numeric niche labels — leave order as-is - - has_regions = ("region_annotation" in adata.obs.columns - and adata.obs["region_annotation"].nunique() > 1 - and not all(adata.obs["region_annotation"] == "Unlabeled")) - - fig1 = _build_page1(adata, sample_id, keys, library_id, has_regions) - _rasterize_heavy_layers(fig1) - pdf.savefig(fig1, bbox_inches="tight", dpi=DPI); plt.close(fig1) - - for fig2 in _build_page2(adata, sample_id, keys, - ANNOTATION_COLORS, sample_col, has_regions): - _rasterize_heavy_layers(fig2) - pdf.savefig(fig2, bbox_inches="tight", dpi=DPI); plt.close(fig2) - log.info(" Pages saved for %s", sample_id) - - except Exception as e: - log.warning("FAILED page for %s: %s\n%s", sample_id, e, - traceback.format_exc()) - # Create a placeholder page with error message - fig, ax = plt.subplots(figsize=(28, 16)) - ax.text(0.5, 0.5, f"Sample {sample_id}\nFailed: {e}", - ha="center", va="center", fontsize=14) - ax.set_axis_off() - pdf.savefig(fig, dpi=DPI) - plt.close(fig) - finally: - # Free memory before next sample - if "adata" in dir(): - del adata - gc.collect() - - log.info("Report saved → %s", out_report) - -except Exception: - log.error("FAILED:\n%s", traceback.format_exc()) - raise + apply_region_palette(adata, REGION_COLORS) + + sample_col = next((c for c in ["sample", "sample_batch"] + if c in adata.obs.columns), None) + if sample_col is None: + adata.obs["sample"] = sample_id + sample_col = "sample" + + keys = _pick_keys( + adata, + NICHE_COLUMN, + preferred_annotation=PRIMARY_ANNOTATION_COLUMN, + ) + + # Order spatial-niche categories numerically (like clusters), so the + # page-1 legends/colours read 0,1,2,…,10 rather than lexicographically. + niche_key = keys[2] + if niche_key and niche_key in adata.obs.columns: + _nv = adata.obs[niche_key].astype(str) + try: + _ord = [str(v) for v in sorted(_nv.unique(), key=lambda x: int(x))] + adata.obs[niche_key] = pd.Categorical(_nv, categories=_ord) + except (ValueError, TypeError): + pass # non-numeric niche labels — leave order as-is + + has_regions = ("region_annotation" in adata.obs.columns + and adata.obs["region_annotation"].nunique() > 1 + and not all(adata.obs["region_annotation"] == "Unlabeled")) + + fig1 = _build_page1(adata, sample_id, keys, library_id, has_regions) + _rasterize_heavy_layers(fig1) + pdf.savefig(fig1, bbox_inches="tight", dpi=DPI); plt.close(fig1) + + for fig2 in _build_page2(adata, sample_id, keys, + ANNOTATION_COLORS, sample_col, has_regions): + _rasterize_heavy_layers(fig2) + pdf.savefig(fig2, bbox_inches="tight", dpi=DPI); plt.close(fig2) + log.info(" Pages saved for %s", sample_id) + + except Exception as e: + log.warning("FAILED page for %s: %s\n%s", sample_id, e, + traceback.format_exc()) + # Create a placeholder page with error message + fig, ax = plt.subplots(figsize=(28, 16)) + ax.text(0.5, 0.5, f"Sample {sample_id}\nFailed: {e}", + ha="center", va="center", fontsize=14) + ax.set_axis_off() + pdf.savefig(fig, dpi=DPI) + plt.close(fig) + finally: + # Free memory before next sample + if "adata" in dir(): + del adata + gc.collect() + + log.info("Report saved → %s", out_report) + +except Exception: + log.error("FAILED:\n%s", traceback.format_exc()) + raise diff --git a/workflow/scripts/spatial_niches.py b/workflow/scripts/spatial_niches.py index 19b3ae7..c1d91e0 100644 --- a/workflow/scripts/spatial_niches.py +++ b/workflow/scripts/spatial_niches.py @@ -35,6 +35,7 @@ # Shared composition-barplot helpers (scripts/ is on sys.path for script: rules) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from composition_barplots import composition_pair, build_niche_palette # noqa: E402 +from plotting_legends import draw_legend_on_axis, grid_figure # noqa: E402 # ── Logging ────────────────────────────────────────────────────────────────── @@ -69,8 +70,8 @@ N_PCS = int(snakemake.params.n_pcs) NICHE_RES = float(snakemake.params.niche_resolution) COMPUTE_UMAP = bool(getattr(snakemake.params, "compute_umap", False)) -CLUSTER_NN = int(getattr(snakemake.params, "cluster_n_neighbours", 15)) -N_ITERATIONS = int(getattr(snakemake.params, "n_iterations", 2)) +CLUSTER_NN = int(getattr(snakemake.params, "cluster_n_neighbours", 50)) +N_ITERATIONS = int(getattr(snakemake.params, "n_iterations", -1)) USE_PRECOMPUTED = bool(getattr(snakemake.params, "use_precomputed", False)) NICHE_DIR = str(getattr(snakemake.params, "niche_dir", "") or "") REFINE = bool(getattr(snakemake.params, "refine", False)) @@ -79,7 +80,9 @@ REFINE_AUTO = bool(getattr(snakemake.params, "refine_auto", False)) ANNOTATION_COLORS = snakemake.params.annotation_colors REGION_COLORS = snakemake.params.region_colors -DPI = int(getattr(snakemake.params, "dpi", 300)) +DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) +SPATIAL_POINT_SIZE = float(getattr(snakemake.params, "spatial_point_size", 20)) out_concat = str(snakemake.output.concatenated) niche_tsvs = [str(p) for p in snakemake.output.niche_tsvs] @@ -89,6 +92,17 @@ COORD_KEYS = ("x_pixel", "y_pixel", "coord_xy") # BANKSY reads obsm[coord_keys[2]] +def _spatial_scatter_area(default_area): + """Scale Matplotlib scatter area with the configured spatial diameter. + + ``sc.pl.spatial`` interprets ``spot_size`` as a diameter in coordinate units, + whereas Matplotlib's ``s`` is an area in points squared. Squaring the ratio + keeps their visible diameters proportional and preserves the existing areas + when ``spatial_point_size`` retains its default value of 20. + """ + return default_area * (SPATIAL_POINT_SIZE / 20.0) ** 2 + + def harmony_embedding(emb, obs, batch_key, seed): """Harmony-correct a PCA embedding directly (not scanpy's wrapper, which hard-codes a Z_corr transpose that breaks across harmonypy versions). @@ -146,8 +160,14 @@ def _spatial_scatter(adata, sample_key, color_key, out_path, dpi, color_map): n = len(samples) ncol = min(4, n) nrow = int(np.ceil(n / ncol)) - fig, axes = plt.subplots(nrow, ncol, figsize=(4 * ncol, 4 * nrow), - squeeze=False) + fig, axes, legend_ax = grid_figure( + nrow, + ncol, + [str(c) for c in cats], + cell_width=4, + cell_height=4, + max_legend_columns=min(8, 2 * ncol), + ) for ax in axes.ravel(): ax.set_axis_off() for i, s in enumerate(samples): @@ -159,17 +179,24 @@ def _spatial_scatter(adata, sample_key, color_key, out_path, dpi, color_map): for c in cats: mm = vals == c if mm.any(): - ax.scatter(sp[mm, 0], sp[mm, 1], s=2, linewidths=0, - color=colour[c], rasterized=True) + ax.scatter( + sp[mm, 0], sp[mm, 1], s=_spatial_scatter_area(2), + linewidths=0, color=colour[c], rasterized=True, + ) ax.set_title(str(s), fontsize=9) ax.set_aspect("equal") ax.invert_yaxis() ax.set_xticks([]); ax.set_yticks([]) handles = [plt.Line2D([0], [0], marker="o", linestyle="", markersize=5, color=colour[c], label=str(c)) for c in cats] - fig.legend(handles=handles, loc="center left", bbox_to_anchor=(1.0, 0.5), - frameon=False, title="Spatial niche", fontsize=7, title_fontsize=8) - fig.tight_layout() + draw_legend_on_axis( + legend_ax, + handles, + [str(c) for c in cats], + title="Spatial niche", + fontsize=12, + max_columns=min(8, 2 * ncol), + ) fig.savefig(out_path, dpi=dpi, bbox_inches="tight") plt.close(fig) @@ -183,7 +210,15 @@ def _spatial_scatter_highlight(adata, sample_key, color_key, target, out_path, d n = len(samples) ncol = min(4, n) nrow = int(np.ceil(n / ncol)) - fig, axes = plt.subplots(nrow, ncol, figsize=(4 * ncol, 4 * nrow), squeeze=False) + highlight_labels = [f"niche {target}", "other"] + fig, axes, legend_ax = grid_figure( + nrow, + ncol, + highlight_labels, + cell_width=4, + cell_height=4, + max_legend_columns=min(8, 2 * ncol), + ) for ax in axes.ravel(): ax.set_axis_off() for i, s in enumerate(samples): @@ -194,11 +229,15 @@ def _spatial_scatter_highlight(adata, sample_key, color_key, target, out_path, d v = vals_all[m] bg, hi = v != str(target), v == str(target) if bg.any(): - ax.scatter(sp[bg, 0], sp[bg, 1], s=2, linewidths=0, - color=bg_color, rasterized=True) + ax.scatter( + sp[bg, 0], sp[bg, 1], s=_spatial_scatter_area(2), + linewidths=0, color=bg_color, rasterized=True, + ) if hi.any(): - ax.scatter(sp[hi, 0], sp[hi, 1], s=2, linewidths=0, - color=hi_color, rasterized=True) + ax.scatter( + sp[hi, 0], sp[hi, 1], s=_spatial_scatter_area(2), + linewidths=0, color=hi_color, rasterized=True, + ) ax.set_title(str(s), fontsize=9) ax.set_aspect("equal") ax.invert_yaxis() @@ -207,9 +246,13 @@ def _spatial_scatter_highlight(adata, sample_key, color_key, target, out_path, d color=hi_color, label=f"niche {target}"), plt.Line2D([0], [0], marker="o", linestyle="", markersize=5, color=bg_color, label="other")] - fig.legend(handles=handles, loc="center left", bbox_to_anchor=(1.0, 0.5), - frameon=False, fontsize=7) - fig.tight_layout() + draw_legend_on_axis( + legend_ax, + handles, + highlight_labels, + fontsize=12, + max_columns=min(8, 2 * ncol), + ) fig.savefig(out_path, dpi=dpi, bbox_inches="tight") plt.close(fig) @@ -268,7 +311,7 @@ def _resolve_precomputed(): # preprocess_umap already sets obs["sample"] = sample_id per cell, so we do # NOT pass label= (which would clash with that column). keys + index_unique # only disambiguate obs_names across samples. - adata = sc.concat(adatas, join="inner", keys=sample_ids, index_unique="-") + adata = sc.concat(adatas, join="inner", label="sample_batch", keys=sample_ids) del adatas log.info("Concatenated: %d cells, %d genes", adata.n_obs, adata.n_vars) @@ -482,7 +525,8 @@ def _leiden(res, key): xy = adata.obsm[COORD_KEYS[2]] codes = pd.Categorical(adata.obs["sample"]).codes fig, ax = plt.subplots(figsize=(min(40, 4 * len(sample_ids)), 5)) - ax.scatter(xy[:, 0], xy[:, 1], c=codes, cmap="tab20", s=1, + ax.scatter(xy[:, 0], xy[:, 1], c=codes, cmap="tab20", + s=_spatial_scatter_area(1), linewidths=0, rasterized=True) ax.set_aspect("equal"); ax.invert_yaxis(); ax.set_axis_off() ax.set_title("Staggered coordinates (coloured by sample)", fontsize=10) @@ -497,7 +541,8 @@ def _leiden(res, key): xy = adata.obsm[COORD_KEYS[2]] colors = adata.obs[NICHE_KEY].astype(str).map(niche_palette).values fig, ax = plt.subplots(figsize=(min(40, 4 * len(sample_ids)), 5)) - ax.scatter(xy[:, 0], xy[:, 1], c=colors, s=1, + ax.scatter(xy[:, 0], xy[:, 1], c=colors, + s=_spatial_scatter_area(1), linewidths=0, rasterized=True) ax.set_aspect("equal"); ax.invert_yaxis(); ax.set_axis_off() ax.set_title("BANKSY spatial niches (staggered, all samples)", fontsize=10) @@ -516,7 +561,8 @@ def _leiden(res, key): for color, fname in [(NICHE_KEY, "umap_by_niche.png"), ("sample", "umap_by_sample.png")]: try: - sc.pl.embedding(adata, basis="X_umap_banksy", color=color, size=3, + sc.pl.embedding(adata, basis="X_umap_banksy", color=color, + size=UMAP_POINT_SIZE, frameon=False, show=False, title=f"BANKSY (Harmony) – {color}") plt.savefig(os.path.join(plots_dir, fname), dpi=DPI, diff --git a/workflow/scripts/subcluster.py b/workflow/scripts/subcluster.py index 19a3bab..f62d09e 100644 --- a/workflow/scripts/subcluster.py +++ b/workflow/scripts/subcluster.py @@ -35,6 +35,7 @@ _here = os.getcwd() sys.path.insert(0, _here) from composition_barplots import save_stacked_composition, find_niche_column, build_niche_palette +from plotting_legends import category_labels, grid_figure, move_legend_to_axis # ── Logging ────────────────────────────────────────────────────────────────── @@ -66,10 +67,21 @@ def _resolution_range(rmin, rmax, step): def split_umap(adata, split_by, ncol=None, nrow=None, **kwargs): categories = adata.obs[split_by].cat.categories - ncol = ncol or len(categories) + ncol = ncol or min(4, len(categories)) nrow = nrow or int(np.ceil(len(categories) / ncol)) - fig, axs = plt.subplots(nrow, ncol, figsize=(5 * ncol, 4 * nrow)) - axs = np.atleast_1d(axs).flatten() + color_key = kwargs.get("color") + if isinstance(color_key, (list, tuple)): + color_key = color_key[0] if color_key else None + labels = category_labels(adata, color_key) + fig, axes, legend_ax = grid_figure( + nrow, + ncol, + labels, + cell_width=5, + cell_height=4, + max_legend_columns=min(8, 2 * ncol), + ) + axs = axes.ravel() for i, cat in enumerate(categories): sc.pl.umap( adata[adata.obs[split_by] == cat], @@ -77,7 +89,12 @@ def split_umap(adata, split_by, ncol=None, nrow=None, **kwargs): ) for j in range(i + 1, len(axs)): axs[j].set_visible(False) - plt.tight_layout() + move_legend_to_axis( + axs[:len(categories)], + legend_ax, + title=str(color_key).replace("_", " ").title() if color_key else None, + max_columns=min(8, 2 * ncol), + ) def recalculate_qc_metrics(adata): @@ -263,7 +280,15 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, leiden_keys = [] for res in resolutions: key = f"leiden_{str(res).replace('.', '_')}" - sc.tl.leiden(adata, resolution=res, key_added=key, random_state=RANDOM_SEED) + sc.tl.leiden( + adata, + resolution=res, + key_added=key, + random_state=RANDOM_SEED, + flavor="igraph", + n_iterations=2, + directed=False, + ) leiden_keys.append(key) log.info(" [%s] res=%.1f → %d clusters", branch_name, res, adata.obs[key].nunique()) @@ -318,7 +343,7 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, qc_cols = [c for c in ["n_genes_by_counts", "total_counts", "pct_counts_mt", "pct_counts_hb"] if c in adata.obs.columns] if qc_cols: - sc.pl.umap(adata, color=qc_cols, size=2, wspace=0.25, frameon=False) + sc.pl.umap(adata, color=qc_cols, size=UMAP_POINT_SIZE, wspace=0.25, frameon=False) plt.savefig(os.path.join(qc_dir, "QC_UMAPs.png"), dpi=DPI, bbox_inches="tight") plt.close() @@ -358,21 +383,21 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, adata.uns[f"{annot_col}_colors"] = [cd.get(str(c), "#cccccc") for c in cats] for res, key in zip(resolutions, leiden_keys): - sc.pl.umap(adata, color=[key], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=[key], size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, title=f"Leiden {res}") plt.savefig(os.path.join(umap_dir, f"UMAP_clusters_res{res}.png"), dpi=DPI, bbox_inches="tight") plt.close() if sample_col: - sc.pl.umap(adata, color=[sample_col], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=[sample_col], size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, title="By sample") plt.savefig(os.path.join(umap_dir, "UMAP_by_sample.png"), dpi=DPI, bbox_inches="tight") plt.close() if annot_col in adata.obs.columns: - sc.pl.umap(adata, color=[annot_col], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=[annot_col], size=UMAP_POINT_SIZE, wspace=0.25, frameon=False, title=f"Original annotation ({annot_col})") plt.savefig(os.path.join(umap_dir, f"UMAP_by_{annot_col}.png"), dpi=DPI, bbox_inches="tight") @@ -391,7 +416,8 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, adata.obs["region_annotation"].astype(str), categories=ordered) adata.uns["region_annotation_colors"] = [REGION_COLORS.get(str(c), "#cccccc") for c in ordered] - sc.pl.umap(adata, color=["region_annotation"], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=["region_annotation"], size=UMAP_POINT_SIZE, + wspace=0.25, frameon=False, title="By region") plt.savefig(os.path.join(umap_dir, "UMAP_by_region.png"), dpi=DPI, bbox_inches="tight") @@ -405,7 +431,8 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, cats = adata.obs[_dc].cat.categories pal = sample_colors.get(_dc, {}) if isinstance(sample_colors, dict) else {} adata.uns[f"{_dc}_colors"] = [pal.get(str(c), "#cccccc") for c in cats] - sc.pl.umap(adata, color=[_dc], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=[_dc], size=UMAP_POINT_SIZE, + wspace=0.25, frameon=False, title=f"By {_dc}") plt.savefig(os.path.join(umap_dir, f"UMAP_by_{_dc}.png"), dpi=DPI, bbox_inches="tight") @@ -423,7 +450,8 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, if isinstance(ANNOTATION_COLORS, dict) else {}) _npal = build_niche_palette(_ncats, _ncfg) adata.uns[f"{_niche_col}_colors"] = [_npal.get(str(c), "#cccccc") for c in _ncats] - sc.pl.umap(adata, color=[_niche_col], size=2, wspace=0.25, frameon=False, + sc.pl.umap(adata, color=[_niche_col], size=UMAP_POINT_SIZE, + wspace=0.25, frameon=False, title="By spatial niche") plt.savefig(os.path.join(umap_dir, "UMAP_by_spatial_niche.png"), dpi=DPI, bbox_inches="tight") @@ -440,7 +468,7 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, try: adata.obs[sample_col] = adata.obs[sample_col].astype("category") split_umap(adata, color=split_leiden_key, split_by=sample_col, - size=10, ncol=4) + size=max(UMAP_POINT_SIZE, 10.0), ncol=4) plt.savefig(os.path.join(umap_dir, "UMAP_split_by_sample.png"), dpi=DPI, bbox_inches="tight") plt.close() @@ -450,7 +478,7 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, if has_regions: try: split_umap(adata, color=split_leiden_key, split_by="region_annotation", - size=10, ncol=3) + size=max(UMAP_POINT_SIZE, 10.0), ncol=3) plt.savefig(os.path.join(umap_dir, "UMAP_split_by_region.png"), dpi=DPI, bbox_inches="tight") plt.close() @@ -503,6 +531,7 @@ def run_clustering_branch(adata, branch_name, branch_dir, resolutions, REGION_COLORS = snakemake.params.region_colors REGION_LEVELS = list(getattr(snakemake.params, "region_levels", []) or []) DPI = int(getattr(snakemake.params, "dpi", 300)) +UMAP_POINT_SIZE = float(getattr(snakemake.params, "umap_point_size", 2)) EXTRA_ANNOT_COLUMNS = list(getattr(snakemake.params, "extra_annot_columns", []) or []) SAMPLE_COLORS = getattr(snakemake.params, "sample_colors", {}) or {} sub_dir = str(snakemake.output.sub_dir) diff --git a/workflow/scripts/validate_input.py b/workflow/scripts/validate_input.py index 7f659b8..0e4f457 100644 --- a/workflow/scripts/validate_input.py +++ b/workflow/scripts/validate_input.py @@ -34,10 +34,17 @@ from pathlib import Path import numpy as np -import pandas as pd import scanpy as sc import scipy.sparse as sp +try: + _here = os.path.dirname(os.path.abspath(__file__)) +except NameError: # very old Snakemake + _here = os.getcwd() +sys.path.insert(0, _here) +from annotation_utils import non_placeholder_annotation_mask +from external_metadata import optional_input_path, read_cell_metadata + # ── Logging ────────────────────────────────────────────────────────────────── log_handlers = [logging.StreamHandler(sys.stderr)] if hasattr(snakemake, "log"): @@ -91,7 +98,7 @@ def _valid_image_entry(entry): mito_prefix = tuple(snakemake.params.mito_prefix) ext_enabled = bool(getattr(snakemake.params, "external_enabled", False)) ext_column = str(getattr(snakemake.params, "external_column", "") or "") - ext_meta_dir = str(getattr(snakemake.params, "external_meta_dir", "") or "") + ext_path = optional_input_path(getattr(snakemake.input, "external_metadata", None)) out_report = str(snakemake.output.report) log.info("=" * 70) @@ -187,32 +194,50 @@ def _valid_image_entry(entry): # Catch a barcode-format mismatch here rather than silently producing all- # "Unannotated" downstream (or 0 cells when keep_unannotated=false). ext_overlap = None - if ext_enabled and ext_column and ext_meta_dir: - ext_path = os.path.join(ext_meta_dir, f"metadata_{sample_id}.tsv") - if not os.path.isfile(ext_path): - errors.append(f"external_annotation enabled but metadata missing: {ext_path}") - else: - try: - _sv = pd.read_csv(ext_path, sep="\t", index_col=0, comment="#") - if ext_column not in _sv.columns: - errors.append(f"external_annotation column '{ext_column}' not in {ext_path}") - else: - _lab = _sv[ext_column].dropna().astype(str).str.strip() - _bc = set(_lab[(_lab != "") & (_lab.str.lower() != "nan") - & (_lab.str.lower() != "unannotated")].index.astype(str)) - _match = len(_bc & set(adata.obs_names.astype(str))) - frac = round(_match / len(_bc), 4) if _bc else 0.0 - ext_overlap = {"annotated_barcodes": len(_bc), - "matched_obs_names": _match, - "fraction_matched": frac} - if _bc and _match == 0: - errors.append(f"external_annotation: 0 of {len(_bc)} annotated " - f"barcodes match obs_names — check barcode formatting") - elif _bc and frac < 0.5: - warnings.append(f"external_annotation: only {_match}/{len(_bc)} " - f"annotated barcodes match obs_names ({frac:.0%})") - except Exception as e: - warnings.append(f"external_annotation overlap check failed: {e}") + if ext_enabled and ext_column: + try: + if ext_path: + _sv = read_cell_metadata( + ext_path, required_columns=(ext_column,) + ) + _lab = _sv[ext_column] + elif ext_column in adata.obs.columns: + _lab = adata.obs[ext_column] + else: + errors.append( + f"external_annotation column '{ext_column}' is absent from the " + "contract h5ad obs and no external metadata TSV was supplied" + ) + _lab = None + + if _lab is not None: + _bc = set( + _lab[non_placeholder_annotation_mask(_lab)].index.astype(str) + ) + _match = len(_bc & set(adata.obs_names.astype(str))) + frac = round(_match / len(_bc), 4) if _bc else 0.0 + ext_overlap = { + "annotated_barcodes": len(_bc), + "matched_obs_names": _match, + "fraction_matched": frac, + } + if _bc and _match == 0: + errors.append( + f"external_annotation: 0 of {len(_bc)} annotated barcodes " + "match obs_names — check barcode formatting" + ) + elif _bc and frac < 0.5: + warnings.append( + f"external_annotation: only {_match}/{len(_bc)} annotated " + f"barcodes match obs_names ({frac:.0%})" + ) + elif not _bc: + warnings.append( + "external_annotation column contains no non-placeholder " + "labels; external-annotation plots will be omitted" + ) + except Exception as e: + errors.append(f"external_annotation metadata is invalid: {e}") # ── Verdict ────────────────────────────────────────────────────────── for w in warnings: