A Pennsieve processor, the last step of the single-cell workflows (Single-nucleus Atlas Mapping & Condition Comparison; Multiome Gene Regulation). It reads whatever upstream outputs it is given and writes one self-contained offline HTML report (samples, QC, clusters, cell types and composition, markers, differential expression, ATAC QC and regulatory results, methods, and the citations gathered from every step's provenance) and the tables behind it.

The last step of both single-cell workflows, and the only one whose output is written for a person:
sc-prep → sc-qc → sc-integrate → sc-annotate → {sc-markers, st-compare} → sc-reportatac-prep → atac-regulome (+ labels from sc-annotate) → sc-reportIt reads whatever upstream outputs it is given and writes one self-contained HTML page and
the tables behind it. Every input is optional: the report detects what it has and renders
the matching sections, so the same app closes both workflows and any mix of them. The page is a
single file: styles and figures are inline (PNG as data: URIs), there are no scripts, and
nothing is loaded from the network, so it can be opened offline, attached to an email or stored
with a dataset.
For a biologist. One place to read what the workflow found:
Each finding is a plain sentence with its numbers ("Neuron 5.4% of cells in control vs 15% in pain"), followed by the caveats that come with the method: few donors per group, marker p-values that are not evidence, nuclei sampling some cell types unevenly. It describes the data; it does not advise. The Methods and Citations sections say which tool did what, at which version, with which parameters, and whose work to cite.
For a computational analyst. The report fits nothing. It reads each step's summary tables,
summary.json and provenance.json, plus labels.csv (in chunks of 500,000 rows, three
columns) to count cells per sample and label. It never reads .h5ad files or samples_full.csv.
Upstream folders are recognised by the app of their provenance.json (every single-cell app
writes one per output folder), else by a file only that app writes. So the report works whether
or not the workflow links each output to its own port, and it does not depend on file names
beyond the contract. Formats known from conventions.md and from the apps (samples.csv,
labels.csv, sc-integrate, sc-markers, st-compare) get dedicated sentences. For everything else a
step writes, the section shows the headline numbers of its summary.json, its per-sample status
from provenance.json, its CSV tables (first rows) and its figures, so a new column or table
shows up without a code change.
| Section | Shown when | What it says |
|---|---|---|
| Samples and metadata | metadata |
samples, nuclei, genes, organism, input format, chemistry (prep.json); conditions and samples per level; columns that group the samples like the condition (identical, or donor/batch nested in it); sc-prep warnings, skipped and ignored entries |
| Quality control | qc |
nuclei before/after per sample, removed per reason (n_removed_* columns), samples that lost much more than the median; sc-qc's tables, status and figures |
| Integration, clusters and UMAP | integration |
clusters, cells, samples, method; clusters present (>= 20 cells) in one sample only; UMAPs, cluster table, metrics |
| Cell types and composition | annotation (labels.csv) |
types and shares, types below 1%, low-confidence calls (cell_type_score < 0.5), types in one sample only; composition per sample (the report's own stacked-bar figure) and mean share per condition, with the largest differences between two conditions (descriptive) |
| Marker genes | markers (one block per <LABEL>/) |
per label: cells, samples, markers, hits dropped as inconsistent, top five; dot plot and heatmap; the p-value caveat |
| Condition comparison | compare (one block per <LABEL>/) |
composition tests (at the report's FDR, and when nothing is significant, the smallest p the rank test can reach); pseudobulk DE results, top genes and labels not tested; st-compare's figures |
| ATAC QC | atac |
barcodes before/after and removed per reason (atac-prep's per-sample qc_summary.csv; without it, the rows of atac_qc.csv with passed false, read in chunks), median TSS enrichment and fragments; atac-prep's summary, tables and figures |
| Gene regulation | regulome (one block per <LABEL>/) |
atac-regulome's summary, tables and figures |
| Methods | any provenance.json |
per step: version, tools, parameters, method as run, citation numbers |
| Citations | always | first the platform statement ("Citing Pennsieve…", below); then the methods gathered from every step's provenance.json citations, deduplicated, numbered in pipeline order, plus the report's own |
| Input data | metadata |
the samples; a data credit (dataset, contributors, licence, Discover id, describing paper) when a demo dataset is recognised from sc-prep's sample paths |
A section whose inputs are present but unreadable is reported at the top of the page and in
provenance.json (sectionErrors); the rest is still rendered.
Every report prints this statement first in its Citations section, above the per-step methods, word for word (only the DOI is a link):
Citing Pennsieve. If you used Pennsieve to store, analyse, share or publish data reported in a publication, please cite: Goldblum Z, et al. Pennsieve: A Collaborative Platform for Translational Neuroscience and Beyond. Scientific Data 2025;12:1834. doi:10.1038/s41597-025-06075-5. Please also cite the datasets you used (their DOIs) and the methods listed below.
The paper is in processor/references.json (verified like the others), first in
tables/citations.csv with role platform, and first in summary.json citations. It is not
in this app's CITATION.cff: the app also runs outside Pennsieve.
A step's provenance.json may cite a bare DOI (as conventions.md describes) or an object
{doi, pmid, text} (as sc-integrate and sc-markers write). Each DOI is looked up in
processor/references.json, which scripts/verify_citations.py wrote from Crossref, PubMed,
DataCite and the Discover API. The page shows the verified record, marked ✓. A DOI the table
does not hold is shown with the step's own text, marked "as given by the step". st-compare
(reused unchanged from the spatial suite) writes no citations, so its methods papers come from
processor/citations.py. When an upstream app adds a reference, re-run
scripts/verify_citations.py with its DOI added, so the report shows it verified.
Inputs, all placement: subfolder and none required: metadata, qc, integration,
annotation, markers, compare, atac, regulome (see sample-input/README.md for which
upstream output goes where). markers, compare and regulome may each receive several links,
one <LABEL>/ folder each.
Every input path comes from the shared processor/safe_inputs.py (vendored unchanged from
workflow-single-cell/shared/): InputDir.walk() follows symlinks only to where the platform's
own links point (the entries of INPUT_DIR and of a real port folder, one hop: a link that an
upstream app wrote into its own output is not trusted), and a folder's files are the walked files
in it, never a fresh glob or path join. Untrusted content is handled as data:
labels.csv, st-compare's de.csv and atac-prep's atac_qc.csv are read in chunks of a few
columns from the checked file;obs_id column, e.g. sc-qc's <sample_id>_qc.csv) are never
shown;default-src 'none'; img-src data:; style-src 'unsafe-inline');InputDir.inline_png: a PNG signature, at most 8 MB.Outputs (two, so each in OUTPUT_DIR/<name>/):
| Output | Files |
|---|---|
report |
index.html (self-contained), provenance.json |
tables |
the CSVs below (each only when its section has inputs), summary.json, provenance.json |
| Table | Columns |
|---|---|
samples.csv |
sc-prep's samples.csv as read, without path |
qc.csv |
sc-qc's per-sample table |
clusters.csv |
sc-integrate's cluster_summary.csv |
composition.csv |
sample_id, <label column>, n_cells, fraction, condition |
composition_by_condition.csv |
condition, <label column>, mean_fraction |
markers_top.csv |
label_set, label, n_cells, n_samples, n_markers, dropped_inconsistent, top markers |
composition_tests.csv, de_summary.csv, de_top_genes.csv |
st-compare's rows + label_set; significance at the report's FDR |
atac_qc.csv |
sample_id, n_removed |
methods.csv, citations.csv |
per step run: app, step, version, tools, parameters, citations; per reference: role (platform first, then method), number (methods only), text, doi, pmid, verified_by_report |
summary.json |
per section: observations, caveats, headline numbers; workflows detected; citations; data credit |
Exit codes: 0 success; 1 none of the inputs found; 2 bad parameter or INPUT_DIR not a
folder. provenance.json has the conventions' keys (app, version, tools, parameters,
inputs, started, finished, samples, peak_rss_mb, cgroup_peak_mb, threads,
citations) plus the folders found per port, sections, section errors and omitted figures.
| Name | Default | What it does |
|---|---|---|
REPORT_TITLE |
Single-cell Report |
Title of the page (escaped; at most 200 characters). |
FDR |
0.05 |
Threshold the report applies to st-compare's adjusted p-values when it counts and lists significant composition changes and DE genes. Display only. |
MAX_ITEMS |
20 |
Rows per top-N table and per table preview. |
MAX_FIGURES |
12 |
Figures inlined per section (per <LABEL> for markers, comparisons and regulation); the page says how many more the upstream output holds. |
drg-c2 (Discover 475), the whole of workflow A as it stood on 2026-10-31 18:48 UTC: sc-prep
metadata, sc-qc qc, sc-integrate integration, sc-annotate labels and annotation,
sc-markers (cell-type and cluster), st-compare (cell-type, condition: acute vs chronic).
Each port was a real folder of symlinks into the upstream outputs, as the platform links them.
Output in the workflow repository under data/work/drg-c2/report/{report,tables}/. The page
(9.1 MB, 34 figures inlined) says:
subject_experimental_group to
condition, so no confounding caveat is raised for that copy).cpu: 1024, memory: 2048, computeTypes: [standard, lambda] (the image serves both; see
processor/entry.py and processor/handler.py), timeout 10 min. Measured with docker run --memory 3g --cpus 1 --network none --read-only --tmpfs /tmp under amd64 emulation on an
M-series Mac:
| Inputs | Sections | Peak RSS | cgroup peak | Wall time (incl. container start) |
|---|---|---|---|---|
| drg-c2, workflow A (12 samples; labels.csv of 18,438 nuclei; 2 markers runs, 1 st-compare run) | 6 | 207 MB | 199 MB | 2 s |
| Synthetic fixture, both workflows (labels.csv of 11,820 cells) | 8 | 201 MB (local) | < 1 s (local) |
Memory does not grow with the number of cells beyond one 500,000-row chunk of three
labels.csv columns. Image: 540 MB (python:3.12.16-slim-bookworm + pandas, matplotlib,
jinja2, awslambdaric). The page's size is mostly its inlined figures: about 50–300 KB each at
150 dpi, at most MAX_FIGURES per section.
python3.12 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
make test PYTHON=.venv/bin/python # synthetic fixture: both workflows, each alone, each port alone, none
make run PYTHON=.venv/bin/python # Docker over data/fixture/linked/ -> data/output/
INPUT_HOST=/path/to/inputs make run # Docker over your own folder (one subfolder per port)
make validate PYTHON=.venv/bin/python # app.yml against the manifest schema (+ CITATION.cff with cffconvert)
tests/make_fixture.py writes contract-following upstream outputs for every port, with planted
findings: a sample losing 45% to QC, a cluster in one sample only, neurons 5% → 15% between
conditions, a significant composition test, a DOI the reference table does not hold, and
st-compare's provenance without citations. The tests also cover:
summary.json and a
figure next to a run folder): never read, never inlined;passed false only), and the CSP meta tag;.png, and HTML/script in a metadata value (escaped,
never executed);INPUT_DIR;MAX_FIGURES, bad parameters, no inputs, and the Lambda handler.n_<cells|obs|nuclei>_before/after and n_removed_<reason> columns).sub-UTD-CC…/sam-…-DRG?/), the DRG atlas (480, sub-…/sam-…/sam-…-<level>/) and the
sympathetic ganglia multiome (465, sam-hSG-lib-<n>). Other data get no credit line: cite your
data yourself.verify_citations.py. A shared package would be simpler once
a third report exists; for now each is self-contained, and changes to one should be copied to
the other.Our code: Apache-2.0 (LICENSE). Bundled components and their licences: THIRD_PARTY.md (all
permissive; no GPL Python packages, while the Debian base layer contains GPL system tools).
The report shows other people's work: each step's methods (cited per step on the page) and the data. Its own dependencies:
scripts/verify_citations.py fetched every DOI the report can cite on 2026-10-31:
processor/references.json holds 60 papers and 3 datasets. All but the platform paper (59 papers
and 3 datasets) are in CITATION.cff with author lists as Crossref and Discover
give them, and in processor/references.json. The platform paper (printed first, not in CITATION.cff):
The report's own:
The table also holds every DOI the other single-cell apps cite in their CITATION.cff or
provenance.json (sc-prep, sc-qc, sc-integrate, sc-annotate, atac-prep, atac-regulome: e.g.
Scrublet, CellBender, pipeComp, scANVI, CellTypist, SnapATAC2, ArchR, MACS, GENCODE 2023, JASPAR
2026, NumPy, scikit-image, the arXiv records of scikit-learn and PyTorch) and fastp 1.0 (Chen S,
iMeta 4(5):e70078, 2025, doi:10.1002/imt2.70078, PMID 41112039), so they render verified.
Pyro (Bingham et al., JMLR 2019) has no DOI; the report lists works by DOI only, so a citation
without one (Pyro, if a step cited it so) is not shown.
Per step (shown when that step's provenance is among the inputs):
Demo data:
1 - 0 of 0 files
Virshup, I., Rybakov, S., Theis, F. J., Angerer, P., & Wolf, F. A. (2024). anndata: Access and store annotated data matrices. Journal of Open Source Software, 9(101), 4371. https://doi.org/10.21105/joss.04371
Gayoso, A., Lopez, R., Xing, G., Boyeau, P., Valiollah Pour Amiri, V., Hong, J., Wu, K., Jayasuriya, M., Mehlman, E., Langevin, M., Liu, Y., Samaran, J., Misrachi, G., Nazaret, A., Clivio, O., Xu, C., Ashuach, T., Gabitto, M., Lotfollahi, M., et al. (2022). A Python library for probabilistic analysis of single-cell omics data. Nature Biotechnology, 40(2), 163–166. https://doi.org/10.1038/s41587-021-01206-w
Luecken, M. D., Büttner, M., Chaichoompu, K., Danese, A., Interlandi, M., Mueller, M. F., Strobl, D. C., Zappia, L., Dugas, M., Colomé-Tatché, M., & Theis, F. J. (2021). Benchmarking atlas-level data integration in single-cell genomics. Nature Methods, 19(1), 41–50. https://doi.org/10.1038/s41592-021-01336-8
Satija, R., Farrell, J. A., Gennert, D., Schier, A. F., & Regev, A. (2015). Spatial reconstruction of single-cell gene expression data. Nature Biotechnology, 33(5), 495–502. https://doi.org/10.1038/nbt.3192
Pedregosa, F., Varoquaux, G., Gramfort, A., Michel, V., Thirion, B., Grisel, O., Blondel, M., Müller, A., Nothman, J., Louppe, G., Prettenhofer, P., Weiss, R., Dubourg, V., Vanderplas, J., Passos, A., Cournapeau, D., Brucher, M., Perrot, M., & Duchesnay, É. (2012). Scikit-learn: Machine Learning in Python. ArXiv. https://doi.org/10.48550/ARXIV.1201.0490
Paszke, A., Gross, S., Massa, F., Lerer, A., Bradbury, J., Chanan, G., Killeen, T., Lin, Z., Gimelshein, N., Antiga, L., Desmaison, A., Köpf, A., Yang, E., DeVito, Z., Raison, M., Tejani, A., Chilamkurthy, S., Steiner, B., Fang, L., … Chintala, S. (2019). PyTorch: An Imperative Style, High-Performance Deep Learning Library (Version 1). arXiv. https://doi.org/10.48550/ARXIV.1912.01703
Arendt-Tranholm, A., Sankaranarayanan, I., & Price, T. J. (2025). Single nucleus sequencing of human C2 dorsal root ganglia tissue from fusion surgeries (Version 1) [Dataset]. SPARC Portal. https://doi.org/10.26275/CXUF-FTAV
Sankaranarayanan, I., & Price, T. J. (2025). Single nucleus sequencing atlas of human dorsal root ganglion (Version 1) [Dataset]. SPARC Portal. https://doi.org/10.26275/9QSP-I8DH
Yang, L., & Gereau, R. (2025). A single-cell multi-omic atlas of human sympathetic ganglia (Version 1) [Dataset]. SPARC Portal. https://doi.org/10.26275/OQXX-SI9H