diff --git a/.changeset/deep-zebras-grow.md b/.changeset/deep-zebras-grow.md new file mode 100644 index 0000000..082e92e --- /dev/null +++ b/.changeset/deep-zebras-grow.md @@ -0,0 +1,8 @@ +--- +"@platforma-open/milaboratories.feature-integration.per-cell-metrics": minor +"@platforma-open/milaboratories.feature-integration.workflow": minor +"@platforma-open/milaboratories.feature-integration.model": minor +"@platforma-open/milaboratories.feature-integration.ui": minor +--- + +Visual refactoring diff --git a/docs/description.md b/docs/description.md index ac2c384..299a424 100644 --- a/docs/description.md +++ b/docs/description.md @@ -1,39 +1,30 @@ # Overview -This block reads feature-barcode sequencing and answers, for every clonotype in the run, what it bound. -The headline use case is antigen binding — BEAM- and LIBRA-seq–style experiments — but it works for any -barcoded feature, such as surface-protein tags. - -It takes the feature-barcode FASTQ files, a panel file declaring what each barcode carries in each sample, -and the single-cell V(D)J dataset the clonotypes come from. It counts the barcodes per cell, reads each -count against a baseline you choose, and combines the cells of a clonotype into one answer per antigen. - -**The answer has four states, not two.** A clonotype against an antigen reads *bound*, *not bound*, *never -asked* — no sample its cells came from offered that antigen, or the reagent returned nothing — or -*unreliable*, where the experiment did ask and the data cannot settle it. The two non-answers reach you -rather than being folded into *not bound*, because a clone shown to be clean of an off-target and a clone -never tested against it are different candidates, and the difference otherwise surfaces months later at the -cost of a made molecule. - -Each verdict carries what it rests on: how many of the clonotype's cells could answer, how many read -bound, and — where you declared two antigens as competing for one site — the competitor that bound -instead. - -**Which barcodes are read as one antigen is your choice, and it belongs to the question.** Group on the -antigen column and ten strains on twelve barcodes are ten answers; group on a family column and they are -one. Regrouping re-runs the reading alone, not the counting. - -**What a count is read against is also your choice**, from the baselines the run supports: a control tag -declared in the panel and read in the same cell, or each barcode's own distribution across a sample's -cells. The block computes which are available and refuses to pick for you, because a baseline nobody chose -is a method nobody knows they used — and which one served travels with every verdict, since no two produce -comparable numbers. - -The block also reports the quality of the run before you spend time on its biology — per sample, per -reagent, and per barcode the panel never declared — so a failed reagent or a mis-declared panel is visible -as itself rather than as a clone that did not bind. - -The verdicts are keyed on the clonotype, so downstream blocks read them directly to filter and rank leads. - -This block uses mitool, which is developed by MiLaboratories Inc. For more information, please see the +This block reads feature-barcode sequencing and determines, for every clonotype in the run, which antigens +it bound. The headline use case is antigen binding in BEAM- and LIBRA-seq-style experiments, but it works +for any barcoded feature, such as surface-protein tags. It links each antigen to the receptors that bound +it, producing results ready for lead selection. + +The block takes the feature-barcode FASTQ files, a panel file declaring what each barcode carries in each +sample, and the single-cell V(D)J dataset the clonotypes come from. It uses mitool to parse the reads, +counts barcodes per cell, reads each count against a user-selected baseline — a control tag declared in the +panel, or each barcode's own distribution across a sample's cells — and combines the cells of a clonotype +into one verdict per antigen. + +Every verdict is one of four states: + +- **bound** — measured, and the cells cleared the defined bound threshold +- **not bound** — measured, and they did not +- **unreliable** — measured, but too few of its cells answered, or they disagreed +- **never asked** — no measurement exists: the antigen appeared in no read from any of those cells' samples + +Each verdict also reports how many cells it rests on. + +The block also reports run quality per sample and per reagent, along with any barcodes found in the reads +that the panel does not declare. + +The verdicts are keyed on the clonotype, so downstream blocks read them directly — Labeling to turn +verdicts into named specificity labels, and Lead Selection to filter and rank candidates. + +mitool is developed by MiLaboratories Inc. For more information, please see the [mitool reference](https://mixcr.com/mixcr/reference/mitool-parse). diff --git a/model/src/index.ts b/model/src/index.ts index 18b2703..da9fe35 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -1684,8 +1684,8 @@ export const platforma = BlockModelV3.create(dataModel) value: "declared", label: "Declared baseline tag", description: - "The block reads each count against the tag your panel marks as the baseline, in the " + - "same cell. Verdicts read this way compare across runs.", + "Use this when your panel declares a control barcode. In each cell, an antigen counts as " + + "bound where its count clearly exceeds the control's.", needs: declaredTags.length > 0 ? undefined @@ -1698,12 +1698,9 @@ export const platforma = BlockModelV3.create(dataModel) value: "distribution", label: "Each tag's own distribution", description: - `The block splits each tag's counts across a sample's cells into two components. It reads ` + - `each count against the lower component. The sample needs at least ` + - `${Math.round(ctx.data.distributionMinCells)} cells. Nothing checks that the two ` + - `components stand apart. The run shows each fit on the Run quality page, and you judge it. ` + - `Where no two-component fit can be computed, that tag gets no baseline, and only the ` + - `antigens it carries read unreliable. Select this where your panel declares no baseline tag.`, + `Use this when your panel has no control barcode. For each antigen, the block estimates ` + + `background from that antigen's own counts across a sample's cells, and a cell counts as ` + + `bound where its count is too high to be background.`, }, ]; @@ -1726,13 +1723,10 @@ 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: "Per-sample QC" }, - { type: "link" as const, href: "/results" as const, label: "Per-cell results" }, - // Shown for every run, including a run with no V(D)J dataset. That run produces no antigen columns, and - // this page is the only place a user learns why. - { type: "link" as const, href: "/punchcard" as const, label: "Explore readout" }, - // "Run quality" rather than "QC". That page is per SAMPLE and this one is per run. - { type: "link" as const, href: "/antigen-qc" as const, label: "Run quality" }, + { 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/model/src/types.ts b/model/src/types.ts index 6ecc1e6..37bf963 100644 --- a/model/src/types.ts +++ b/model/src/types.ts @@ -257,7 +257,7 @@ export type BlockData = { defaultBlockLabel?: string; // UI-only: sidebar subtitle, mirrored from the suggestedBlockLabel output tableState: PlDataTableStateV2; // per-cell results grid state (UI-only, never projected to args) qcSummaryTableState: PlDataTableStateV2; // per-sample QC summary grid state (UI-only) - // The Run quality page's two grids (UI-only). Deliberately NOT named `antigenQcTableState` / + // The Per-tag QC page's two grids (UI-only). Deliberately NOT named `antigenQcTableState` / // `panelMismatchTableState`: the v3 -> v4 migration strips those two keys. A stored grid state means // something only against the frame it was saved on. runQualityTableState: PlDataTableStateV2; // run-level quality measurements grid state diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8477d94..6e4a934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ catalogs: specifier: 2.3.1-131-main version: 2.3.1-131-main '@platforma-sdk/block-tools': - specifier: 2.14.3 - version: 2.14.3 + specifier: 2.14.4 + version: 2.14.4 '@platforma-sdk/model': specifier: 1.81.1 version: 1.81.1 @@ -95,7 +95,7 @@ importers: version: 1.6.2(@types/node@25.3.2)(rollup@4.53.3)(vue@3.5.41(typescript@5.9.3))(yaml@2.8.1) '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.14.4(@types/node@25.3.2) shx: specifier: 'catalog:' version: 0.4.0 @@ -122,7 +122,7 @@ importers: version: link:../workflow '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.14.4(@types/node@25.3.2) '@platforma-sdk/model': specifier: 'catalog:' version: 1.81.1 @@ -159,7 +159,7 @@ importers: version: 1.4.0 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.14.4(@types/node@25.3.2) software/per-cell-metrics: devDependencies: @@ -168,7 +168,7 @@ importers: version: 1.11.6 '@platforma-sdk/block-tools': specifier: 'catalog:' - version: 2.14.3(@types/node@25.3.2) + version: 2.14.4(@types/node@25.3.2) test: dependencies: @@ -1011,6 +1011,10 @@ packages: resolution: {integrity: sha512-HIjPfGAYRRD0hbq5YSTkWt5XgBiv+yYtw73EjWLxui8eUEeB2ffdgsvH7IhVx2oXjUOL4p7M4i8PBnmdJ3830w==} engines: {node: '>=22.19.0'} + '@milaboratories/pl-client@3.15.0': + resolution: {integrity: sha512-JqS1qqeX9mBrmWr1g6Va4CxaAgjGm7ck9VBpKn05Nz+4K/sBUQktDrSxVChsq2YGltXLmzejtBgmY4JPBiajog==} + engines: {node: '>=22.19.0'} + '@milaboratories/pl-config@1.8.5': resolution: {integrity: sha512-XnfYXSSkRxeImQ21k6I8y5apisvagcSgMGfEeyRxNdSGwUVJbbI8TwJk+XBEDQ4lErW8oqtLxKjFQuHJMzRUoQ==} @@ -1048,6 +1052,9 @@ packages: '@milaboratories/pl-model-backend@1.4.21': resolution: {integrity: sha512-Z2z5J8bglslgXXoi1aySi1ho+/d+ylJiuEaRw9XTNuE5iBM3EQPe0rw095pXGOinTlMpmvUcCI2wkVCraUQ1dA==} + '@milaboratories/pl-model-backend@1.4.22': + resolution: {integrity: sha512-3y/5dfOVEJ8jCL6vRawT9EK+6Kj2+CsiQw41m9qTIQrOeM1rDReMIkZmYJQz2LLItGVQRF/BZSn2OuUopSho6A==} + '@milaboratories/pl-model-common@1.23.0': resolution: {integrity: sha512-1uHb2pS+hWJyBKvfOlMYmjbFuWn+tNOULWj84mWASdqSjdYyHfVYbLq5esawM+dnZ2GBQIzJRbXrZYIMqH4Peg==} @@ -1658,8 +1665,8 @@ packages: resolution: {integrity: sha512-xKiTmNLjfxCnnHRr9Q4ct3HdjLfkbLztvxpU++/pek0R8Bj/Lezeoji+eLpxAAYYUxjMr8jLj935eLK5MqKrSg==} hasBin: true - '@platforma-sdk/block-tools@2.14.3': - resolution: {integrity: sha512-CD0NPfUoXiJhl6JArC057oCXbqKkJf5HJsmrlZShnh6lKRaIQlibF8VUqbpBi9+PopsGCQ4/s5HnLR7wQoWDzg==} + '@platforma-sdk/block-tools@2.14.4': + resolution: {integrity: sha512-QgWu6X7ZXaGZd8kqGfvXC5BRmCV2rW8MY+42fWXyFrtOVYFrWSDWeP31yh8GpocVmUczwdqxatb0DnGHsYpHzA==} hasBin: true '@platforma-sdk/blocks-deps-updater@2.2.0': @@ -7754,6 +7761,24 @@ snapshots: utility-types: 3.11.0 yaml: 2.8.1 + '@milaboratories/pl-client@3.15.0': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@milaboratories/pl-http': 1.2.4 + '@milaboratories/pl-model-common': 1.48.0 + '@milaboratories/ts-helpers': 1.8.6 + '@protobuf-ts/grpc-transport': 2.11.1(@grpc/grpc-js@1.14.4) + '@protobuf-ts/runtime': 2.11.1 + '@protobuf-ts/runtime-rpc': 2.11.1 + canonicalize: 2.1.0 + denque: 2.1.0 + long: 5.3.2 + lru-cache: 11.2.4 + openapi-fetch: 0.15.0 + undici: 7.16.0 + utility-types: 3.11.0 + yaml: 2.8.1 + '@milaboratories/pl-config@1.8.5': dependencies: '@milaboratories/ts-helpers': 1.8.6 @@ -7878,6 +7903,12 @@ snapshots: canonicalize: 2.1.0 zod: 3.25.76 + '@milaboratories/pl-model-backend@1.4.22': + dependencies: + '@milaboratories/pl-client': 3.15.0 + canonicalize: 2.1.0 + zod: 3.25.76 + '@milaboratories/pl-model-common@1.23.0': dependencies: '@milaboratories/pl-error-like': 1.12.5 @@ -8585,13 +8616,13 @@ snapshots: - '@types/node' - aws-crt - '@platforma-sdk/block-tools@2.14.3(@types/node@25.3.2)': + '@platforma-sdk/block-tools@2.14.4(@types/node@25.3.2)': dependencies: '@aws-sdk/client-ecr-public': 3.859.0 '@aws-sdk/client-s3': 3.859.0 '@inquirer/prompts': 7.10.1(@types/node@25.3.2) '@milaboratories/pl-http': 1.2.4 - '@milaboratories/pl-model-backend': 1.4.21 + '@milaboratories/pl-model-backend': 1.4.22 '@milaboratories/pl-model-common': 1.48.0 '@milaboratories/pl-model-middle-layer': 1.32.0 '@milaboratories/resolve-helper': 1.1.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a142b97..01b9edb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,7 +13,7 @@ catalog: "@milaboratories/ts-configs": 1.4.0 typescript: ~5.9.3 "@platforma-sdk/workflow-tengo": 6.8.3 - "@platforma-sdk/block-tools": 2.14.3 + "@platforma-sdk/block-tools": 2.14.4 "@platforma-sdk/model": 1.81.1 "@platforma-sdk/ui-vue": 1.81.1 "@platforma-sdk/test": 1.81.3 diff --git a/software/per-cell-metrics/src/emit_verdicts.py b/software/per-cell-metrics/src/emit_verdicts.py index 0037bf9..f2828e1 100644 --- a/software/per-cell-metrics/src/emit_verdicts.py +++ b/software/per-cell-metrics/src/emit_verdicts.py @@ -505,7 +505,7 @@ def main() -> None: # cannot be caught from the settings: whether a sample holds three hundred cells whose counts # admit a two-component fit is a property of the data. So the run FINISHES, says so, and draws # no punchcard. - # The gate is not the comparator. `reference-two-roles` keeps a declared baseline tag's two + # The gate is not the comparator. A declared baseline tag has two # roles apart: comparator always, admissibility gate only where a threshold is declared. Which # rung supplies the comparator does not reach the gate, so the declared readings are built here # too wherever the panel carries them. Without them a stored gate goes silently inert the moment diff --git a/software/per-cell-metrics/src/qc_measures.py b/software/per-cell-metrics/src/qc_measures.py index ba177b1..f8578f3 100644 --- a/software/per-cell-metrics/src/qc_measures.py +++ b/software/per-cell-metrics/src/qc_measures.py @@ -110,8 +110,7 @@ class Measurement: # (`undeclaredBarcodeShare` below), keyed by sequence and computed on the pre-refine counts. This # row keeps the number and carries no status. # - # The usable row is a different quantity: Cell Ranger `main`, - # `lib/python/cellranger/rna/report_matrix.py`, `_report_genome_agnostic_metrics`, defines + # The usable row is a different quantity: Cell Ranger `main`, defines # `frac_feature_reads_usable` as conf-mapped, barcoded reads restricted to the called-cell # partition (`cell_bcs_union`), over the whole library's read count. UMI validity is that source's # separate `good_umi_frac` figure. It is declared below as `usableReadFraction`. @@ -136,7 +135,7 @@ class Measurement: "Reads on a sequence the panel does not declare that refine-tags then snapped onto a panel " "entry, over reads matched.", ), - # Ported from Cell Ranger `main`, `lib/python/cellranger/rna/report_matrix.py`, + # Ported from Cell Ranger's own read-recovery metric, # `_report_genome_agnostic_metrics::frac_feature_reads_usable`: conf-mapped, barcoded reads # restricted to the called-cell partition, over the whole library's read count. # `usable_read_fraction` sums the post-refine tag-stat's `totalWeight` over rows whose cell barcode @@ -162,7 +161,7 @@ class Measurement: "which points at the wrong whitelist or the wrong read geometry.", "inherited", ), - # The categorical route's member. `where-the-lines-come-from` keeps that route for an alerting + # The categorical route's member. That route is kept for an alerting # condition that is a fact rather than a quantity, and says the route stays because the next # measurement may need it. This is that measurement: no cell barcode observed at all is a fact, and # a sample that detects none produced nothing for anything downstream to read. @@ -201,9 +200,8 @@ class Measurement: "sample", "Deciles of the total antigen count per cell barcode.", ), - # Ports `detect_outlier_umis_bcs` from Cell Ranger `main`, - # `lib/python/cellranger/feature/antibody/analysis.py`, called for the ANTIGEN library type from - # `cell_calling_helpers.py::remove_antibody_antigen_aggregates`. That function's antibody sibling + # Ports Cell Ranger's `detect_outlier_umis_bcs`, which it calls for the ANTIGEN library + # type while removing antibody and antigen aggregates. # (`detect_aggregate_barcodes`, cross-feature co-elevation against gene expression) is a different # rule and is not ported. # @@ -407,21 +405,21 @@ class Measurement: # the line published no error threshold. # # The two thresholds of one line are read INDEPENDENTLY. Three of the four inherited lines warn on -# a direction and put error at total failure -- "at 0", "at 1.0" -- which is `alerting-at` and not +# a direction and put error at total failure -- "at 0", "at 1.0" -- which is alerting AT failure, not # a further step along the warn direction. Only barcode validity steps the same way twice: warn # below 0.75, error below 0.50. One direction per measurement collapsed those into one, and a # fraction whose error sits "at 0" could then never alert. _COMPARISON: dict[str, tuple[str, str | None]] = { "cellBarcodeValidFraction": ("at-least", "at-least"), "readsPerCell": ("at-least", None), - # Error at total failure (`alerting-at` 1.0) rather than a further step past warn: every + # Error at total failure (alerting at 1.0) rather than a further step past warn: every # inherited share sits at either "at least" or "at most" with error at the catastrophe end, and # this is one of the two upward-facing members of that set. "aggregateBarcodeFraction": ("at-most", "alerting-at"), # Both ends face the same way, unlike the four inherited shares: this line alerts ABOVE its error - # threshold rather than at a catastrophe value, so `alerting-at` would fire only at exactly 0.05. + # threshold rather than at a catastrophe value, so alerting at failure would fire only at 0.05. "undeclaredBarcodeShare": ("at-most", "at-most"), - # Error at total failure (`alerting-at` 0.0), the downward-facing member of that same set. + # Error at total failure (alerting at 0.0), the downward-facing member of that same set. "usableReadFraction": ("at-least", "alerting-at"), } @@ -757,7 +755,7 @@ def usable_read_fraction( """Reads landing on a called cell, recognised against the panel, over `reads_total`. Ports Cell Ranger's `frac_feature_reads_usable` (Cell Ranger `main`, - `lib/python/cellranger/rna/report_matrix.py`, `_report_genome_agnostic_metrics`): conf-mapped, + `_report_genome_agnostic_metrics`): conf-mapped, barcoded reads restricted to the called-cell partition, over the whole library's read count. UMI validity is that source's separate `good_umi_frac` figure and is not part of this one. @@ -778,7 +776,7 @@ def usable_read_fraction( # Cell Ranger's own constants for the ANTIGEN branch of `detect_outlier_umis_bcs` -# (`lib/python/cellranger/feature/antibody/analysis.py`): a 3x interquartile multiplier over the top +# a 3x interquartile multiplier over the top # 100 barcodes by count, and a 1000-UMI floor below which nothing is flagged. AGGREGATE_BARCODE_IQR_MULTIPLIER: float = 3.0 AGGREGATE_BARCODE_MIN_THRESHOLD: float = 1000.0 @@ -793,9 +791,8 @@ def detect_aggregate_barcodes( ) -> tuple[frozenset[str], float | None]: """Barcodes whose antigen UMI count is an outlier, and the threshold that decided it. - Ports `detect_outlier_umis_bcs` (Cell Ranger `main`, - `lib/python/cellranger/feature/antibody/analysis.py`), called for the ANTIGEN library type from - `cell_calling_helpers.py::remove_antibody_antigen_aggregates`. + Ports Cell Ranger's `detect_outlier_umis_bcs`, which it calls for the ANTIGEN library + type while removing antibody and antigen aggregates. `per_barcode` has one row per observed barcode, columns `barcode` and `umiCount` -- the whole whitelist-corrected barcode universe, not the cell list. q1 and q3 are taken over the top `top_n` diff --git a/software/per-cell-metrics/src/qc_report.py b/software/per-cell-metrics/src/qc_report.py index 9d5b58d..ae69177 100644 --- a/software/per-cell-metrics/src/qc_report.py +++ b/software/per-cell-metrics/src/qc_report.py @@ -145,7 +145,7 @@ def main() -> None: features = int(stat[args.feature_col].n_unique()) total_umis = int(stat[args.umi_col].sum()) per_cell = stat.group_by(args.cell_col).agg(pl.col(args.umi_col).sum().alias("u")) - # Blank, never 0.0. `qc-status-and-rollup` renders a measurement the run could not supply the + # Blank, never 0.0. A measurement the run could not supply the # inputs for as its reason rather than as a number, and a blank and a zero are opposite # findings: a sample whose reads never arrived would otherwise sit beside its neighbours # reading a median of nothing, which is a library that failed rather than a library missing. diff --git a/software/per-cell-metrics/src/qc_rows.py b/software/per-cell-metrics/src/qc_rows.py index 76618b9..3de37e9 100644 --- a/software/per-cell-metrics/src/qc_rows.py +++ b/software/per-cell-metrics/src/qc_rows.py @@ -460,18 +460,20 @@ def sample_summary_rows( Pivots `sample_report` -- the same dict `main` writes to `result_qc_by_sample.json` -- rather than walking `MEASUREMENTS` a second time, so this table and a sample's own report cannot disagree. - `status` is `sample_report`'s own rollup, from `roll_up`, never recomputed here. + + NO rollup column. `roll_up`'s result travels in `result_qc_by_sample.json` and reaches a reader as + the Main grid's Quality tag and the heading of a sample's own Quality Checks tab. A third copy here + said nothing those two had not already said, and it needed its own cell renderer to say it. Every id in `samples` gets a row, a sample absent from `sample_report` included: its measurement - columns and its status come back null, which reads as nothing having rolled up rather than as a - passing sample. + columns come back null, which reads as nothing having been computed rather than as a passing sample. """ built = [] for sample in samples: report = sample_report.get(sample, {}) entries = {e["id"]: e["value"] for e in report.get("measurements", [])} qc = read_qc.get(sample, {}) - row = {"sampleId": sample, "status": report.get("status")} + row = {"sampleId": sample} for col in _MITOOL_ONLY_COLUMNS: row[col] = _number(qc, col) for m in _SAMPLE_MEASUREMENTS: @@ -479,7 +481,6 @@ def sample_summary_rows( built.append(row) schema = { "sampleId": pl.String, - "status": pl.String, **{col: pl.Float64 for col in _MITOOL_ONLY_COLUMNS}, **{m.id: pl.Float64 for m in _SAMPLE_MEASUREMENTS}, } diff --git a/software/per-cell-metrics/src/tag_distribution.py b/software/per-cell-metrics/src/tag_distribution.py index 8faa6be..cbb6822 100644 --- a/software/per-cell-metrics/src/tag_distribution.py +++ b/software/per-cell-metrics/src/tag_distribution.py @@ -21,8 +21,7 @@ so, and both it and this module decide afterwards which is which, by median. So the starting point does not change what the components mean; it changes which answer the EM settles on. On a mostly-background population that is the difference between a background weight near 0.8 and one near 0.95. The starting -split is the paper's, `DEFAULT_INITIAL_SIGNAL_WEIGHT` above. It used to be the median, which is a choice -`what-plays-the-baseline` never actually specified. +split is the paper's, `DEFAULT_INITIAL_SIGNAL_WEIGHT` above. **No normalization.** The paper normalizes by each cell's UMI total. Every reading here is a raw integer UMI count, and a normalized comparator would be the only non-count in the pipeline. The @@ -303,7 +302,7 @@ def _fit_two_component_nb(counts: np.ndarray, initial_signal_weight: float | Non def _signal_component(means: np.ndarray, sizes: np.ndarray) -> int: """Which component is the signal one: the higher MEDIAN, the mean breaking a tie. - `what-plays-the-baseline` fixes the rule as the higher-median component, and a negative binomial's + The published rule is the higher-median component, and a negative binomial's median is not ordered by its mean. The median depends on the size as much as the mean: at mean 50 and size 0.05 it is 0, at mean 5 and size 1e6 it is 5. Sizes are re-estimated per component from that component's own variance every round, so the two orderings are free to disagree -- and an @@ -352,9 +351,9 @@ def fit_tag_probabilities( The count of cells, not of readings, is what the 300 gates. `scored` is the reading each cell is turned into a state on, defaulting to `counts`. The two differ - by the minimum: `what-plays-the-baseline` fits this rung "on the raw counts", while - `minimum-count-before-any-reference` decides a count below the minimum per cell and per tag, on the - raw count, "before anything is read against a baseline". A count the minimum zeroed is therefore in + by the minimum: this rung is fitted on the RAW counts, while the minimum decides a count per cell + and per tag on the raw count, before anything is read against a baseline. A count the minimum + zeroed is therefore in the population the background is estimated from and is read as the zero it became. Both arrays are one entry per cell, in the same order. diff --git a/software/per-cell-metrics/src/verdict.py b/software/per-cell-metrics/src/verdict.py index 628aaa7..5ad0c3b 100644 --- a/software/per-cell-metrics/src/verdict.py +++ b/software/per-cell-metrics/src/verdict.py @@ -237,7 +237,7 @@ def reference_by_cell( ) if served is ReferenceChoice.DECLARED: - # Several declared references combine by taking the HIGHEST, which is `baseline-scope`'s rule + # Several declared references combine by taking the HIGHEST, which is the rule # for replicates within one group. This block has no scope construct, so no declared property # separates any two references, so the whole panel is one group and every declared reference in # it is a replicate -- the case that atom settles rather than the cross-group case it forbids. @@ -285,7 +285,7 @@ def gate_cells( A sticky cell left in returns as a confident *not bound*, the collapse the four-state model prevents, so the exposure is surfaced either way. - Strictly above the threshold, never at it. `reference-two-roles` says a cell *above* the + Strictly above the threshold, never at it. The rule sets aside a cell *above* the threshold is set aside and that a cell is set aside where a reading *exceeds* it, which is the same direction the minimum takes from the other side -- a count of four survives a minimum of four. diff --git a/software/per-cell-metrics/test/test_emit_verdicts.py b/software/per-cell-metrics/test/test_emit_verdicts.py index 7180388..57ea815 100644 --- a/software/per-cell-metrics/test/test_emit_verdicts.py +++ b/software/per-cell-metrics/test/test_emit_verdicts.py @@ -1274,7 +1274,7 @@ def test_one_antigen_on_two_barcodes_is_read_by_its_highest_member(wide_bed): def test_two_declared_comparators_serve_together(wide_bed): - # A panel declaring two undifferentiated comparators runs. `baseline-scope` makes them replicates of + # A panel declaring two undifferentiated comparators runs. They are replicates of # one group, since nothing declared separates them, and replicates combine by taking the highest. # It used to be refused, which sent the scientist back to edit a panel file over a case the corpus # had already decided. @@ -2494,7 +2494,7 @@ def _distribution_bed_with_a_baseline_tag(root, n_cells=400, sticky=40, seed=7): """The distribution bed, plus a declared baseline tag reading high in a few cells. The rung's comparator is the fit. The baseline tag is here only in its other role, which is what - `reference-two-roles` keeps apart. Its readings sit either side of the gate used below, and the + the two roles are kept apart. Its readings sit either side of the gate used below, and the minimum never touches a baseline tag, so the low ones survive as the measurement they are. """ _distribution_bed(root, n_cells=n_cells, seed=seed) @@ -3544,10 +3544,9 @@ def test_the_wide_summary_carries_every_sample_in_the_roster_including_one_with_ s2 = summary.filter(pl.col("sampleId") == "S2") assert s2.height == 1 - # Nothing computed a value for S2, so its cells read null rather than 0 or "OK" -- a blank and a zero - # are opposite findings. + # Nothing computed a value for S2, so its cells read null rather than 0 -- a blank and a zero are + # opposite findings. assert s2["readsTotal"].item() is None - assert s2["status"].item() is None def test_the_wide_summary_carries_every_sample_level_measurement_as_a_column(bed): @@ -3562,19 +3561,25 @@ def test_the_wide_summary_carries_every_sample_level_measurement_as_a_column(bed assert "cellBarcodeValidFraction" in summary.columns -def test_the_wide_summary_status_is_the_sample_rollup_and_is_not_recomputed(bed): +def test_the_wide_summary_carries_no_rollup_column(bed): + """The rollup lives in one place, and this table is not it. + + `roll_up`'s result travels in `result_qc_by_sample.json` and reaches a reader as the Main grid's + Quality tag and as the heading of a sample's own Quality Checks tab. It used to be copied here as + well, and the test that stood here pinned the two copies against each other -- because two copies of + a status can disagree, and the one a reader happens to be looking at decides what they believe. + + One copy cannot disagree with itself. What needs pinning now is that a second one does not come + back: this table is measurements, and a status column here would need its own cell renderer and its + own agreement test all over again. + """ _run(bed, *BASE) summary = pl.read_csv(bed / "result_qc_summary.csv") + assert "status" not in summary.columns + # The rollup is still computed and still reported -- just not from here. by_sample = json.loads((bed / "result_qc_by_sample.json").read_text()) - - for sample_id, report in by_sample.items(): - row = summary.filter(pl.col("sampleId") == sample_id) - assert row.height == 1 - got = row["status"].item() - assert got == report["status"], ( - f"wide table status {got!r} for {sample_id!r} disagrees with the sample's own report " - f"{report['status']!r}; the two must read off one rollup" - ) + assert by_sample, "the bed produced no per-sample report" + assert all("status" in report for report in by_sample.values()) def test_a_missing_read_qc_row_names_the_row_not_the_denominator(bed): diff --git a/software/per-cell-metrics/test/test_qc_measures.py b/software/per-cell-metrics/test/test_qc_measures.py index 044280b..df47cf9 100644 --- a/software/per-cell-metrics/test/test_qc_measures.py +++ b/software/per-cell-metrics/test/test_qc_measures.py @@ -241,7 +241,7 @@ def test_usable_read_fraction_line_and_comparison(): def test_usable_read_fraction_status_boundaries(): assert status_for("usableReadFraction", 0.19, DEFAULT_LINES) is Status.WARN assert status_for("usableReadFraction", 0.21, DEFAULT_LINES) is Status.OK - # Error is `alerting-at` total failure (0.0), not a further step past warn. + # Error is alerting AT total failure (0.0), not a further step past warn. assert status_for("usableReadFraction", 0.0, DEFAULT_LINES) is Status.ALERT @@ -403,9 +403,8 @@ def test_reads_per_cell_empty_cell_list_does_not_divide_by_zero(): # --- aggregate-barcode detection -------------------------------------------- # -# Ports `detect_outlier_umis_bcs` from Cell Ranger `main`, -# `lib/python/cellranger/feature/antibody/analysis.py`, called for the ANTIGEN library type from -# `cell_calling_helpers.py::remove_antibody_antigen_aggregates`. +# Ports Cell Ranger's `detect_outlier_umis_bcs`, which it calls for the ANTIGEN library +# type while removing antibody and antigen aggregates. def _per_barcode(umi: list[int], read: list[int] | None = None, barcode: list[str] | None = None) -> pl.DataFrame: @@ -511,7 +510,7 @@ def test_aggregate_barcode_fraction_line_and_comparison(): def test_aggregate_barcode_fraction_status_boundaries(): assert status_for("aggregateBarcodeFraction", 0.04, DEFAULT_LINES) is Status.OK assert status_for("aggregateBarcodeFraction", 0.06, DEFAULT_LINES) is Status.WARN - # Error is `alerting-at`, total failure, not a further step past warn: 0.99 still warns. + # Error is alerting at total failure, not a further step past warn: 0.99 still warns. assert status_for("aggregateBarcodeFraction", 0.99, DEFAULT_LINES) is Status.WARN assert status_for("aggregateBarcodeFraction", 1.0, DEFAULT_LINES) is Status.ALERT @@ -611,7 +610,7 @@ def test_the_undeclared_barcode_line_is_per_barcode_and_operator_set(): def test_the_undeclared_barcode_line_alerts_above_its_error_not_only_at_it(): - # Both ends face the same way, unlike the four inherited shares. `alerting-at` on the error end + # Both ends face the same way, unlike the four inherited shares. Alerting at failure on the error end # would fire only at exactly 0.05 and let every larger share read warn -- the worse finding being # the one that never showed. assert _COMPARISON["undeclaredBarcodeShare"] == ("at-most", "at-most") diff --git a/software/per-cell-metrics/test/test_tag_distribution.py b/software/per-cell-metrics/test/test_tag_distribution.py index 978e67e..bc6a882 100644 --- a/software/per-cell-metrics/test/test_tag_distribution.py +++ b/software/per-cell-metrics/test/test_tag_distribution.py @@ -136,7 +136,7 @@ def test_an_even_split_is_handled(): def test_the_signal_component_is_the_higher_median_one_even_where_the_means_disagree(): """The labelling rule itself, on parameters rather than on a fitted bed. - `what-plays-the-baseline` fixes the rule as the higher-median component. A negative binomial's + The published rule is the higher-median component. A negative binomial's median is not ordered by its mean -- the median depends on the size too -- so the two orderings can disagree, and labelling by mean inverts every call for the tag: cells reading nothing score high and cells reading a lot score low. That was a shipped bug, fixed by labelling on the median. @@ -258,7 +258,7 @@ def test_a_tag_nothing_bound_still_fits_and_now_calls_no_cell_bound(): The method assumes two components exist, so it splits a single population and calls its upper slice signal. That much is unchanged, and the pin below is unchanged with it: the fit must return probabilities rather than refuse, because rejecting here would be a separation test of our own - invention and `what-plays-the-baseline` declines to build one. + invention, and the method this rung implements has no such test. What changed is WHERE the split lands. This rung now starts the EM from the source paper's own pivot rather than from the median, and from that start a single background population no longer diff --git a/software/per-cell-metrics/test/test_verdict.py b/software/per-cell-metrics/test/test_verdict.py index d37ebd1..99d76f4 100644 --- a/software/per-cell-metrics/test/test_verdict.py +++ b/software/per-cell-metrics/test/test_verdict.py @@ -167,7 +167,7 @@ def test_empty_droplets_is_not_offered(): def test_several_reference_tags_combine_by_the_highest(): - # `baseline-scope` combines replicates WITHIN one group by taking the highest, and forbids combining + # Replicates WITHIN one group combine by taking the highest, and combining is forbidden # ACROSS groups. This block has no scope construct, so no declared property separates any two # references and the whole panel is one group -- the case the atom settles, not the one it forbids. # Taking the highest is also what stops a dead reference from making the background look cleaner @@ -244,7 +244,7 @@ def test_panel_source_refuses_one_below_the_minimum(): def test_the_gate_boundary_excludes_the_line_itself(): - # `reference-two-roles` sets aside a cell ABOVE the threshold, and where a reading EXCEEDS it. So a + # The rule sets aside a cell ABOVE the threshold, and where a reading EXCEEDS it. So a # reading exactly at the line stays in and answers, the same direction the floor takes from the # other side -- a count of four survives a minimum of four. Both sides are pinned, so changing the # comparison is a deliberate act. @@ -425,7 +425,7 @@ def _fitted(rows, probabilities): def test_the_population_rung_binds_at_its_line_and_not_above_it(): - # `what-plays-the-baseline` reads a cell bound "at 0.9 or above", so the line itself is inside. The + # The published rule reads a cell bound AT 0.9 or above, so the line itself is inside. The # declared rung has the same test at its cutoff; this rung had none, so `>=` and `>` read alike. at_line = _fitted([("S1", "c1", "A", 7)], {("S1", "c1", "A"): DISTRIBUTION_BOUND_PROBABILITY}) assert at_line["state"].to_list() == [State.BOUND.value] diff --git a/ui/src/pages/AntigenQcPage.vue b/ui/src/pages/AntigenQcPage.vue index a17d1cd..a80fdff 100644 --- a/ui/src/pages/AntigenQcPage.vue +++ b/ui/src/pages/AntigenQcPage.vue @@ -284,13 +284,13 @@ watch(