diff --git a/.changeset/yellow-sides-rescue.md b/.changeset/yellow-sides-rescue.md new file mode 100644 index 0000000..9ad591d --- /dev/null +++ b/.changeset/yellow-sides-rescue.md @@ -0,0 +1,9 @@ +--- +"@platforma-open/milaboratories.feature-integration.per-cell-metrics": minor +"@platforma-open/milaboratories.feature-integration.workflow": minor +"@platforma-open/milaboratories.feature-integration": minor +"@platforma-open/milaboratories.feature-integration.model": minor +"@platforma-open/milaboratories.feature-integration.ui": minor +--- + +New updates diff --git a/model/src/index.ts b/model/src/index.ts index da9fe35..2ae52ee 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -93,6 +93,23 @@ 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]); +} + +// 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 = { @@ -137,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. @@ -616,6 +643,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). @@ -1323,7 +1356,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 }, ) @@ -1402,10 +1454,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 }, @@ -1723,7 +1833,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..adfa5c3 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. @@ -994,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"Reads matching the read pattern: {int(reads_matched):,}" 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 @@ -1089,7 +1093,9 @@ 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" + # No detail line. The cell count is the `usableReadFraction` row's, a few rows above, and stating + # it twice made a reader check whether the two numbers were the same quantity. The three no-number + # cases are covered by the reason below, the absent list among them. # 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. @@ -1100,13 +1106,17 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: if not listed_here else "no read count reached this sample, so depth has no numerator" ) - add(rows, "sample", sample, "readsPerCell", depth, detail, reason=depth_reason) + add(rows, "sample", sample, "readsPerCell", depth, reason=depth_reason) 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. @@ -1127,9 +1137,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"Barcodes flagged: {int(agg_flagged or 0):,}" if agg_threshold is not None: - agg_detail += f"|threshold={agg_threshold:.1f}" + agg_detail += f"|Threshold: {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 = ( @@ -1158,7 +1168,7 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: sample, "floorRemoved", float(stats["readingsFloored"]), - f"cellsEmptied={stats['cellsEmptied']}", + f"Cell barcodes left with no reading: {stats['cellsEmptied']:,}", ) listed_totals = ( @@ -1173,7 +1183,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"Cell barcodes with a reading: {len(listed_totals):,}", # `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. @@ -1278,13 +1288,13 @@ def _disagreement_rates(samples_here: list[str]) -> dict[str, float | None]: # The cell list rides with the figure. Two runs whose lists came from different sources do # not share a denominator, so a count of cells means nothing without the list behind it. detail = ( - f"cellsWithCount={row['cellsWithCount']}" - f"|medianCountPerCell={row['medianCountPerCell']}" - f"|samplesSeenIn={row['samplesSeenIn']}/{row['samplesInPanel']}" - f"|cellList={cell_list_source}" + f"Cells with a count: {row['cellsWithCount']:,}" + f"|Median count per cell: {row['medianCountPerCell']}" + f"|Seen in: {row['samplesSeenIn']} of {row['samplesInPanel']} samples" + f"|Cell list: {cell_list_source}" ) if above is None: - detail += "|cellsAboveTheLine=none asked, this tag supplies the baseline" + detail += "|Cells called bound: none asked, this tag supplies the baseline" add( rows, "tag", diff --git a/software/per-cell-metrics/src/qc_measures.py b/software/per-cell-metrics/src/qc_measures.py index f8578f3..94bd127 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/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(): 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..04db0e9 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -229,6 +229,8 @@ const allSources = computed(() => referenceSources.value?.options ?? []); // The expected binder fraction as a PERCENTAGE, where the data holds a share from 0 to 1. Same display // conversion as the agreement limit below, and for the same reason: a scientist states "about 30% of these // cells bound", not "0.3". Not rounded on the way out, so a typed value comes back as the number typed. +const SHOW_EXPECTED_BINDER_FRACTION = false; + const binderPercent = computed({ get: () => { const share = app.model.data.expectedBinderFraction; @@ -976,11 +978,13 @@ const gridOptions = { measure binding strength. - +