From 0f2fcb21f4affd1ee8342843102db2e692801e96 Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Mon, 31 Aug 2026 15:34:47 +0200 Subject: [PATCH 1/9] MILAB-6496: count-distribution bins sit on whole numbers `count_bin_edges` built its edges with `np.geomspace`, which places them between whole numbers. A UMI count is a whole number, so a bin could fall strictly between two counts and stand empty at every weight a run could produce. `np.geomspace(1, 5155, 25)` puts one at [2.039, 2.911); on a 15-tag run it held nothing on all 15 panels and read as a missing bar. Edges are now whole and strictly increasing, so every bin holds at least one count. The step is `max(previous + 1, geometric)`, which draws one count per bar near a count of 1 and stays geometric above that. The last edge is one past the top count, making every bin half-open rather than closing the last one and giving it a count more than its width. A run now takes at most COUNT_BIN_COUNT bins instead of always that many. Nothing reads the count: `bin_values`, `per_tag_count_bins` and the chart all take it from the edge list. --- software/per-cell-metrics/src/qc_measures.py | 51 ++++++++++++++----- .../per-cell-metrics/test/test_qc_measures.py | 45 ++++++++++++++-- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/software/per-cell-metrics/src/qc_measures.py b/software/per-cell-metrics/src/qc_measures.py index 989ed35..912ac42 100644 --- a/software/per-cell-metrics/src/qc_measures.py +++ b/software/per-cell-metrics/src/qc_measures.py @@ -812,36 +812,61 @@ def deciles_of(values: np.ndarray) -> pl.DataFrame: ) -# How many log-spaced buckets a count distribution is drawn in. Enough bars for two humps to read -# apart at thumbnail size, few enough that a sparse tag does not dissolve into single-cell spikes. +# The MOST buckets a count distribution is drawn in. `count_bin_edges` returns fewer where the run's +# range cannot fill them at integer width. Enough bars for two humps to read apart at thumbnail size, +# few enough that a sparse tag does not dissolve into single-cell spikes. COUNT_BIN_COUNT = 24 def count_bin_edges(counts: pl.DataFrame) -> list[float]: - """Log-spaced bin edges spanning every count in the frame, shared by every plot drawn from it. + """INTEGER bin edges spanning every count in the frame, shared by every plot drawn from it. The caller passes the counts of the CELL LIST where one arrived, so the domain ends at the highest count among cells. Observed barcodes outnumber cells by one to two orders of magnitude. - ONE edge set for the whole run, not one per tag. A reader judges whether a tag's counts fall into - two separated humps by scanning a grid of tags side by side, and per-tag edges would rescale - every panel to its own range, so a tag whose counts span 1-4 and one spanning 1-4000 would draw - identical pictures. + ONE edge set for the whole run, not one per tag: a reader compares a grid of tags side by side, + and per-tag edges would draw a tag spanning 1-4 and one spanning 1-4000 alike. - Log-spaced because UMI counts per cell span orders of magnitude: on a linear axis the ambient - population occupies one bar and everything above it is empty. + Four invariants, and every one of them is load-bearing: + + - **Edges are whole numbers.** A UMI count is a whole number. Geometric edges are not, so a bin + can fall strictly between two counts and stand empty at every weight the run could produce. + `np.geomspace(1, 5155, 25)` puts one at [2.039, 2.911), which held nothing on all 15 tags of a + real run and read as a missing bar. + - **Strictly increasing, so every bin holds at least one whole number.** Rounding alone does not + give this: below a step of 1 the geometric ideal repeats an edge. + - **Bins are half-open `[a, b)`, and the last edge is `top + 1`.** A bin holds `b - a` counts, + including the top one. An edge at `top` would instead close the last bin and give it one count + more than its width. + - **Unit width at the low end, geometric above it.** The step is `max(previous + 1, geometric)`, + so the ambient population near 1 draws one count per bar. Above the crossover a bar covers + several counts and its height carries that width -- the shape is weight, never density. + + At most `COUNT_BIN_COUNT` bins, and fewer where the range cannot fill them. Nothing reads the + count: `bin_values`, `per_tag_count_bins` and the UI all take it from `len(edges) - 1`. Edges start at 1, the smallest count that exists -- a row is only written for an observed reading, so zero never appears. `[]` where the frame holds no counts at all. """ if counts.height == 0: return [] - top = float(counts["umiCount"].max() or 0) + top = int(counts["umiCount"].max() or 0) if top < 1: return [] - # `top` lands on the last edge, so the largest count falls inside the last bin rather than - # outside every bin. - return [float(x) for x in np.geomspace(1.0, max(top, 2.0), COUNT_BIN_COUNT + 1)] + # Unit bins already reach the top, so a geometric step would only throw resolution away. + if top <= COUNT_BIN_COUNT: + return [float(x) for x in range(1, top + 2)] + # `top + 1` is the last edge, so the ratio is taken against it rather than against `top`. + ratio = (top + 1) ** (1.0 / COUNT_BIN_COUNT) + edges = [1] + while len(edges) < COUNT_BIN_COUNT: + nxt = max(edges[-1] + 1, round(edges[-1] * ratio)) + # `>=`, not `>`: an edge at `top` would leave the last bin holding only the top count. + if nxt >= top: + break + edges.append(nxt) + edges.append(top + 1) + return [float(x) for x in edges] def linear_bin_edges(values: np.ndarray, count: int = COUNT_BIN_COUNT) -> list[float]: diff --git a/software/per-cell-metrics/test/test_qc_measures.py b/software/per-cell-metrics/test/test_qc_measures.py index 546e3eb..0d1706e 100644 --- a/software/per-cell-metrics/test/test_qc_measures.py +++ b/software/per-cell-metrics/test/test_qc_measures.py @@ -5,6 +5,7 @@ import pytest from qc_measures import ( _COMPARISON, + COUNT_BIN_COUNT, DEFAULT_LINES, LINE_ROUTES, MEASUREMENTS, @@ -1060,9 +1061,47 @@ def test_one_edge_set_spans_the_whole_run(): counts = _bin_counts([("S1", "c1", "AAAA", 2), ("S1", "c2", "BBBB", 4000)]) edges = count_bin_edges(counts) assert edges[0] == 1.0 - assert edges[-1] == 4000.0 - # Log-spaced, so the ambient population does not collapse into one bar. - assert edges[2] - edges[1] > edges[1] - edges[0] + # One past the top count, so the last bin is half-open like every other one. + assert edges[-1] == 4001.0 + # Widening, so the ambient population does not collapse into one bar. + assert edges[-1] - edges[-2] > edges[1] - edges[0] + + +def test_every_edge_is_a_whole_number(): + # A UMI count is a whole number. A fractional edge can put a bin strictly between two counts, and + # that bin then stands empty at every weight the run could produce. + counts = _bin_counts([("S1", f"c{i}", "AAAA", n) for i, n in enumerate([1, 2, 3, 40, 5155])]) + edges = count_bin_edges(counts) + assert all(edge == float(int(edge)) for edge in edges) + + +def test_no_bin_is_empty_by_construction(): + # The defect this replaced: geomspace(1, 5155, 25) puts a bin at [2.039, 2.911), which holds no + # count at all. On a real 15-tag run it read as a missing bar on every panel. + for top in (2, 5, 24, 25, 30, 100, 5155, 23466): + edges = count_bin_edges(_bin_counts([("S1", "c1", "AAAA", top)])) + widths = [int(edges[i + 1]) - int(edges[i]) for i in range(len(edges) - 1)] + assert min(widths) >= 1, top + assert len(edges) == len(set(edges)), top + + +def test_the_low_end_draws_one_count_per_bar(): + # Where the geometric step falls below 1 the step is forced to 1 instead. Without it the first + # bars carry two counts each while their neighbours carry one, and the difference reads as a hump. + edges = count_bin_edges(_bin_counts([("S1", "c1", "AAAA", 5155)])) + assert edges[:4] == [1.0, 2.0, 3.0, 4.0] + + +def test_a_run_that_fits_in_unit_bins_gets_them(): + # Below the bin budget a geometric step only throws resolution away. + edges = count_bin_edges(_bin_counts([("S1", "c1", "AAAA", 5)])) + assert edges == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + + +def test_the_bin_budget_is_a_ceiling(): + for top in (1, 5, 24, 25, 5155, 23466, 200000): + edges = count_bin_edges(_bin_counts([("S1", "c1", "AAAA", top)])) + assert 1 <= len(edges) - 1 <= COUNT_BIN_COUNT, top def test_a_frame_with_no_counts_has_no_edges_and_no_bins(): From 70f728b4f6de6bd1bf90f76e134a168210252960 Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Mon, 31 Aug 2026 15:34:57 +0200 Subject: [PATCH 2/9] MILAB-6496: the fitted background draws density, not weight Whole-number edges stop a bin standing empty, but they do not make bar heights comparable. Bin width in counts rises across the edge set -- one real set spans 1, 1, 2, 3, 4, 6, 9, 14, 21 -- so a bin covering 4 counts stood about four times a neighbour covering 1 at equal density. That step drew a second hump on tags whose counts hold one population, and the grid exists to answer whether two populations separated at all, so a hump the bins invented is the one error this surface cannot carry. `CountHistogram` takes a `density` flag, applied in the `log-bins` branch only, which divides each weight by the whole counts its bin spans. The y axis reads "Cells per count". The two linear callers, the score spread and the reference reading, are untouched. Atom 330 binds the x axis -- "the unit the gate is declared in follows from the axis" -- and only on the plots a scientist declares from. This grid informs nothing settable, so its y axis carries no such constraint. `PlChartHistogram` prints the number it is handed under a fixed `count:` label, so a hovered bar on this grid now reports the density. Each panel's caption carries the cell count instead. --- .changeset/integer-count-bin-edges.md | 27 +++++++++++++++ ui/src/components/CountHistogram.vue | 29 ++++++++++++---- ui/src/components/FittedBackgroundGrid.vue | 39 +++++++++++++++++----- 3 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 .changeset/integer-count-bin-edges.md diff --git a/.changeset/integer-count-bin-edges.md b/.changeset/integer-count-bin-edges.md new file mode 100644 index 0000000..239ac95 --- /dev/null +++ b/.changeset/integer-count-bin-edges.md @@ -0,0 +1,27 @@ +--- +'@platforma-open/milaboratories.feature-integration.per-cell-metrics': patch +'@platforma-open/milaboratories.feature-integration.ui': patch +--- + +The fitted background draws the distribution the run holds + +Two defects, one picture. The fitted-background grid is the only way to see whether a tag's counts +separated into two populations, so a hump it invents is the one error this surface cannot carry. + +**Bins now sit on whole numbers.** `np.geomspace` puts edges between whole numbers, and a UMI count is +a whole number, so a bin could fall strictly between two counts and stand empty at every weight a run +could produce. On a 15-tag run topping out at 5,155 counts the bin at [2.039, 2.911) held nothing on +all 15 panels and read as a missing bar. Edges are now whole and strictly increasing, so every bin +holds at least one count. The low end steps by 1. Above that the step is geometric as before. The last +edge is one past the top count, which makes every bin half-open. A run now takes at most 24 bins +instead of always 24. + +**Bars are now density, cells per count.** Bin width in counts rises across the edge set — 1, 1, 2, 3, +4, 6, 9, 14, 21 on one real run — so a bin spanning 4 counts stood about four times a neighbour +spanning 1 at equal density. That step read as a second hump on tags whose counts only decay. Dividing +each weight by the counts its bin spans removes it. The y axis is labelled "Cells per count", and each +panel's caption carries the cell count the plot is drawn from. + +The hover readout on that grid now reports the density rather than the cell count, because +`PlChartHistogram` prints the number it is handed under a fixed `count:` label. The caption carries the +magnitude instead. diff --git a/ui/src/components/CountHistogram.vue b/ui/src/components/CountHistogram.vue index c96e8a3..84649b6 100644 --- a/ui/src/components/CountHistogram.vue +++ b/ui/src/components/CountHistogram.vue @@ -30,6 +30,20 @@ const props = defineProps<{ title?: string; /** Drawn as a marker. Undefined draws none, which is the statement that no gate is declared. */ threshold?: number; + /** + * Divides each bin's weight by the number of whole counts it spans, so bar height is cells per count + * rather than cells. `log-bins` only. + * + * Bin width in counts is not constant: `count_bin_edges` steps by 1 near a count of 1 and + * geometrically above it, so one real edge set spans 1, 1, 2, 3, 4, 6, 9, 14, 21 counts. Drawn as + * weight, a bin spanning 4 counts stands about four times a neighbour spanning 1 at equal density, + * and that step reads as a hump the data does not hold. + * + * COSTS THE HOVER READOUT. `PlChartHistogram` prints the weight it was handed and labels it `count:`, + * with no way to supply either, so a hovered bar reports the density. Callers that need the magnitude + * put it beside the plot. + */ + density?: boolean; /** * Zeroes every margin, which drops the axes, the axis labels and the title. Without it the fixed 85px * left and 40px bottom margins take most of a small panel, leaving a plot narrower than its own axis @@ -76,7 +90,7 @@ const settings = computed(() => { ...(threshold.value === undefined ? {} : { threshold: threshold.value }), ...(props.title === undefined ? {} : { title: props.title }), xAxisLabel: props.xAxisLabel ?? "Counts per cell", - yAxisLabel: props.yAxisLabel ?? "Cells", + yAxisLabel: props.yAxisLabel ?? (props.density ? "Cells per count" : "Cells"), totalWidth: width.value, totalHeight: props.totalHeight, compact: props.compact, @@ -95,11 +109,14 @@ const settings = computed(() => { ...common, type: "log-bins" as const, // A bin's own bounds travel with its weight, since this form bins nothing itself. - bins: props.weights.map((weight, i) => ({ - from: props.edges[i]!, - to: props.edges[i + 1]!, - weight, - })), + bins: props.weights.map((weight, i) => { + const from = props.edges[i]!; + const to = props.edges[i + 1]!; + // `count_bin_edges` returns whole, strictly increasing numbers, so the span is at least 1. The + // guard holds for a caller that sets `density` against edges from somewhere else. + const span = Math.max(to - from, 1); + return { from, to, weight: props.density === true ? weight / span : weight }; + }), }; }); diff --git a/ui/src/components/FittedBackgroundGrid.vue b/ui/src/components/FittedBackgroundGrid.vue index c08bcd8..3b17105 100644 --- a/ui/src/components/FittedBackgroundGrid.vue +++ b/ui/src/components/FittedBackgroundGrid.vue @@ -16,9 +16,17 @@ import CountHistogram from "./CountHistogram.vue"; // No marker is drawn. The threshold slot means "the declared gate" on the reference-reading plot and "the // bound cutoff" on the scores plot, so a third meaning here would make one marker say three things. // -// A panel carries its title, its plot, and the fit's own three numbers. No separated / does-not-separate -// label: no criterion for it exists. This panel is the substitute for the check nobody has built, so -// withholding the fit leaves the rung with no safeguard at all. +// A panel carries its title, its plot, its cell count, and the fit's own three numbers. No separated / +// does-not-separate label: no criterion for it exists. This panel is the substitute for the check nobody +// has built, so withholding the fit leaves the rung with no safeguard at all. +// +// Bars are DENSITY, cells per count. Bin width in counts rises across the edge set, so weight makes a +// wide bin stand above a narrow one at equal density and puts a hump where the data holds none. The +// panel's own question is whether two humps stand apart, so a hump the bins invented is the one error +// this surface cannot carry. +// +// Density costs the hover readout, which reports the number the chart was handed. The cell count sits in +// the caption instead. const props = defineProps<{ bins: TagCountBins; /** Sample id -> the label a reader knows it by. A sample with no label renders as its own id. */ @@ -66,6 +74,10 @@ const panels = computed(() => { // nothing. Three significant figures reads the same at 0.33 and at 930. const fmt = (value: number) => Number(value.toPrecision(3)).toLocaleString(); +// Cells holding any count of this tag in this sample. The bins are taken over the cell list, so the sum +// is that population and nothing wider. +const cellsIn = (panel: Panel) => panel.weights.reduce((total, weight) => total + weight, 0); + const enlarged = ref(undefined); const isOpen = computed({ get: () => enlarged.value !== undefined, @@ -93,19 +105,28 @@ const isOpen = computed({ - - - bg {{ fmt(panel.fit.backgroundMean) }} · signal {{ fmt(panel.fit.signalMean) }} · - {{ (panel.fit.backgroundWeight * 100).toFixed(0) }}% of cells background + + + {{ cellsIn(panel).toLocaleString() }} cells + + - no fit for this pair From b0818af4ff192993ea5b023994be76046b2f5a0c Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Mon, 31 Aug 2026 15:45:43 +0200 Subject: [PATCH 3/9] MILAB-6496: Run quality hides its tab strip while the run computes VIEW_TABS is derived from the rung the run reports, and that rung is unreported until the run settles. A strip drawn mid-run therefore offered every plot and then dropped the ones the served rung cannot draw, so a reader could open a tab that stopped existing under them. `isRunning` is the block's own computing signal, the one already driving the block spinner. The open view's body is untouched and keeps drawing its own processing placeholder, so the section still shows progress while the strip is away. --- .../run-quality-tabs-hidden-while-running.md | 14 ++++++++++++++ ui/src/pages/AntigenQcPage.vue | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .changeset/run-quality-tabs-hidden-while-running.md diff --git a/.changeset/run-quality-tabs-hidden-while-running.md b/.changeset/run-quality-tabs-hidden-while-running.md new file mode 100644 index 0000000..b63c8a8 --- /dev/null +++ b/.changeset/run-quality-tabs-hidden-while-running.md @@ -0,0 +1,14 @@ +--- +'@platforma-open/milaboratories.feature-integration.ui': patch +--- + +Run quality hides its tab strip while the run computes + +The tab set is derived from the baseline rung the run reports. Until the run +settles that rung is unknown, so the strip offered every plot and then dropped +the ones the served rung cannot draw — a reader could open a tab that then +stopped existing. + +The strip is now hidden while the block computes. The open view's body keeps +rendering and draws its own processing placeholder, so the page still shows the +run's progress. diff --git a/ui/src/pages/AntigenQcPage.vue b/ui/src/pages/AntigenQcPage.vue index 7ecff06..8d6bcdf 100644 --- a/ui/src/pages/AntigenQcPage.vue +++ b/ui/src/pages/AntigenQcPage.vue @@ -224,6 +224,11 @@ const VIEW_TABS = computed(() => [ const activeView = ref("reagents"); +// VIEW_TABS is derived from the rung, and the rung is unreported until the run settles, so a strip drawn +// mid-run offers every plot and then drops the ones that rung cannot draw. It stays hidden until then. The +// open view's body keeps rendering and draws its own processing placeholder. +const isRunning = computed(() => app.model.outputs.isRunning === true); + // The open tab can stop existing: the run reports its rung, and the plot that tab held cannot be drawn. // Falling back keeps the page showing something rather than an empty body under a tab strip that no longer // offers the tab. Watching an output and writing a LOCAL ref is not a hairpin: nothing here reaches @@ -267,7 +272,7 @@ const tagBins = computed(() => app.model.outputs.tagCountBins); - + + - - {{ cellsIn(panel).toLocaleString() }} cells - - - + {{ boundLine(panel.fit) }} @@ -197,4 +255,16 @@ const isOpen = computed({ color: var(--color-txt-03); font-variant-numeric: tabular-nums; } + +/* The enlarged panel's readout: one statement per line, because these are read rather than scanned and + three facts on one line ran together. Tabular figures so the two means line up under each other. */ +.enlargedFit { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; + font-size: 13px; + color: var(--color-txt-02); + font-variant-numeric: tabular-nums; +} diff --git a/ui/src/pages/AntigenQcPage.vue b/ui/src/pages/AntigenQcPage.vue index 8d6bcdf..a17d1cd 100644 --- a/ui/src/pages/AntigenQcPage.vue +++ b/ui/src/pages/AntigenQcPage.vue @@ -8,7 +8,9 @@ import { PlAgDataTableV2, PlAlert, PlBlockPage, + PlDropdown, PlPlaceholder, + PlRow, PlTabs, usePlDataTableSettingsV2, useWatchFetch, @@ -251,6 +253,29 @@ const referenceSpread = computed(() => tagBins.value?.spreads?.referenceReading) // because it asks one question and offers no axes to pick. const tagBins = computed(() => app.model.outputs.tagCountBins); +const backgroundSample = ref(undefined); + +const backgroundSampleOptions = computed(() => { + const labels = app.model.outputs.sampleLabels ?? {}; + return Object.keys(tagBins.value?.bySample ?? {}) + .map((id) => ({ value: id, label: labels[id] ?? id })) + .sort((a, b) => a.label.localeCompare(b.label)); +}); + +// Keep the current selection if it still exists, otherwise fall back to the first sample. A re-run can +// drop the sample that was on screen, and an empty selector next to a full grid looks broken. +// +// The selector shows even when there is only one sample, because the panel titles no longer name it. +watch( + backgroundSampleOptions, + (options) => { + if (!options.some((o) => o.value === backgroundSample.value)) { + backgroundSample.value = options[0]?.value; + } + }, + { immediate: true }, +); + // Status is rendered as the plain string the workflow emitted, with the discrete filter its spec declares. // It stays plain text because of the fourth case a tag cannot render: a measurement with no line behind it // leaves this column empty, and an empty cell beside three tags reads as a tag that failed to load. Which @@ -368,11 +393,24 @@ const tagBins = computed(() => app.model.outputs.tagCountBins); No binned count distributions have arrived from this run yet. They are taken by the same verdict stage as the measurements, so they arrive with them. - + diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 072aac3..15ef437 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -35,6 +35,7 @@ import { AGGREGATE_DETECTION_DEFAULTS, groupingColumns, QC_LINE_DEFAULTS, + VERDICT_DEFAULTS, } from "@platforma-open/milaboratories.feature-integration.model"; import type { ImportFileHandle } from "@platforma-sdk/model"; import { parseTagCsvMeta } from "../csvMeta"; @@ -227,6 +228,19 @@ const allSources = computed(() => referenceSources.value?.options ?? []); // // The data keeps the share, so this is a display conversion and not a migration. Rounded on the way in, // because a percentage entered as an integer must come back as the same integer. +// 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 binderPercent = computed({ + get: () => { + const share = app.model.data.expectedBinderFraction; + return typeof share === "number" ? share * 100 : undefined; + }, + set: (percent: number | undefined) => { + app.model.data.expectedBinderFraction = typeof percent === "number" ? percent / 100 : undefined; + }, +}); + const agreementPercent = computed({ get: () => { const share = app.model.data.minAgreement; @@ -972,6 +986,54 @@ const gridOptions = { this score does not measure binding strength. + + + + + + + +