From c643d35a08ad03e708f99b718f0251c606513a90 Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Fri, 4 Sep 2026 09:38:26 +0200 Subject: [PATCH 1/8] Change undeclared axis name to avoid label overlap in table --- model/src/index.ts | 6 ++++++ workflow/src/column-specs.lib.tengo | 14 ++++++++++++++ workflow/src/verdict-import.tpl.tengo | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/model/src/index.ts b/model/src/index.ts index da9fe35..c308a3b 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -616,6 +616,12 @@ const dataModel = new DataModelBuilder() // rather than rewritten: the saved filters and column set were saved against axes that no longer exist in // that order. .migrate("v10", (data) => ({ ...data, reagentTableState: createPlDataTableStateV2() })) + // v11. The undeclared-barcode grid moved to its own axis, so a saved column set, order or filter names an + // axis that table no longer has. Reset rather than rewritten, for the same reason as v10. + .migrate("v11", (data) => ({ + ...data, + undeclaredBarcodesTableState: createPlDataTableStateV2(), + })) .init(() => ({ runMode: "full" as const, // full run by default. "dry" = read-limited Preview // The geometry the block shipped with, 10x 5' v2 BEAM (16 / 10 / 15). diff --git a/workflow/src/column-specs.lib.tengo b/workflow/src/column-specs.lib.tengo index e839a01..efd2b92 100644 --- a/workflow/src/column-specs.lib.tengo +++ b/workflow/src/column-specs.lib.tengo @@ -371,6 +371,19 @@ tagAxis := func(blockId) { } } +// undeclaredTagAxis: one sequence the reads carried that NO panel declares. +undeclaredTagAxis := func(blockId) { + return { + name: "pl7.app/antigen/undeclaredTagId", + type: "String", + domain: { + "pl7.app/blockId": blockId, + "pl7.app/antigen/tagType": "antigen" + }, + annotations: { "pl7.app/label": "Barcode" } + } +} + // identityAxis: the thing a verdict is about -- one tag, or a group of tags read as one antigen. // // `groupingId` is in the DOMAIN, and ships even though v1 only ever sets "per-tag". Without it the axis @@ -1672,6 +1685,7 @@ export { negativeControlColumn: negativeControlColumn, tagAxis: tagAxis, + undeclaredTagAxis: undeclaredTagAxis, identityAxis: identityAxis, panelAxis: panelAxis, captureAxis: captureAxis, diff --git a/workflow/src/verdict-import.tpl.tengo b/workflow/src/verdict-import.tpl.tengo index 96f80d3..4003aea 100644 --- a/workflow/src/verdict-import.tpl.tengo +++ b/workflow/src/verdict-import.tpl.tengo @@ -265,7 +265,7 @@ self.body(func(inputs) { undeclaredFb := pframes.pFrameBuilder() addTo(undeclaredFb, "undeclaredBarcodes", inputs.undeclaredBarcodes, - columnSpecs.undeclaredBarcodeImportSpec(sampleAxis, tagAxis)) + columnSpecs.undeclaredBarcodeImportSpec(sampleAxis, columnSpecs.undeclaredTagAxis(blockId))) return { antigenVerdicts: exportFb.build(), From 4dce26778961903ae8c768ac55aae5d4321a63ef Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Fri, 4 Sep 2026 11:09:34 +0200 Subject: [PATCH 2/8] Show VDJ sequence and gene columns in clonotype binding table --- model/src/index.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/model/src/index.ts b/model/src/index.ts index c308a3b..9d0024d 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -93,6 +93,20 @@ export const CELL_PUNCH_COLUMN_NAME = "pl7.app/antigen/cellPunch"; // unreachable here. export const PUNCH_CELL_COUNT_COLUMN = "pl7.app/antigen/cellCount"; +// The clonotype's own V(D)J properties, as every producer names them. +export const VDJ_SEQUENCE_COLUMN = "pl7.app/vdj/sequence"; +export const VDJ_GENE_HIT_COLUMN = "pl7.app/vdj/geneHit"; +export const CHAIN_INDEX_DOMAIN = "pl7.app/vdj/scClonotypeChain/index"; +export const VDJ_ASSEMBLING_FEATURE_ANNOTATION = "pl7.app/vdj/isAssemblingFeature"; + +// Axis identity as a comparable string. Domain entries are SORTED, because two specs that mean the same +// axis can carry their domain keys in different orders and a bare JSON.stringify would call them different. +function axisKeyOf(axis: Parameters[0]): string { + const id = getAxisId(axis); + const domain = Object.entries(id.domain ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify([id.name, id.type, domain]); +} + // User-facing names only. The DATA layer keeps `declared`/`panel`/`none`, which are p-column domain values, // and domain is part of column identity. These strings match the labels `referenceSources` offers. export const REFERENCE_SOURCE_LABELS: Record = { @@ -1408,10 +1422,68 @@ export const platforma = BlockModelV3.create(dataModel) // 96000, between the clonotype label's 100000 and the punches' 92000. To fix a column that "renders last", // measure with `aria-colindex`: `querySelectorAll('[role="columnheader"]')` returns AG Grid's recycled // header nodes in an order unrelated to column position. + + // The clonotype's own V(D)J properties, joined onto the same axis the punches use. + const punchAxisKey = axisKeyOf(cols[0].spec.axesSpec[0]); + const vdjColumns = ctx.resultPool + .getOptions((spec) => { + if (!isPColumnSpec(spec)) return false; + if (spec.name !== VDJ_SEQUENCE_COLUMN && spec.name !== VDJ_GENE_HIT_COLUMN) return false; + if (spec.domain?.[CHAIN_INDEX_DOMAIN] !== "primary") return false; + // Keyed on the clonotype and on NOTHING else. A per-sample column would drag a sample axis in and + // re-key the whole card. + if (spec.axesSpec.length !== 1) return false; + return axisKeyOf(spec.axesSpec[0]) === punchAxisKey; + }) + .map((o) => ctx.resultPool.getPColumnByRef(o.ref)) + .filter((c): c is NonNullable => c !== undefined); + + // Which sequence a reader sees by default. + const aaSequences = vdjColumns.filter( + (c) => + c.spec.name === VDJ_SEQUENCE_COLUMN && + c.spec.domain?.["pl7.app/alphabet"] === "aminoacid", + ); + type SequenceMatch = { + name: string; + domain: Record; + annotations?: Record; + }; + const shownSequenceMatch: SequenceMatch | undefined = aaSequences.some( + (c) => c.spec.annotations?.[VDJ_ASSEMBLING_FEATURE_ANNOTATION] === "true", + ) + ? { + name: VDJ_SEQUENCE_COLUMN, + domain: { "pl7.app/alphabet": "aminoacid" }, + annotations: { [VDJ_ASSEMBLING_FEATURE_ANNOTATION]: "true" }, + } + : aaSequences.some((c) => /cdr3/i.test(c.spec.domain?.["pl7.app/vdj/feature"] ?? "")) + ? { + name: VDJ_SEQUENCE_COLUMN, + // The matcher offers only exact and regex, so "contains" is spelled as one. + domain: { + "pl7.app/alphabet": "aminoacid", + "pl7.app/vdj/feature": { type: "regex" as const, value: ".*[Cc][Dd][Rr]3.*" }, + }, + } + : undefined; + return createPlDataTableV3(ctx, { primaryColumns: [...cellCount, ...ordered].map((c) => DataColumn.fromColumn(c)), - columns: null, + columns: vdjColumns.map((c) => DataColumn.fromColumn(c)), tableState: ctx.data.punchcardTableState, + // Read in order, first match wins; anything UNMATCHED keeps its own default, which is what leaves the + // punches and the cell count showing. Both fallthrough rules are scoped by column name so they can + // never reach them. + displayOptions: { + visibility: [ + ...(shownSequenceMatch === undefined + ? [] + : [{ match: shownSequenceMatch, visibility: "default" as const }]), + { match: { name: VDJ_SEQUENCE_COLUMN }, visibility: "optional" as const }, + { match: { name: VDJ_GENE_HIT_COLUMN }, visibility: "optional" as const }, + ], + }, }); }, { retentive: true, withStatus: true }, From e42930d9b93d9c05e998de7bd6560ec01fa6e883 Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Fri, 4 Sep 2026 12:12:27 +0200 Subject: [PATCH 3/8] Hide sample QC column and remove from cell counts table those that didn't match VDJ --- model/src/index.ts | 26 +++++++++++++++++-- .../per-cell-metrics/src/emit_verdicts.py | 4 +++ workflow/src/column-specs.lib.tengo | 26 ++++++++++++++++++- workflow/src/main.tpl.tengo | 1 + workflow/src/verdict-import.tpl.tengo | 4 +++ workflow/src/verdict-run.tpl.tengo | 1 + 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/model/src/index.ts b/model/src/index.ts index 9d0024d..b5cc8b8 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -107,6 +107,9 @@ function axisKeyOf(axis: Parameters[0]): string { return JSON.stringify([id.name, id.type, domain]); } +// The cell list as a joinable column: a row per cell the V(D)J data matched and none otherwise. +export const CELL_IN_LIST_COLUMN = "pl7.app/antigen/cellInList"; + // User-facing names only. The DATA layer keeps `declared`/`panel`/`none`, which are p-column domain values, // and domain is part of column identity. These strings match the labels `referenceSources` offers. export const REFERENCE_SOURCE_LABELS: Record = { @@ -1343,7 +1346,26 @@ export const platforma = BlockModelV3.create(dataModel) (ctx) => { const pCols = ctx.outputs?.resolve("perCellTable")?.getPColumns(); if (pCols === undefined || pCols.length === 0) return undefined; - return createPlDataTableV2(ctx, pCols, ctx.data.tableState); + + // Narrowed to the cells the V(D)J data matched. + const runMeta = ctx.outputs + ?.resolve({ field: "antigenRunMeta", allowPermanentAbsence: true }) + ?.getDataAsJsonOrUndefined(); + const listed = + runMeta === undefined || runMeta.cellListSource === "none" + ? [] + : ( + ctx.outputs + ?.resolve({ field: "antigenCellReference", allowPermanentAbsence: true }) + ?.getPColumns() ?? [] + ).filter((c) => c.spec.name === CELL_IN_LIST_COLUMN); + + return createPlDataTableV2( + ctx, + [...pCols, ...listed], + ctx.data.tableState, + listed.length > 0 ? { coreJoinType: "inner" } : undefined, + ); }, { retentive: true, withStatus: true }, ) @@ -1801,7 +1823,7 @@ export const platforma = BlockModelV3.create(dataModel) { type: "link" as const, href: "/" as const, label: "Main" }, ...(hasRun ? [ - { type: "link" as const, href: "/qc" as const, label: "Sample QC" }, + //{ type: "link" as const, href: "/qc" as const, label: "Sample QC" }, { type: "link" as const, href: "/results" as const, label: "Cell counts" }, { type: "link" as const, href: "/antigen-qc" as const, label: "Tag QC" }, { type: "link" as const, href: "/punchcard" as const, label: "Clonotype binding" }, diff --git a/software/per-cell-metrics/src/emit_verdicts.py b/software/per-cell-metrics/src/emit_verdicts.py index f2828e1..8de7341 100644 --- a/software/per-cell-metrics/src/emit_verdicts.py +++ b/software/per-cell-metrics/src/emit_verdicts.py @@ -736,6 +736,10 @@ def _admissibility(key: CellKey) -> str: ) _write_sorted(_answers(cell_scalars), f"{prefix}_cell_scalars.csv", ["sampleId", "cellId"]) + # The cell list ITSELF, as its own frame: a row per cell the V(D)J data matched and none for any other + # barcode. + _write_sorted(in_list, f"{prefix}_cell_in_list.csv", ["sampleId", "cellId"]) + # Both frames are pure key sets -- what a sample was offered, and which identity a tag feeds -- and # each carries a constant value column so it can become a p-column at all. A frame of key columns # alone imports as nothing: columns are built from value columns. diff --git a/workflow/src/column-specs.lib.tengo b/workflow/src/column-specs.lib.tengo index efd2b92..d89e754 100644 --- a/workflow/src/column-specs.lib.tengo +++ b/workflow/src/column-specs.lib.tengo @@ -556,7 +556,7 @@ verdictsImportSpec := func(setAxisSpec, identityAxisSpec, served) { "pl7.app/label": "Binding verdict", "pl7.app/isDiscreteFilter": "true", "pl7.app/discreteValues": VERDICT_STATES, - "pl7.app/description": "One of four states. 'Never asked' means the experiment did not put this antigen to these cells; 'unreliable' means it did and the data cannot settle it. Neither is a kind of 'not bound'." + "pl7.app/description": "One of four states. 'Bound' and 'not bound' read the count against the baseline that served. 'Never asked' means the antigen appeared in no read from these cells' samples; 'unreliable' means it was measured and the reading cannot tell bound from not bound. Neither is a kind of 'not bound'." })) }), verdictCol("unreliableReason", "unreliableReason", { @@ -1026,6 +1026,29 @@ cellScalarsImportSpec := func(sampleAxisSpec, cellAxisSpec, served) { } } +// --- The cell list, as a joinable frame --------------------------------------------------- +// +// result_cell_in_list.csv, keyed (sampleId, cellId). One row per matched cell (VDJ vs tags) +cellInListImportSpec := func(sampleAxisSpec, cellAxisSpec) { + return { + axes: [ + { column: "sampleId", spec: sampleAxisSpec }, + { column: "cellId", spec: cellAxisSpec } + ], + columns: [{ + column: "inCellList", + id: "cellInList", + spec: { + name: "pl7.app/antigen/cellInList", + valueType: "String", + annotations: a(0, undefined, { "pl7.app/label": "In cell list" }) + } + }], + storageFormat: "Parquet", + partitionKeyLength: 0 + } +} + // --- Scope: which identities each sample was stained with -------------------------------- // // result_offered.csv, keyed (sampleId, identity). Without it, "never asked" is a claim a reader cannot @@ -1702,6 +1725,7 @@ export { cellTagCountsImportSpec: cellTagCountsImportSpec, cellRawTagCountsImportSpec: cellRawTagCountsImportSpec, cellScalarsImportSpec: cellScalarsImportSpec, + cellInListImportSpec: cellInListImportSpec, offeredImportSpec: offeredImportSpec, tagIdentityLinkerImportSpec: tagIdentityLinkerImportSpec, identityLabelsImportSpec: identityLabelsImportSpec, diff --git a/workflow/src/main.tpl.tengo b/workflow/src/main.tpl.tengo index 4debb48..e0b2c8a 100644 --- a/workflow/src/main.tpl.tengo +++ b/workflow/src/main.tpl.tengo @@ -463,6 +463,7 @@ wf.body(func(args) { // cellRawCounts, the same grain BEFORE the floor, IS passed. cellRawCounts: verdictRun.output("cellRawCounts"), cellScalars: verdictRun.output("cellScalars"), + cellInList: verdictRun.output("cellInList"), offered: verdictRun.output("offered"), tagIdentity: verdictRun.output("tagIdentity"), identityLabels: verdictRun.output("identityLabels"), diff --git a/workflow/src/verdict-import.tpl.tengo b/workflow/src/verdict-import.tpl.tengo index 4003aea..f23ee66 100644 --- a/workflow/src/verdict-import.tpl.tengo +++ b/workflow/src/verdict-import.tpl.tengo @@ -142,6 +142,10 @@ self.body(func(inputs) { cellRefFb := pframes.pFrameBuilder() addTo(cellRefFb, "cellScalars", inputs.cellScalars, columnSpecs.cellScalarsImportSpec(sampleAxis, cellAxis, served)) + // The cell list as a frame of its own. Same grain as cellScalars beside it, opposite shape: rows only + // where the cell matched. The model inner-joins it to narrow the per-cell table. + addTo(cellRefFb, "cellInList", inputs.cellInList, + columnSpecs.cellInListImportSpec(sampleAxis, cellAxis)) // The per-cell punchcard, into the SAME frame: one row per cell, one column per identity, that cell's own // reading. The by-cell face of the expansion, and the one place the block shows WHY a clonotype's verdict diff --git a/workflow/src/verdict-run.tpl.tengo b/workflow/src/verdict-run.tpl.tengo index 8e744f7..2acaa59 100644 --- a/workflow/src/verdict-run.tpl.tengo +++ b/workflow/src/verdict-run.tpl.tengo @@ -32,6 +32,7 @@ RESULT_TABLES := [ { out: "cellCounts", file: "result_cell_counts.csv" }, { out: "cellRawCounts", file: "result_cell_raw_counts.csv" }, { out: "cellScalars", file: "result_cell_scalars.csv" }, + { out: "cellInList", file: "result_cell_in_list.csv" }, { out: "offered", file: "result_offered.csv" }, { out: "tagIdentity", file: "result_tag_identity.csv" }, { out: "identityLabels", file: "result_identity_labels.csv" }, From 0b9d18baf0649d4e5e6addf21a2eed326c2afb53 Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Fri, 4 Sep 2026 13:01:41 +0200 Subject: [PATCH 4/8] UI changes --- block/package.json | 2 +- model/src/index.ts | 10 ++++ .../per-cell-metrics/src/emit_verdicts.py | 20 ++++--- software/per-cell-metrics/src/qc_measures.py | 41 +++++++------- software/per-cell-metrics/src/qc_rows.py | 19 +++---- ui/src/components/CellPunchCell.vue | 15 ++++-- ui/src/components/PunchCell.vue | 54 +++++++++++++------ ui/src/components/PunchLegend.vue | 26 ++++----- ui/src/pages/MainPage.vue | 10 ++-- ui/src/pages/PunchcardPage.vue | 2 + 10 files changed, 119 insertions(+), 80 deletions(-) diff --git a/block/package.json b/block/package.json index d1d3366..2264a9b 100644 --- a/block/package.json +++ b/block/package.json @@ -18,7 +18,7 @@ }, "scripts": { "build": "ts-builder build --target block-facade && block-tools pack", - "prepublishOnly": "block-tools publish --unstable -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science", + "prepublishOnly": "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science", "do-pack": "shx rm -f package.tgz && pnpm pack && shx mv *.tgz package.tgz", "check": "ts-builder type-check --target block-facade" }, diff --git a/model/src/index.ts b/model/src/index.ts index b5cc8b8..2ae52ee 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -154,6 +154,16 @@ export type VerdictRunMeta = { * reader treats absent as one. */ samplePanelCount?: number; + /** + * The two combining rules the run applied, as applied and not as stored: `args()` substitutes the shipped + * default wherever the stored value is undefined, so a reader comparing runs needs the number that served. + * + * Neither is optional. Both have travelled in the run record since it existed, so no stored run lacks them. + * `minAgreement` is null where no floor was set -- off is absent rather than zero, the same convention + * `gateThreshold` below follows. + */ + minVoters: number; + minAgreement: number | null; /** * The read limit the run applied. Absent or null where the run declared none, which is the one signal for * "a gate was declared". A gate that set nothing aside must still say so. diff --git a/software/per-cell-metrics/src/emit_verdicts.py b/software/per-cell-metrics/src/emit_verdicts.py index 8de7341..48164ce 100644 --- a/software/per-cell-metrics/src/emit_verdicts.py +++ b/software/per-cell-metrics/src/emit_verdicts.py @@ -998,7 +998,7 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: qc = read_qc.get(sample, {}) reads_matched = _number(qc, "readsMatched") - matched_detail = "" if reads_matched is None else f"readsMatched={int(reads_matched)}" + matched_detail = "" if reads_matched is None else f"{int(reads_matched):,} reads matched the read pattern" add(rows, "sample", sample, "readsTotal", _number(qc, "readsTotal"), matched_detail, reason=NO_READ_QC) # `qc_report.py` computes this from the tag-stat TSV directly, the same required input # `readsTotal` reads from the parse report -- a missing figure means no read-QC row reached this @@ -1093,7 +1093,7 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: if reads_matched is not None and listed_here is not None else None ) - detail = f"cellsInList={len(listed_here)}" if listed_here is not None else "no cell list supplied" + detail = f"{len(listed_here):,} cells in the V(D)J cell list" if listed_here is not None else "no cell list supplied" # Three cases, not two. `reads_per_cell` returns no number for an EMPTY cell list as well as # for an absent one, and a sample with no listed cell is the zero-cells finding rather than a # missing read count. @@ -1108,9 +1108,13 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: deciles = antigen_count_deciles(sample_counts) sample_decile_rows += _sample_decile_rows(sample, deciles) - decile_detail = "|".join( - f"{d}:{'' if v is None else round(v, 3)}" for d, v in zip(deciles["decile"], deciles["value"], strict=True) + # The top of the range only. All eleven deciles went out as a wall of numbers no reader used; + # the value beside it already carries the middle. + _top = next( + (v for d, v in zip(deciles["decile"], deciles["value"], strict=True) if d == 100 and v is not None), + None, ) + decile_detail = "" if _top is None else f"highest {_top:,.0f}" middle = deciles.filter(pl.col("decile") == 50)["value"].to_list() # An empty input still returns all eleven decile points, each unanswered, so a value of None # here means this sample holds no counted reading at all. @@ -1131,9 +1135,9 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: agg_fraction = _number(qc, "aggregateBarcodeFraction") agg_flagged = _number(qc, "aggregateBarcodesFlagged") agg_threshold = _number(qc, "aggregateBarcodeThreshold") - agg_detail = "" if agg_fraction is None else f"barcodesFlagged={int(agg_flagged or 0)}" + agg_detail = "" if agg_fraction is None else f"{int(agg_flagged or 0):,} barcodes flagged" if agg_threshold is not None: - agg_detail += f"|threshold={agg_threshold:.1f}" + agg_detail += f"|at a threshold of {agg_threshold:,.0f} UMIs" # `reads_total` is None where the row carries no readsTotal at all, which is neither of the two # cases below: it reports no read count rather than a count of zero. agg_reason = ( @@ -1162,7 +1166,7 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: sample, "floorRemoved", float(stats["readingsFloored"]), - f"cellsEmptied={stats['cellsEmptied']}", + f"{stats['cellsEmptied']:,} cell barcodes left with no reading", ) listed_totals = ( @@ -1177,7 +1181,7 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: sample, "uniqueCountsPerCell", _median([float(v) for v in listed_totals]), - f"cellsWithAReading={len(listed_totals)}", + f"{len(listed_totals):,} cell barcodes carried a reading", # `in_list` is empty whenever no list arrived, so the join yields nothing for every sample of # such a run. Branching on the same fact `readsPerCell` branches on keeps the two rows from # giving one run two incompatible accounts. diff --git a/software/per-cell-metrics/src/qc_measures.py b/software/per-cell-metrics/src/qc_measures.py index f8578f3..d178d8a 100644 --- a/software/per-cell-metrics/src/qc_measures.py +++ b/software/per-cell-metrics/src/qc_measures.py @@ -95,13 +95,13 @@ class Measurement: MEASUREMENTS: tuple[Measurement, ...] = ( Measurement( "readsTotal", - "Reads total and fraction matched", + "Reads parsed", "sample", # No line. Exactly four numbers are inherited from the field, and the matched share is not # one of them: usable antigen-read fraction (warn below 0.20), undeclared-barcode fraction # (warn above 0.50), aggregate-barcode read fraction (warn above 0.05), barcode validity # (warn below 0.75). Nothing published says what a low matched share means. - "Every read the parser saw, and the share matching the tag pattern.", + "Every read the block read from this sample's files.", ), # `qc_report._refine_kept_fraction` returns the FEATURE step's outputCount/inputCount -- the share # of matched reads whose barcode corrects onto a panel entry. Its complement is the share landing @@ -119,9 +119,9 @@ class Measurement: # Renaming it breaks both. Measurement( "panelAssignedFraction", - "Fraction of reads in undeclared barcodes (as its complement)", + "Fraction of reads matching a panel barcode", "sample", - "Reads whose corrected barcode is on the panel, over reads matched.", + "Reads whose barcode matches the panel, out of the reads that matched the read pattern.", ), # No line. The four inherited numbers do not include this one, and nothing published says what a # low or high rescued share means -- a panel whose barcodes sit far apart rescues little because @@ -130,10 +130,9 @@ class Measurement: # table is not a read the run lost, and this says how much of it was not. Measurement( "refineRescuedShare", - "Fraction of reads correction rescued onto the panel", + "Fraction of reads rescued by barcode correction", "sample", - "Reads on a sequence the panel does not declare that refine-tags then snapped onto a panel " - "entry, over reads matched.", + "Reads whose barcode sat a base or two off a panel entry and was corrected onto it." ), # Ported from Cell Ranger's own read-recovery metric, # `_report_genome_agnostic_metrics::frac_feature_reads_usable`: conf-mapped, barcoded reads @@ -143,9 +142,9 @@ class Measurement: # panel-recognised FEATURE value, so restricting to the cell list is the only condition left. Measurement( "usableReadFraction", - "Fraction of antigen reads usable", + "Fraction of reads usable for antigen calls", "sample", - "Reads whose corrected barcode is on the panel and whose cell barcode is in the cell list, over readsTotal.", + "Reads that both match the panel and come from a cell the V(D)J data matched.", "A low share means most of the library's reads are lost before reaching a called cell " "with a panel-recognised barcode.", "inherited", @@ -154,7 +153,7 @@ class Measurement: # putting error at total failure. The refine-tags report already carries the CELL step this reads. Measurement( "cellBarcodeValidFraction", - "Fraction of reads whose cell barcode the chemistry could have produced", + "Fraction of reads with a valid cell barcode", "sample", "Reads whose cell barcode corrects onto the chemistry's whitelist, over reads entering correction.", "A low share means the reads carry cell barcodes this chemistry does not produce, " @@ -174,7 +173,7 @@ class Measurement: "cellsDetected", "Cell barcodes detected", "sample", - "Distinct cell barcodes in the tag-stat table, before any cell-calling step.", + "Distinct cell barcodes seen, before cell calling. Most are empty droplets.", "Zero cells means nothing downstream can be computed for this sample.", "categorical", ), @@ -196,7 +195,7 @@ class Measurement: ), Measurement( "antigenCountDistribution", - "Distribution of antigen count per barcode", + "Median antigen count per cell barcode", "sample", "Deciles of the total antigen count per cell barcode.", ), @@ -215,7 +214,7 @@ class Measurement: "aggregateBarcodeFraction", "Fraction of reads in aggregate barcodes", "sample", - "Reads in barcodes flagged as aggregates by the top-100 IQR rule, over readsTotal.", + "Reads in barcodes carrying far more signal than the rest, which points at clumped droplets.", "A high share means much of the run's antigen signal comes from a small number of " "clumped droplets rather than single cells.", "inherited", @@ -241,13 +240,13 @@ class Measurement: ), Measurement( "floorRemoved", - "Counts removed as below the minimum, and cells left with none", + "Counts removed as too low", "sample", "Readings the minimum zeroed, and cells whose every non-reference reading was removed.", ), Measurement( "uniqueCountsPerCell", - "Reads and unique counts per cell", + "Median unique counts per cell", "sample", "Reads and distinct UMIs per cell barcode.", ), @@ -259,9 +258,9 @@ class Measurement: # reading of any size means. Measurement( "highReferenceCells", - "Sticky cells, or the spread of the readings where no gate is declared", + "Sticky cells, or the spread of control readings", "sample", - "Cells whose reference reading exceeded the declared gate, or the spread of those readings.", + "Cells whose control reading exceeded the admissibility gate.", ), # The id is a value on the `measurement` axis, so renaming it does not break the column -- it # splits the rows, and a table holding old and new runs reads as two measurements. @@ -772,7 +771,7 @@ def usable_read_fraction( if not reads_total: return None, "no total read count to divide by" usable = float(tag_stat.filter(pl.col(cell_col).is_in(list(listed_cells)))["totalWeight"].sum()) - return usable / reads_total, f"cellsInList={len(listed_cells)}" + return usable / reads_total, f"Cells in the V(D)J cell list: {len(listed_cells):,}" # Cell Ranger's own constants for the ANTIGEN branch of `detect_outlier_umis_bcs` @@ -845,11 +844,11 @@ def aggregate_barcode_fraction( detail = "no antigen barcode observed in this sample" elif threshold < min_umi_threshold: detail = ( - f"barcodesTested={tested}|threshold={threshold:.1f} " - f"(below the {min_umi_threshold:.0f}-UMI floor, no barcode flagged)" + f"Barcodes tested: {tested:,}|Threshold: {threshold:,.0f} UMIs " + f"(below the {min_umi_threshold:,.0f}-UMI floor, so no barcode is flagged)" ) else: - detail = f"barcodesTested={tested}|threshold={threshold:.1f}|barcodesFlagged={len(flagged)}" + detail = f"Barcodes tested: {tested:,}|Threshold: {threshold:,.0f} UMIs|Barcodes flagged: {len(flagged):,}" flagged_reads = per_barcode.filter(pl.col("barcode").is_in(flagged))["readCount"].sum() if flagged else 0 return flagged_reads / reads_total, detail diff --git a/software/per-cell-metrics/src/qc_rows.py b/software/per-cell-metrics/src/qc_rows.py index 3de37e9..ced8534 100644 --- a/software/per-cell-metrics/src/qc_rows.py +++ b/software/per-cell-metrics/src/qc_rows.py @@ -337,17 +337,14 @@ def _sticky_measure(readings: dict[tuple[str, str], int], gate: int | None) -> t run never asked, while the run record reports None for the same condition. So neither form returns a number there, and the caller's reason goes out in place of one. """ - comparator_detail = f"cellsWithAComparator={len(readings)}" + comparator_detail = f"Cells with a control reading: {len(readings):,}" if not readings: return None, comparator_detail if gate is not None: - return float(sum(1 for v in readings.values() if v > gate)), f"{comparator_detail}|gate={gate}" + return float(sum(1 for v in readings.values() if v > gate)), f"{comparator_detail}|Gate: {gate:,} UMIs" deciles = deciles_of(np.asarray(list(readings.values()), dtype=float)) - points = "|".join( - f"{d}:{'' if v is None else round(v, 3)}" for d, v in zip(deciles["decile"], deciles["value"], strict=True) - ) middle = deciles.filter(pl.col("decile") == 50)["value"].to_list() - return (middle[0] if middle else None), f"{comparator_detail}|noGateDeclared|{points}" + return (middle[0] if middle else None), f"{comparator_detail}|No gate declared" def _score_spread(states: pl.DataFrame, served: ReferenceChoice) -> tuple[float | None, str]: @@ -388,11 +385,11 @@ def _fitted_background(tag_fits: TagFits | None, samples: Collection[str], tag: means = [b.mean for b in fitted] detail = "|".join( [ - f"samplesFitted={len(fitted)}", - f"samplesUnfitted={len(missed)}", - f"backgroundRange={min(means):.4g}..{max(means):.4g}", - f"medianSignalMean={_median([b.signal_mean for b in fitted]):.4g}", - f"medianBackgroundWeight={_median([b.weight for b in fitted]):.4g}", + f"Samples fitted: {len(fitted)}", + f"Samples not fitted: {len(missed)}", + f"Background range: {min(means):.4g} to {max(means):.4g}", + f"Median fitted signal: {_median([b.signal_mean for b in fitted]):.4g}", + f"Median background share: {_median([b.weight for b in fitted]):.4g}", ] ) return _median(means), detail diff --git a/ui/src/components/CellPunchCell.vue b/ui/src/components/CellPunchCell.vue index 5ac953e..4d03821 100644 --- a/ui/src/components/CellPunchCell.vue +++ b/ui/src/components/CellPunchCell.vue @@ -61,10 +61,9 @@ const cellStyle: CSSProperties = { height: "100%", }; -// No token -> sentence map here, deliberately. `UnreliableReason`'s VALUES are already the prose meant for a -// reader ("no comparator for this cell"), and the enum member is what code compares against. The set-level -// card expands tokens because its reasons come from `SetUnreliableReason`, a different vocabulary that -// really is machine values. +// Keyed by `UnreliableReason`'s VALUES, not by its enum members: the values are what travel in the data +// ("no comparator for this cell"), and they are already readable -- so an unmapped one falls through to +// itself under a `Why:` prefix rather than being swallowed. Only these two reach a cell. // // Prefixed rather than capitalised, for the reason PunchCell gives: a blanket transform would also // capitalise the antigen name, which is panel data. @@ -78,12 +77,18 @@ const EXPLANATION: Record = { "No mark: no sample holding this cell declared this antigen, so there was nothing to answer", }; +const WHY_UNUSABLE: Record = { + "cell set aside by the admissibility gate": + "This cell bound the negative control strongly, so it is taking up reagent non-specifically and its readings were set aside.", + "no comparator for this cell": "There was no background reading to compare this cell against.", +}; + const lines = computed(() => { const r = reading.value; if (r.state === "unparsed") return ["No readable reading for this cell at this identity"]; const out = props.params.antigen === undefined ? [] : [props.params.antigen]; out.push(r.state.toUpperCase(), EXPLANATION[r.state]); - if (r.reason !== undefined) out.push(`Why: ${r.reason}`); + if (r.reason !== undefined) out.push(WHY_UNUSABLE[r.reason] ?? `Why: ${r.reason}`); return out; }); diff --git a/ui/src/components/PunchCell.vue b/ui/src/components/PunchCell.vue index a2c4dd5..e3c4d5a 100644 --- a/ui/src/components/PunchCell.vue +++ b/ui/src/components/PunchCell.vue @@ -29,7 +29,14 @@ import { PUNCH_DIAMETER_PX, PUNCH_PAINT, parsePunch } from "./punchMarks"; // There is deliberately no score and no binding level here. The tooltip explains a verdict by what it RESTS // on and never by how strongly anything bound. const props = defineProps<{ - params: { value: unknown; antigen?: string; mergedNote?: string; showAsked?: boolean }; + params: { + value: unknown; + antigen?: string; + mergedNote?: string; + showAsked?: boolean; + minAgreement?: number; + minVoters?: number; + }; }>(); const punch = computed(() => parsePunch(props.params.value)); @@ -66,20 +73,37 @@ const cellStyle: CSSProperties = { height: "100%", }; -// Why this mark is this colour, in the order a reader asks it: what the verdict is, what it rests on, and -// -- where the verdict is unsettled -- which of the five ways it failed to settle. The sixth key below is -// never-offered, which belongs to *never asked* rather than to an unsettled reading. The reason tokens are -// machine values (`no-comparator`, `tie`, ...), so each is expanded here rather than shown raw. +// The five ways a reading fails to settle, plus never-offered, which belongs to *not tested* rather than to +// an unsettled reading. The tokens are machine values (`no-comparator`, `tie`, ...), so each is expanded +// here rather than shown raw. A token with no case falls through to itself, so an unknown one is shown +// rather than swallowed. // -// Each line is capitalised at the source rather than by a transform over `lines`. A blanket transform would -// also capitalise the antigen name, which is panel data. -const WHY_UNSETTLED: Record = { - "never-offered": "No sample holding these cells declared this antigen", - "no-comparator": "No baseline reading existed for these cells", - "all-cells-gated": "Every cell was set aside by the admissibility gate", - tie: "The cells split evenly, so no majority settled it", - "below-agreement-floor": "The cells agreed less than the run required", - "too-few-voters": "Fewer cells answered than the run required", +// A function rather than a map because two of them quote the run's cutoffs. Each line is capitalised at the +// source rather than by a transform over `lines`: a blanket transform would also capitalise the antigen +// name, which is panel data. +const whyUnsettled = (reason: string): string => { + const voters = props.params.minVoters; + const agreement = props.params.minAgreement; + switch (reason) { + case "no-comparator": + return "No background value could be worked out for these cells, so there was nothing to compare their counts against."; + case "all-cells-gated": + return "Every one of these cells bound the negative control strongly, so all were set aside as non-specific."; + case "tie": + return "The cells split evenly, with no majority either way."; + case "below-agreement-floor": + return agreement === undefined + ? "The cells agreed less than this run requires." + : `The cells agreed less than the ${Math.round(agreement * 100)}% this run requires.`; + case "too-few-voters": + return voters === undefined + ? "This run requires more cells with a usable reading." + : `This run requires at least ${voters} cell${voters === 1 ? "" : "s"} with a usable reading.`; + case "never-offered": + return "This antigen was not in the panel for the samples these cells came from."; + default: + return reason; + } }; const EXPLANATION: Record = { @@ -115,7 +139,7 @@ const lines = computed(() => { if (p.bound !== undefined) out.push(`${p.bound} of them read bound`); if (p.agreement !== undefined) out.push(`${Math.round(p.agreement * 100)}% of them agreed`); } - if (p.reason !== undefined) out.push(WHY_UNSETTLED[p.reason] ?? p.reason); + if (p.reason !== undefined) out.push(whyUnsettled(p.reason)); // Last, because it is about the COLUMN rather than this verdict: why this identity is one merged reagent // while its neighbours are single antigens. if (props.params.mergedNote !== undefined) out.push(props.params.mergedNote); diff --git a/ui/src/components/PunchLegend.vue b/ui/src/components/PunchLegend.vue index 80c905b..ee505e5 100644 --- a/ui/src/components/PunchLegend.vue +++ b/ui/src/components/PunchLegend.vue @@ -20,22 +20,22 @@ const SET_ENTRIES: Entry[] = [ { glyph: "bound", label: "Bound", - meaning: "a majority of the cells that answered read it as bound", + meaning: "most cells with a usable reading show binding", }, { glyph: "not-bound", label: "Not bound", - meaning: "the cells that answered read it as not bound", + meaning: "most cells with a usable reading show no binding", }, { glyph: "unreliable", label: "Unreliable", - meaning: "asked, and the readings could not settle it. Hover for which of the five ways", + meaning: "this antigen was in the panel, but nothing usable came back. Hover for why.", }, { glyph: "none", - label: "Never asked", - meaning: "no sample holding these cells declared this antigen", + label: "Not tested", + meaning: "this antigen was not in the panel for these samples", }, ]; @@ -43,25 +43,25 @@ const CELL_ENTRIES: Entry[] = [ { glyph: "bound", label: "Bound", - meaning: "this cell read the antigen as bound", + meaning: "this cell shows binding", }, { glyph: "not-bound", label: "Not bound", - // Said explicitly, because it is the one thing about this face a reader would otherwise get wrong: a cell - // that returned no count for an antigen it WAS asked about reads here, never blank. A zero count is a - // reading, and the same cell votes that way in its clonotype's verdict. - meaning: "this cell read it as not bound, a returned count of zero included", + // The zero count is said explicitly, because it is the one thing about this face a reader would otherwise + // get wrong: a cell that returned no count for an antigen it WAS asked about reads here, never blank. A + // zero count is a reading, and the same cell votes that way in its clonotype's verdict. + meaning: "no binding, including where the count came back as zero", }, { glyph: "unreliable", label: "Unreliable", - meaning: "this cell could not be compared at all, so it cast no vote. Hover for why", + meaning: "no usable reading from this cell", }, { glyph: "none", - label: "Never asked", - meaning: "no sample holding this cell declared this antigen", + label: "Not tested", + meaning: "this antigen was not in the panel for this sample", }, ]; diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 34627ae..1913913 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -991,12 +991,10 @@ const gridOptions = { label="Expected binder %" > + Date: Fri, 4 Sep 2026 13:19:36 +0200 Subject: [PATCH 8/8] Fix test --- .../test/test_emit_verdicts.py | 39 +++++++++---------- .../per-cell-metrics/test/test_qc_measures.py | 4 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/software/per-cell-metrics/test/test_emit_verdicts.py b/software/per-cell-metrics/test/test_emit_verdicts.py index 57ea815..a105989 100644 --- a/software/per-cell-metrics/test/test_emit_verdicts.py +++ b/software/per-cell-metrics/test/test_emit_verdicts.py @@ -2579,7 +2579,7 @@ def test_a_declared_gate_acts_under_the_tag_distribution_rung(tmp_path): qc = pl.read_csv(tmp_path / "result_qc.csv", infer_schema_length=0) row = qc.filter(pl.col("measurement") == "highReferenceCells").row(0, named=True) assert float(row["value"]) == sticky - assert "gate=100" in row["detail"] + assert "Gate: 100 UMIs" in row["detail"] def test_the_sticky_measurement_says_why_where_no_cell_carries_a_baseline_reading(tmp_path): @@ -2597,7 +2597,7 @@ def test_the_sticky_measurement_says_why_where_no_cell_carries_a_baseline_readin for row in rows.iter_rows(named=True): assert not row["value"], "a zero would read as a sample carrying no sticky cells" assert row["reason"] == "no cell in this sample carries a comparator reading" - assert row["detail"] == "cellsWithAComparator=0" + assert row["detail"] == "Cells with a control reading: 0" @pytest.fixture @@ -2632,11 +2632,11 @@ def test_the_reagent_figures_count_cells_rather_than_observed_barcodes(ambient_b qc = pl.read_csv(ambient_bed / "result_qc.csv", infer_schema_length=0) row = qc.filter((pl.col("measurement") == "perAntigen") & (pl.col("entity") == "AAAA")).row(0, named=True) - assert "cellsWithCount=3" in row["detail"], row["detail"] - assert "medianCountPerCell=40.0" in row["detail"], row["detail"] + assert "Cells with a count: 3" in row["detail"], row["detail"] + assert "Median count per cell: 40.0" in row["detail"], row["detail"] # And the figure says which list it was computed against, since two runs whose lists came from # different sources do not share a denominator. - assert "cellList=cell list" in row["detail"], row["detail"] + assert "Cell list: cell list" in row["detail"], row["detail"] def test_the_reagent_figures_fall_back_to_the_linker_as_the_cell_list(ambient_bed): @@ -2647,8 +2647,8 @@ def test_the_reagent_figures_fall_back_to_the_linker_as_the_cell_list(ambient_be qc = pl.read_csv(ambient_bed / "result_qc.csv", infer_schema_length=0) row = qc.filter((pl.col("measurement") == "perAntigen") & (pl.col("entity") == "AAAA")).row(0, named=True) - assert "cellsWithCount=3" in row["detail"], row["detail"] - assert "cellList=clonotype linker" in row["detail"], row["detail"] + assert "Cells with a count: 3" in row["detail"], row["detail"] + assert "Cell list: clonotype linker" in row["detail"], row["detail"] def test_the_sticky_measurement_is_a_spread_when_no_gate_is_declared(bed): @@ -2659,11 +2659,11 @@ def test_the_sticky_measurement_is_a_spread_when_no_gate_is_declared(bed): qc = pl.read_csv(bed / "result_qc.csv", infer_schema_length=0) row = qc.filter(pl.col("measurement") == "highReferenceCells").row(0, named=True) - assert "noGateDeclared" in row["detail"] - assert "gate=" not in row["detail"] - # Eleven decile points ride in the detail, and the value is their median. - points = [p.split(":")[0] for p in row["detail"].split("|")[2:]] - assert points == [str(p) for p in range(0, 101, 10)] + assert "No gate declared" in row["detail"] + assert "Gate:" not in row["detail"] + # The value is the median of those readings. The eleven decile points used to ride in the detail too; + # they were a wall of numbers no reader used, and the spread rows carry the distribution itself. + assert float(row["value"]) > 0 def test_the_sticky_measurement_counts_the_cells_the_gate_set_aside(bed): @@ -2673,8 +2673,8 @@ def test_the_sticky_measurement_counts_the_cells_the_gate_set_aside(bed): qc = pl.read_csv(bed / "result_qc.csv", infer_schema_length=0) row = qc.filter(pl.col("measurement") == "highReferenceCells").row(0, named=True) - assert "gate=1" in row["detail"] - assert "noGateDeclared" not in row["detail"] + assert "Gate: 1 UMIs" in row["detail"] + assert "No gate declared" not in row["detail"] meta = json.loads((bed / "result_run_meta.json").read_text()) # Same cells, counted once. The per-sample rows sum to the run's set-aside total. per_sample = qc.filter(pl.col("measurement") == "highReferenceCells")["value"].to_list() @@ -2875,11 +2875,11 @@ def test_the_fitted_background_reaches_the_measurement_set(tmp_path): seps = rows["SEPS"] assert seps["value"] is not None - assert "samplesFitted=1" in seps["detail"] - assert "medianSignalMean=" in seps["detail"] + assert "Samples fitted: 1" in seps["detail"] + assert "Median fitted signal: " in seps["detail"] # The background sits below the signal it was separated from. Read together they are the finding: a # background alone says nothing about whether the counts separated. - signal = float(seps["detail"].split("medianSignalMean=")[1].split("|")[0]) + signal = float(seps["detail"].split("Median fitted signal: ")[1].split("|")[0]) assert float(seps["value"]) < signal flat = rows["FLAT"] @@ -3264,7 +3264,6 @@ def test_a_valueless_measurement_names_the_input_that_is_actually_missing(bed): assert rows["readsTotal"]["reason"] == "no read QC summary row reached this sample" assert rows["readsPerCell"]["reason"] == "no read count reached this sample, so depth has no numerator" - assert "cellsInList=3" in rows["readsPerCell"]["detail"] def test_a_run_with_no_cell_list_gives_its_two_cell_rows_one_account(tmp_path): @@ -3360,7 +3359,7 @@ def test_usable_read_fraction_computes_a_real_value_end_to_end(tmp_path): row = {m["id"]: m for m in _sample_report(tmp_path)["measurements"]}["usableReadFraction"] assert row["value"] == pytest.approx((80 + 3 + 90 + 3) / 1000) - assert row["detail"] == "cellsInList=2" + assert row["detail"] == "Cells in the V(D)J cell list: 2" def test_usable_read_fraction_with_no_cell_list_reads_a_stated_blank(tmp_path): @@ -3396,7 +3395,7 @@ def test_usable_read_fraction_with_an_empty_cell_list_reads_zero(tmp_path): row = {m["id"]: m for m in _sample_report(tmp_path)["measurements"]}["usableReadFraction"] assert row["value"] == 0.0 - assert row["detail"] == "cellsInList=0" + assert row["detail"] == "Cells in the V(D)J cell list: 0" def test_a_declared_sample_measurement_nothing_computes_still_takes_a_row(monkeypatch): diff --git a/software/per-cell-metrics/test/test_qc_measures.py b/software/per-cell-metrics/test/test_qc_measures.py index df47cf9..ad1d405 100644 --- a/software/per-cell-metrics/test/test_qc_measures.py +++ b/software/per-cell-metrics/test/test_qc_measures.py @@ -459,8 +459,8 @@ def test_aggregate_barcode_fraction_top_n_narrows_the_tested_slice(): per_barcode = _per_barcode(list(range(600, 600 + 150 * 10, 10))) _, detail_default = aggregate_barcode_fraction(per_barcode, reads_total=1000) _, detail_50 = aggregate_barcode_fraction(per_barcode, reads_total=1000, top_n=50) - assert "barcodesTested=100" in detail_default - assert "barcodesTested=50" in detail_50 + assert "Barcodes tested: 100" in detail_default + assert "Barcodes tested: 50" in detail_50 def test_aggregate_barcode_fraction_divides_flagged_reads_by_reads_total():