From 0f2fcb21f4affd1ee8342843102db2e692801e96 Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Mon, 31 Aug 2026 15:34:47 +0200 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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. + + + + + + + + Date: Thu, 3 Sep 2026 02:27:20 +0200 Subject: [PATCH 10/14] Minor adjustments --- pnpm-lock.yaml | 17 ++++++++++++++--- pnpm-workspace.yaml | 2 +- ui/src/components/CountHistogram.vue | 2 +- ui/src/pages/MainPage.vue | 6 +++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 167d8a2..8477d94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ catalogs: specifier: 1.81.1 version: 1.81.1 '@platforma-sdk/workflow-tengo': - specifier: 6.8.2 - version: 6.8.2 + specifier: 6.8.3 + version: 6.8.3 ag-grid-enterprise: specifier: ~34.1.2 version: 34.1.2 @@ -254,7 +254,7 @@ importers: version: 2.3.1-131-main '@platforma-sdk/workflow-tengo': specifier: 'catalog:' - version: 6.8.2 + version: 6.8.3 devDependencies: '@platforma-sdk/tengo-builder': specifier: 'catalog:' @@ -1692,6 +1692,9 @@ packages: '@platforma-sdk/workflow-tengo@6.8.2': resolution: {integrity: sha512-AHR/Y+vbyfda17A7xY2uH9TlXiBpM8242tTO6+lj8phA4/KS6mez4GLu4ZEaASlZpX6YkQsUs5LUxYQU7pXeiQ==} + '@platforma-sdk/workflow-tengo@6.8.3': + resolution: {integrity: sha512-KImCzq7v/2qgH/PIBCTipV0B/ulLxDnDOpWOO+zs56+p/B2Dg25ZevlA2LzOFVk9SWx6ilXp4ilWx3K2HbccqQ==} + '@protobuf-ts/grpc-transport@2.11.1': resolution: {integrity: sha512-l6wrcFffY+tuNnuyrNCkRM8hDIsAZVLA8Mn7PKdVyYxITosYh60qW663p9kL6TWXYuDCL3oxH8ih3vLKTDyhtg==} peerDependencies: @@ -8736,6 +8739,14 @@ snapshots: '@platforma-open/milaboratories.software-ptexter': 1.2.4 '@platforma-open/milaboratories.software-small-binaries': 2.1.1 + '@platforma-sdk/workflow-tengo@6.8.3': + dependencies: + '@milaboratories/pframes-rs-wasip2': 1.1.56 + '@milaboratories/software-pframes-conv': 2.2.9 + '@platforma-open/milaboratories.software-ptabler': 2.1.8 + '@platforma-open/milaboratories.software-ptexter': 1.2.4 + '@platforma-open/milaboratories.software-small-binaries': 2.1.1 + '@protobuf-ts/grpc-transport@2.11.1(@grpc/grpc-js@1.14.4)': dependencies: '@grpc/grpc-js': 1.14.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 02d6def..a142b97 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,7 @@ catalog: "@milaboratories/ts-builder": 1.6.2 "@milaboratories/ts-configs": 1.4.0 typescript: ~5.9.3 - "@platforma-sdk/workflow-tengo": 6.8.2 + "@platforma-sdk/workflow-tengo": 6.8.3 "@platforma-sdk/block-tools": 2.14.3 "@platforma-sdk/model": 1.81.1 "@platforma-sdk/ui-vue": 1.81.1 diff --git a/ui/src/components/CountHistogram.vue b/ui/src/components/CountHistogram.vue index 2a530ed..1ef3c9d 100644 --- a/ui/src/components/CountHistogram.vue +++ b/ui/src/components/CountHistogram.vue @@ -110,7 +110,7 @@ const settings = computed(() => { type: "log-bins" as const, // A bin's own bounds travel with its weight, since this form bins nothing itself. // Bar height is the plain cell count. Every caller's bars are the same width on screen, so the - // height already is the share and there is nothing to divide by. PADDED to the full edge set. + // height already is the share and there is nothing to divide by. PADDED to the full edge set. bins: Array.from({ length: Math.max(props.edges.length - 1, 0) }, (_, i) => ({ from: props.edges[i]!, to: props.edges[i + 1]!, diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 65b5b92..94d9117 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -997,7 +997,7 @@ const gridOptions = { :max-value="99.9" :step="5" clearable - label="Expected binder fraction (%)" + label="Expected binder %" > + + + Date: Thu, 3 Sep 2026 02:27:34 +0200 Subject: [PATCH 11/14] set publication to unstabe --- block/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/block/package.json b/block/package.json index fa5f776..d1b13e5 100644 --- a/block/package.json +++ b/block/package.json @@ -18,7 +18,7 @@ }, "scripts": { "build": "ts-builder build --target block-facade && block-tools pack", - "prepublishOnly": "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science", + "prepublishOnly": "block-tools publish --unstable -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science", "do-pack": "shx rm -f package.tgz && pnpm pack && shx mv *.tgz package.tgz", "check": "ts-builder type-check --target block-facade" }, From c11e66b7938b99f4657cb3c4d37812d5afed2f5d Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Thu, 3 Sep 2026 02:30:08 +0200 Subject: [PATCH 12/14] Changeset --- .changeset/thirty-ties-beg.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/thirty-ties-beg.md diff --git a/.changeset/thirty-ties-beg.md b/.changeset/thirty-ties-beg.md new file mode 100644 index 0000000..19dc206 --- /dev/null +++ b/.changeset/thirty-ties-beg.md @@ -0,0 +1,10 @@ +--- +"@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.test": minor +"@platforma-open/milaboratories.feature-integration.ui": minor +--- + +Algorithm adjustments and UI updates From e873489f598372c42559f6994f957273c934ccde Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Thu, 3 Sep 2026 10:31:29 +0200 Subject: [PATCH 13/14] Changeset update --- .../fitted-background-equal-width-bins.md | 43 ++++++++++++++++++ .changeset/fitted-baseline-settings.md | 44 +++++++++++++++++++ .changeset/integer-count-bin-edges.md | 27 ------------ .changeset/per-cell-tag-counts-export.md | 24 ++++++++++ .changeset/run-quality-grid-per-sample.md | 38 ++++++++++++++++ .changeset/thirty-ties-beg.md | 10 ----- 6 files changed, 149 insertions(+), 37 deletions(-) create mode 100644 .changeset/fitted-background-equal-width-bins.md create mode 100644 .changeset/fitted-baseline-settings.md delete mode 100644 .changeset/integer-count-bin-edges.md create mode 100644 .changeset/per-cell-tag-counts-export.md create mode 100644 .changeset/run-quality-grid-per-sample.md delete mode 100644 .changeset/thirty-ties-beg.md diff --git a/.changeset/fitted-background-equal-width-bins.md b/.changeset/fitted-background-equal-width-bins.md new file mode 100644 index 0000000..32a90e0 --- /dev/null +++ b/.changeset/fitted-background-equal-width-bins.md @@ -0,0 +1,43 @@ +--- +'@platforma-open/milaboratories.feature-integration.per-cell-metrics': minor +'@platforma-open/milaboratories.feature-integration.ui': minor +'@platforma-open/milaboratories.feature-integration.model': patch +--- + +The fitted background draws the distribution the run holds + +The fitted-background grid is the only way to see whether a tag's counts separated into two +populations, so a hump it invents, or one it hides, is the error this surface cannot carry. Three +things were wrong with it. + +**The zeros were missing.** The plot was binned from the sparse counts frame, which has no rows for +cells that read nothing. Those zeros are most of the background, so the plot showed one decaying hump +whatever the fit had found — the left half of the distribution was simply absent. It is now binned over +the cells the fit actually ran on, one entry per cell in the sample, and a cell that read nothing counts +as a zero. + +**Every bar is now the same width.** Bins sit at `expm1(k * 0.2)`, uniform in `log1p`, which is what +the plot's axis already is. The source paper histograms `log1p` counts at a fixed width for the same +reason. + +Before, bins were whole numbers stepping geometrically, so their widths ran from 0.301 of a decade at +`[0, 1)` down to 0.079 at `[4, 5)`. A raw count then made a wide bar stand above a narrow one holding +the same density, so each bar had to be divided by its own width — and dividing by the wrong width hid +a real signal component completely: on a mixture whose upper mode was cleanly separated at a mean of +60, that hump drew at 0.9% of the background peak. Equal widths remove the division and the error with +it. Bar height is a plain cell count again, so the hover readout reports a cell count. + +The cost of equal widths is that the edges are no longer whole numbers, and counts are — consecutive +integers sit further apart than one bin until about count 13, so the low end is a comb of separated +bars. The paper's own figures show the same gaps. `LOG1P_BIN_WIDTH` is coarser than the paper's 0.075 +for that reason: at 0.075 a real tag came back with 75 of 97 bins empty. + +**The bound line is drawn.** Under a fitted baseline the threshold is a probability, so a plot in counts +had nothing to mark it against. Each fit now resolves the count at which the run's bound probability +starts calling a cell bound, and the panel draws it. A fit that reaches no such count draws no line and +says so, rather than marking one at the bottom. + +Each (sample, tag) is binned against its own range, so the emitted weight lists have different lengths +and are shorter than the shared edge set. The plot pads them, which draws the same picture — past a +pair's own maximum every bar is empty either way. Binning inside the fit rather than returning per-cell +arrays takes the fitting step from 2.1 million numbers held to 7,764 on a 27-sample run. diff --git a/.changeset/fitted-baseline-settings.md b/.changeset/fitted-baseline-settings.md new file mode 100644 index 0000000..5059dc7 --- /dev/null +++ b/.changeset/fitted-baseline-settings.md @@ -0,0 +1,44 @@ +--- +'@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 +'@platforma-open/milaboratories.feature-integration.test': patch +'@platforma-open/milaboratories.feature-integration': minor +--- + +The fitted baseline states where it starts, and the scientist can move it + +Two settings appear under the fitted baseline, and nowhere else — neither reaches the declared or +panel rung, so neither is offered there. + +**Expected binder %.** Roughly what share of cells are expected to bind an antigen. The fit splits the +counts at the matching quantile and seeds one component from each side. It is not a threshold: the EM +re-estimates both components from there, so the split the run ends up with is an output of the fit. + +It changes answers anyway, because the EM is not globally convergent on these distributions and the +start decides which optimum it reaches. On a panel where 27% of cells really did bind, the shipped +value put the split at 953 counts; told 30%, the same fit put it at 13 — which is where the gap in that +tag's histogram actually is. The published value comes from a rare-binder regime, and the study behind +this rung never tested a positive fraction above 25%. + +The trade runs one way, so no single value is right: raising it also makes the fit readier to carve a +signal component out of a single population, so a tag that bound nothing invents more binders. Only +the scientist knows which side of that to be on, which is why it is a setting. + +**Bound probability.** How sure the fit must be before a cell counts as bound. Previously fixed at +0.9 with no way to see or move it. Now shown, with 0.9 as both the default and the lowest accepted +value — below it a cell holding none of a tag could cross the line, and the run counts those cells by +arithmetic rather than reading each one, so the two halves would disagree with nothing raised. + +**The fit now starts where the method says.** The split was taken at the median, which +`what-plays-the-baseline` never specified. A median start begins from two halves of equal size, which +is far from the truth on a mostly-background population — every tag here — and pulls the fit toward +calling much of that background signal. On a control reagent, whose counts hold one population, a +median start gives a background weight near 0.8 against 0.95 from the published split. + +That trade is not free, and the direction is recorded in the suite: on a background whose long tail +puts its mean above the binders', the published start decomposes the counts into the bulk and the +tail rather than into background and binders, and calls the tail the signal. The median start got that +shape right and the mostly-background case wrong instead. Neither wins both. The run gives no warning +in either case, which is why the fitted grid puts both means in front of the reader. diff --git a/.changeset/integer-count-bin-edges.md b/.changeset/integer-count-bin-edges.md deleted file mode 100644 index 239ac95..0000000 --- a/.changeset/integer-count-bin-edges.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@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/.changeset/per-cell-tag-counts-export.md b/.changeset/per-cell-tag-counts-export.md new file mode 100644 index 0000000..353db28 --- /dev/null +++ b/.changeset/per-cell-tag-counts-export.md @@ -0,0 +1,24 @@ +--- +'@platforma-open/milaboratories.feature-integration.per-cell-metrics': minor +'@platforma-open/milaboratories.feature-integration.workflow': minor +'@platforma-open/milaboratories.feature-integration': minor +--- + +Per-cell antigen counts are exported, before the minimum is applied + +A new export, keyed `[sampleId, cellId, tagId]`, carrying the UMI count each cell held for each +barcode. It is what a downstream per-cell composition plot needs: one bar per cell, split by antigen. + +**Before the count minimum, and that is the point.** The counts the verdicts are computed on have +already had every value below the minimum set to zero, and the comparator tag is exempt from that — +so on a declared-baseline run the control keeps its small counts while the antigens lose theirs. Those +numbers answer "what counted as evidence of binding". This export answers "what did the cell capture", +so a cell's tags add up to what that cell actually held. The column's own description says so, because +the two do not reconcile and a reader who mixes them draws the wrong conclusion. + +It cannot be derived from the floored counts afterwards: once the minimum has run, a count of 3 and a +count that was never there are both 0. + +Partitioned by sample, the only column in the block that is. It is the largest table the run produces, +at one row per (cell, tag), and a composition plot reads one sample per view — so a reader after one +sample touches one partition instead of the whole run. diff --git a/.changeset/run-quality-grid-per-sample.md b/.changeset/run-quality-grid-per-sample.md new file mode 100644 index 0000000..7c53b30 --- /dev/null +++ b/.changeset/run-quality-grid-per-sample.md @@ -0,0 +1,38 @@ +--- +'@platforma-open/milaboratories.feature-integration.ui': minor +'@platforma-open/milaboratories.feature-integration.workflow': patch +'@platforma-open/milaboratories.feature-integration.model': patch +--- + +The fitted-background grid reads one sample at a time + +The fit runs per (sample, tag), so the grid drew one panel per pair — 27 samples and 9 barcodes is 243 +panels on one page, and every title had to repeat the sample to tell them apart. It now shows one +sample, chosen from a selector above it, so a panel is titled by its reagent alone and the sample is +named once. Barcodes read in the order the panel file declares them, so a barcode holds the same slot +whichever sample is shown. + +What that gives up is reading down one reagent across samples. The grid still supports that shape; +nothing asks for it today. + +Each panel's caption now carries the bound count alone, and the fit's own numbers moved to the enlarged +panel, where a value is read rather than scanned. The cell count that used to lead the caption was the +sample's analysed population — the same number on every panel of the sample — and now sits once above +the grid. + +**The Panel column is out of the tables.** It is a hash of the sorted barcode list, because no panel +file names its panel, and a run declaring one panel for every sample repeated that hash identically on +every row. It stays available in the column picker, and a multi-panel run should switch it on: +`Seen in 2/3` cannot be read without knowing which three samples. + +**Fixes** + +- A fitted background mean of 0.000488 printed as `0`, a value the fit cannot produce — three + significant figures were computed and then discarded by a formatter keeping three decimal places. +- The Run quality page failed to render at all: a watch read a value declared further down the file. +- A run computed before the bound count existed reported "no count reaches the bound probability", + stating a finding no run had produced. Absent and null now read differently. +- Resizing the window redrew every panel on every frame, and each redraw leaks a tooltip node in the + uikit. A few pixels of tolerance takes a drag from hundreds of redraws to a handful. The leak itself + is the uikit's. +- The quality-report JSON was pretty-printed, which roughly doubled it for a file only the UI reads. diff --git a/.changeset/thirty-ties-beg.md b/.changeset/thirty-ties-beg.md deleted file mode 100644 index 19dc206..0000000 --- a/.changeset/thirty-ties-beg.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@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.test": minor -"@platforma-open/milaboratories.feature-integration.ui": minor ---- - -Algorithm adjustments and UI updates From d2587aa27245b7d00ce465ab910aaf91ad5d3669 Mon Sep 17 00:00:00 2001 From: Julen Mendieta Date: Thu, 3 Sep 2026 11:37:37 +0200 Subject: [PATCH 14/14] Minor improvements --- model/src/index.ts | 8 +- .../per-cell-metrics/src/emit_verdicts.py | 7 +- .../per-cell-metrics/src/tag_distribution.py | 74 ++++++++++++++++--- test/src/qcDefaults.test.ts | 8 +- ui/src/pages/MainPage.vue | 71 +++++++++--------- ui/src/pages/SampleReportPanelQc.vue | 9 ++- workflow/src/column-specs.lib.tengo | 6 +- workflow/src/verdict-args.lib.tengo | 7 +- 8 files changed, 123 insertions(+), 67 deletions(-) diff --git a/model/src/index.ts b/model/src/index.ts index 157c915..18b2703 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -32,8 +32,8 @@ export type { PTableKey } from "@platforma-sdk/model"; // The reading's own defaults, in one exported map. Exported so a test can compare it against the other // two copies: this map is what a workflow-driven run is actually answered under, because -// verdict-args.lib.tengo emits every one of these flags UNCONDITIONALLY, substituting its own copy -// wherever the stored value is undefined. The argparse defaults in the Python never govern such a run. +// verdict-args.lib.tengo emits these flags UNCONDITIONALLY, substituting its own copy wherever the +// stored value is undefined. The argparse defaults in the Python never govern such a run. // `test/src/qcDefaults.test.ts` asserts each value against verdict-args.lib.tengo and the Python module // that owns it, the same way it asserts the QC lines below. export const VERDICT_DEFAULTS = { @@ -43,10 +43,6 @@ export const VERDICT_DEFAULTS = { boundCutoff: 75, // verdict.py DISTRIBUTION_BOUND_PROBABILITY. Both the default and the FLOOR: `args()` refuses below it. boundProbability: 0.9, - // tag_distribution.py DEFAULT_INITIAL_SIGNAL_WEIGHT, the source paper's own initial weight. A default - // only -- unlike the line above this is not a floor, because it states what the EXPERIMENT holds - // rather than what the method licenses, and a sorted or synthetic population can sit far from it. - expectedBinderFraction: 0.1, // combine.py DEFAULT_MIN_VOTERS minVotingCells: 1, // verdict.py DEFAULT_PANEL_MIN_MEMBERS. Gates rather than tunes: keep above the fifteen-tag cap of an diff --git a/software/per-cell-metrics/src/emit_verdicts.py b/software/per-cell-metrics/src/emit_verdicts.py index 1eae731..0037bf9 100644 --- a/software/per-cell-metrics/src/emit_verdicts.py +++ b/software/per-cell-metrics/src/emit_verdicts.py @@ -123,7 +123,6 @@ ) from tag_distribution import ( DEFAULT_DISTRIBUTION_MIN_CELLS, - DEFAULT_INITIAL_SIGNAL_WEIGHT, TagFits, bound_at_count, fit_tag_probabilities_by_pair, @@ -245,7 +244,7 @@ def main() -> None: p.add_argument("--min-voters", type=int, default=DEFAULT_MIN_VOTERS) p.add_argument("--min-agreement", type=float, default=DEFAULT_MIN_AGREEMENT) # Roughly what share of cells are expected to bind an antigen. - p.add_argument("--initial-signal-weight", type=float, default=DEFAULT_INITIAL_SIGNAL_WEIGHT) + p.add_argument("--initial-signal-weight", type=float, default=None) p.add_argument("--gate-threshold", type=int, default=None, help="set aside cells whose comparator reads above this") p.add_argument("--grouping", default=None, help="JSON: {'by':'tag'} or {'by':'property','column':...}") p.add_argument("--contending", default=None, help="JSON: groups of identities that contend, as a list of lists") @@ -297,7 +296,9 @@ def main() -> None: # Must be above 0 and below 1. At either end the split hands every cell to one side, and the fit # quietly falls back to splitting at the mean instead. The run would then report a value it never # used, so refuse rather than let that happen silently. - if not 0.0 < args.initial_signal_weight < 1.0: + # Absent is the ordinary case and means the pivot is derived from each tag's own counts. A value + # given is a statement about the experiment, and only then does the range apply. + if args.initial_signal_weight is not None and not 0.0 < args.initial_signal_weight < 1.0: raise SystemExit( f"the expected binder fraction is a share strictly between 0 and 1. Got {args.initial_signal_weight}." ) diff --git a/software/per-cell-metrics/src/tag_distribution.py b/software/per-cell-metrics/src/tag_distribution.py index 269b765..8faa6be 100644 --- a/software/per-cell-metrics/src/tag_distribution.py +++ b/software/per-cell-metrics/src/tag_distribution.py @@ -93,7 +93,8 @@ class TagFit(NamedTuple): NO_FIT = "no two-component fit could be computed for this tag" -# Roughly what share of cells are expected to bind an antigen. +# Roughly what share of cells are expected to bind an antigen. The source paper's own initial weight, +# and an OVERRIDE here rather than the default. DEFAULT_INITIAL_SIGNAL_WEIGHT = 0.1 # The share of the highest counts dropped before the fit, so that a handful of very high readings @@ -170,9 +171,59 @@ class _Mixture(NamedTuple): signal: int -def _fit_two_component_nb( - counts: np.ndarray, initial_signal_weight: float = DEFAULT_INITIAL_SIGNAL_WEIGHT -) -> _Mixture | None: +# @TODO: Review this function in more detail looking for logic flaws that might have been missed +def _scan_pivot(x: np.ndarray) -> float | None: + """The split point that best explains the counts as two negative binomials, or None. + + Scores every cut of the counts into `[0, t]` and the rest, and returns the `t` that scores best. + None where the counts hold fewer than two distinct values, or where no cut scores finitely. + """ + # The histogram, not the cells: everything below is sized by the number of DISTINCT counts, which is + # why the scan costs milliseconds against the EM's seconds and does not grow with the sample. + vals, mult = np.unique(x, return_counts=True) + k = vals.size + if k < 2: + return None + + # Prefix sums of the cell count, of the counts, and of their squares. They make each cut's two-sided + # weight, mean and variance a subtraction instead of a pass over the data. + n = float(x.size) + cum_w = np.cumsum(mult).astype(float) + cum_s = np.cumsum(vals * mult) + cum_q = np.cumsum((vals**2) * mult) + + # One entry per cut, and `[:-1]` is the cut set: cutting at the highest count leaves the upper side + # empty. Variance by `E[x^2] - E[x]^2`, so it can land just below zero on a near-constant side. + w_lo = cum_w[:-1] + w_hi = n - w_lo + m_lo = cum_s[:-1] / w_lo + m_hi = (cum_s[-1] - cum_s[:-1]) / w_hi + v_lo = cum_q[:-1] / w_lo - m_lo**2 + v_hi = (cum_q[-1] - cum_q[:-1]) / w_hi - m_hi**2 + m_lo = np.maximum(m_lo, _MIN_COMPONENT_MEAN) + m_hi = np.maximum(m_hi, _MIN_COMPONENT_MEAN) + + # Dispersions through `_nb_size` rather than inline, so each side inherits the same Poisson-limit + # fallback the EM's own components get and the two cannot disagree about what a dispersion is. + s_lo = np.array([_nb_size(m, v) for m, v in zip(m_lo, np.maximum(v_lo, 0.0), strict=True)]) + s_hi = np.array([_nb_size(m, v) for m, v in zip(m_hi, np.maximum(v_hi, 0.0), strict=True)]) + + # The score, one row per cut and one column per distinct count. It is the CLASSIFICATION + # log-likelihood -- every count read against the side its cut assigns it, weighted by that side's + # share and by how many cells hold the count. + below = vals[None, :] <= vals[:-1, None] + lo = _nb_logpmf(vals[None, :], m_lo[:, None], s_lo[:, None]) + np.log(w_lo / n)[:, None] + hi = _nb_logpmf(vals[None, :], m_hi[:, None], s_hi[:, None]) + np.log(w_hi / n)[:, None] + scored = np.where(below, lo, hi) * mult[None, :] + + # A cut where any count underflowed is dropped rather than left to win the argmax on a -inf. + total = np.where(np.all(np.isfinite(scored), axis=1), scored.sum(axis=1), -np.inf) + if not np.any(np.isfinite(total)): + return None + return float(vals[int(np.argmax(total))]) + + +def _fit_two_component_nb(counts: np.ndarray, initial_signal_weight: float | None = None) -> _Mixture | None: """A two-component negative binomial fitted to `counts`, or None where none exists. The method: fit a two-component negative binomial mixture and label the higher-median component @@ -192,9 +243,14 @@ def _fit_two_component_nb( if np.unique(x).size < 2: return None - # Split the counts so that `initial_signal_weight` of them sit above the pivot. Each side then - # seeds one component, and the two side sizes are the starting weights. - pivot = float(np.quantile(x, 1.0 - initial_signal_weight)) + # Where the scientist stated a share, split the counts so that `initial_signal_weight` of them sit + # above the pivot. Otherwise derive the split from the counts themselves. Each side then seeds one + # component, and the two side sizes are the starting weights. + scanned = _scan_pivot(x) if initial_signal_weight is None else None + if scanned is not None: + pivot = scanned + else: + pivot = float(np.quantile(x, 1.0 - (initial_signal_weight or DEFAULT_INITIAL_SIGNAL_WEIGHT))) low, high = x[x <= pivot], x[x > pivot] if low.size == 0 or high.size == 0: # The quantile landed on the maximum, so one side got every cell. Split at the mean instead: @@ -282,7 +338,7 @@ def fit_tag_probabilities( counts: np.ndarray, min_cells: int = DEFAULT_DISTRIBUTION_MIN_CELLS, scored: np.ndarray | None = None, - initial_signal_weight: float = DEFAULT_INITIAL_SIGNAL_WEIGHT, + initial_signal_weight: float | None = None, ) -> TagFit: """One tag's counts across one sample's cells, as a probability of binding per cell. @@ -368,7 +424,7 @@ def fit_tag_probabilities_by_pair( min_cells: int = DEFAULT_DISTRIBUTION_MIN_CELLS, floor: int = 0, reference_tags: Collection[str] = (), - initial_signal_weight: float = DEFAULT_INITIAL_SIGNAL_WEIGHT, + initial_signal_weight: float | None = None, ) -> TagFits: """One fit per (sample, tag) the panel declares, scored per cell. diff --git a/test/src/qcDefaults.test.ts b/test/src/qcDefaults.test.ts index 4f64c19..b514a96 100644 --- a/test/src/qcDefaults.test.ts +++ b/test/src/qcDefaults.test.ts @@ -91,12 +91,6 @@ describe("VERDICT_DEFAULTS matches verdict-args.lib.tengo and the Python that ow "verdict.py", "DISTRIBUTION_BOUND_PROBABILITY", ], - [ - "expectedBinderFraction", - "DEFAULT_EXPECTED_BINDER_FRACTION", - "tag_distribution.py", - "DEFAULT_INITIAL_SIGNAL_WEIGHT", - ], ["minVotingCells", "DEFAULT_MIN_VOTING_CELLS", "combine.py", "DEFAULT_MIN_VOTERS"], [ "panelReferenceMinMembers", @@ -120,7 +114,7 @@ describe("VERDICT_DEFAULTS matches verdict-args.lib.tengo and the Python that ow it("covers every shaping default the tengo file declares", () => { const declared = [ ...tengo.matchAll( - /^(DEFAULT_(?:COUNT_FLOOR|BOUND_CUTOFF|BOUND_PROBABILITY|EXPECTED_BINDER_FRACTION|MIN_VOTING_CELLS|PANEL_MIN_MEMBERS|DISTRIBUTION_MIN_CELLS))\s*:=/gm, + /^(DEFAULT_(?:COUNT_FLOOR|BOUND_CUTOFF|BOUND_PROBABILITY|MIN_VOTING_CELLS|PANEL_MIN_MEMBERS|DISTRIBUTION_MIN_CELLS))\s*:=/gm, ), ].map((m) => m[1]); expect(new Set(declared)).toStrictEqual(new Set(pairs.map(([, name]) => name))); diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index 94d9117..59d46e0 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -1,7 +1,20 @@