spacr.plate_qc

Workflow inputs and outputs

Plate Viewer

Aggregate the selected measurement by well and inspect plate patterns and controls.

Open: Graph Builder → Plate Viewer.

Inputs and outputs below include conditional alternatives. The guidance and handoff notes say which route applies.

Inputs

  • Measured objects — measurements/measurements.db; object tables depend on the enabled cell, nucleus, pathogen and organelle masks. Relevant tables, depending on the route: cell, nucleus, pathogen, cytoplasm. Relevant columns, depending on the route: plateID, rowID, columnID, fieldID.

  • Experimental layout — Exported plate/condition/control/replicate map. Keep its plate and well identifiers consistent with the acquired data.

Outputs

  • Figures and table exports — The output location chosen by the tool; exports describe the selected data and filters.

API reference.

Plate-level QC: is that hit biology, or is it the edge of the plate?

The outer ring of a microtitre plate evaporates faster, sits at a different temperature and is handled differently from the interior. A well in row A or column 24 that reads three sigma from the plate mean is therefore much more likely to be an artefact of where it sits than of what is in it — and the usual way that gets discovered is a failed follow-up experiment six weeks later.

This module answers the question up front. It takes the same long-format per-object frame the rest of spaCR passes around (a prc identifier plus feature columns), collapses it to one value per well, and then asks three separate questions of the resulting grid:

detect_edge_effect

Does the outer ring read differently from the interior — and by how much? Ring-by-ring, not just outermost-vs-rest.

row_column_trends

What does each row and each column average, and is there a monotonic drift across the plate?

plate_layout / layout_matrix

The well grid itself, tidy or pivoted, ready to draw.

format_edge_report

All of it as text.

Statistics this module refuses to get wrong

Rank tests, not t-tests. Per-well aggregates of object measurements are routinely skewed and heavy-tailed — a well with three enormous cells is not a normal deviate. The ring comparison is a two-sided Mann-Whitney U; the gradient test is Spearman. Neither assumes a shape.

Effect size leads, p-value follows. With 384 wells, almost any systematic drift clears p < 0.05; that fact carries no information. Every comparison therefore reports the median difference, the difference as a percentage of the interior median, and Cliff’s delta — a rank-based standardised effect in [-1, 1] read straight off the same U statistic (δ = 2U/n₁n₂ − 1). Detection requires both a small p and an effect size above DEFAULT_MIN_EFFECT, so “significant but 0.4 %” is reported as what it is: not an edge effect.

Evaporation is not a step function. The profile walks inward ring by ring (outermost = ring 0), comparing each against the plate core, so a gradient that reaches two wells in is visible instead of being averaged into “the interior”.

A gradient is not an edge effect. An incubator or plate-reader gradient runs across the plate; evaporation runs around it. A linear column gradient leaves the outer ring with the same median as the interior (it collects both the high and the low end), so the ring test stays quiet while Spearman on the column index fires. Both are computed, both are reported, and EdgeEffectReport.dominant names which one better explains the plate.

Wells with too few objects are noise. min_count drops them — and the number dropped is reported everywhere, because a heatmap quietly missing a third of its wells looks exactly like data.

The plate format is inferred, never assumed. 6/12/24/48/96/384/1536 are recognised from the observed row and column labels, and the ring geometry uses the nominal grid of the inferred format. A screen that only used rows A-H of a 384 plate does not get row H promoted to “edge” just because nothing was pipetted below it.

Degenerate input explains itself. An empty frame, a single well, or a plate where every well reads the same comes back as a report that says so, with None where a statistic is genuinely undefined. Nothing here returns NaN and nothing here raises on empty.

Nothing in this module imports torch, cellpose or any GPU stack — numpy, pandas, scipy and the standard library only — so the Qt screen can draw a plate without waking a multi-second import chain.

Classes

EdgeEffectReport

Everything detect_edge_effect() worked out, in one object.

GradientStats

A monotonic drift along one axis of the plate.

RingStats

One concentric ring of the plate, compared against its core.

Functions

colour_limits(→ Tuple[float, float])

Return (vmin, vmax) for a heatmap of layout.

detect_edge_effect(→ EdgeEffectReport)

Test a plate for an edge artefact and for a row/column gradient.

format_edge_report(→ str)

Render an EdgeEffectReport as text, effect size first.

infer_plate_format(→ Tuple[Optional[int], int, int])

Infer the plate format containing a n_rows × n_cols extent.

layout_matrix(→ pandas.DataFrame)

Pivot a layout onto the full nominal plate grid.

load_plate_frame(→ pandas.DataFrame)

Read the well identifiers plus value_col from table.

numeric_columns(→ List[str])

Return the columns of table that hold plottable numbers.

parse_column_label(→ Optional[int])

Return the 1-based column index of label, or None.

parse_row_label(→ Optional[int])

Return the 1-based row index of label, or None.

plate_layout(→ pandas.DataFrame)

Collapse a long per-object frame into one row per well.

plates_in(→ List[str])

Return the plate IDs present in df, sorted.

row_column_trends(→ pandas.DataFrame)

Per-row and per-column summaries, plus the drift across each axis.

row_label(→ str)

Return the letter label of a 1-based row index: 3 → 'C'.

table_columns(→ List[str])

Return the column names of table, in declaration order.

tables(→ List[str])

Return the user tables + views of db_path, alphabetically.

well_id(→ str)

Return the canonical well name, e.g. (3, 7) → 'C07'.

write_layout_csv(→ str)

Write the well grid to path as CSV, one row per well.

Module Contents

class spacr.plate_qc.EdgeEffectReport[source]

Everything detect_edge_effect() worked out, in one object.

The headline numbers are pct_difference and cliffs_delta — “the outer ring reads 31 % higher, δ = 0.78” — with p_value as supporting evidence rather than the verdict.

Parameters:
  • plate – plate identifier analysed, or None when none was selected.

  • value_col – measurement aggregated per well, or None for counts.

  • grouping – per-well aggregation used to build the layout.

  • ok – False when the plate could not be tested at all (empty frame, one well, no interior); notes says why.

  • plate_format – nominal well count used to choose the plate grid, or None when geometry is non-standard or unavailable.

  • n_rows – number of rows in the grid used to classify plate rings.

  • n_cols – number of columns in the grid used to classify plate rings.

  • n_wells – wells retaining usable values after count and NaN filtering.

  • n_edge_wells – usable wells assigned to the outermost ring.

  • n_interior_wells – usable wells inside the outermost ring.

  • min_count – minimum number of objects a well needed to remain in the analysis.

  • edge_detected – the outer ring differs from the interior by more than min_effect, at better than alpha.

  • p_value – two-sided Mann-Whitney U p-value comparing outer-ring and interior wells, or None when the comparison is unavailable.

  • cliffs_delta – signed rank effect size for that comparison; positive values mean the outer ring reads higher.

  • edge_median – median usable value on the outermost ring.

  • interior_median – median usable value inside the outermost ring.

  • median_difference – edge_median - interior_median.

  • pct_difference – median difference as a percentage of the interior median, or None when the baseline is zero or unavailable.

  • gradient_detected – at least one axis shows a monotonic drift.

  • dominant – 'edge', 'gradient', or 'none' — which pattern better explains the plate.

  • alpha – p-value threshold used together with min_effect.

  • min_effect – minimum absolute Cliff’s delta required to flag an edge.

  • min_gradient_rho – minimum absolute Spearman correlation required to flag a row or column gradient.

  • rings – ring-by-ring profile, outermost first.

  • gradients – one GradientStats per axis.

  • n_dropped_min_count – wells removed by min_count. A heatmap missing a third of its wells looks like data; this is the number that says it isn’t.

  • notes – filtering, geometry, and degenerate-input explanations safe to present to the user.

gradient(axis: str) → GradientStats | None[source]

Return the GradientStats for 'row' or 'column'.

Parameters:

axis – gradient axis to retrieve.

ring(index: int) → RingStats | None[source]

Return the RingStats for ring index, if computed.

Parameters:

index – zero-based ring depth, outermost first, to retrieve.

property magnitude: str[source]

The edge difference in the most meaningful units available.

A percentage where the interior median is non-zero, absolute units where it is not — “+300 % of nothing” is not a number to put in front of a user.

property summary: str[source]

One-line verdict, effect size first.

class spacr.plate_qc.GradientStats[source]

A monotonic drift along one axis of the plate.

Parameters:
  • axis – plate axis tested, either "row" or "column".

  • spearman_rho – Spearman rank correlation between each usable well’s value and its row or column index, or None when fewer than three wells are available or the correlation is undefined.

  • p_value – two-sided p-value for spearman_rho, or None when the correlation is unavailable.

  • first_label – label of the lowest-index row or column present in the usable wells.

  • last_label – label of the highest-index row or column present in the usable wells.

  • delta_first_last – median value at the last axis index minus the median at the first, or None when either median is unavailable.

  • pct_first_last – delta_first_last as a percentage of the absolute first-index median, or None when the difference or baseline is unavailable or zero.

  • detected – whether p_value < alpha and abs(spearman_rho) >= min_gradient_rho for the thresholds used to produce the profile.

class spacr.plate_qc.RingStats[source]

One concentric ring of the plate, compared against its core.

Parameters:
  • ring – zero-based distance from the plate edge: zero is the outermost ring, one is one well inward, and so on.

  • n_wells – number of usable wells in this ring after minimum-count and missing-value filtering.

  • median – finite median of the per-well values in this ring, or None when it is unavailable.

  • mean – finite mean of the per-well values in this ring, or None when it is unavailable.

  • delta – ring median minus the selected core median, or None when either median is unavailable.

  • pct – delta as a percentage of the absolute core median, or None when the difference or baseline is unavailable or zero.

  • p_value – two-sided Mann-Whitney U p-value comparing this ring with the selected core, or None when the comparison is unavailable.

  • cliffs_delta – signed Cliff’s delta for the same comparison, in [-1, 1]; positive values mean this ring reads higher than the core, or None when unavailable.

spacr.plate_qc.colour_limits(layout: pandas.DataFrame, min_max: Any = 'allq') → Tuple[float, float][source]

Return (vmin, vmax) for a heatmap of layout.

Same specification language as spacr.plot.generate_plate_heatmap(): 'all' for the full range, 'allq' for the 2nd-98th percentile (which stops one dead well from flattening the whole plate to a single colour), or an explicit [vmin, vmax] — floats in [0, 1] are read as quantiles, anything else as absolute limits.

Unlike the original, quantiles are computed over the present wells only; the original quantiles a grid where every absent well has already been turned into a zero.

Parameters:
  • layout – frame from plate_layout().

  • min_max – colour-scale specification.

Returns:

(vmin, vmax), never degenerate.

spacr.plate_qc.detect_edge_effect(df: pandas.DataFrame, value_col: str | None = None, plate: str | None = None, grouping: str = 'mean', min_count: int = 0, alpha: float = DEFAULT_ALPHA, min_effect: float = DEFAULT_MIN_EFFECT, min_gradient_rho: float = DEFAULT_MIN_GRADIENT_RHO, core_depth: int = DEFAULT_CORE_DEPTH, max_rings: int = DEFAULT_MAX_RINGS, plate_format: int | None = None) → EdgeEffectReport[source]

Test a plate for an edge artefact and for a row/column gradient.

The outer ring is compared against everything inside it with a two-sided Mann-Whitney U — rank-based, because per-well aggregates of object measurements are skewed often enough that a t-test would be testing its own assumptions rather than the plate. The accompanying Cliff’s delta comes off the same U statistic, so effect size and p-value cannot disagree about which comparison was made.

Detection deliberately needs both p < alpha and |cliffs_delta| >= min_effect. On 384 wells a p-value on its own flags drifts far below the level at which anyone would act.

The ring-by-ring profile then walks inward, comparing each ring against the plate core (depth >= core_depth), because evaporation reaches past the outermost well and a single outer-vs-rest test would average that away.

Row and column gradients are tested separately with Spearman on the well index. This is what keeps a plate-reader or incubator gradient from being reported as an edge effect: a linear gradient across the plate leaves the outer ring straddling both extremes, so its median matches the interior and the ring test correctly stays quiet.

Parameters:
  • df – long per-object frame, or a layout from plate_layout().

  • value_col – measurement to aggregate per well.

  • plate – plate to test; None takes the first present.

  • grouping – per-well aggregation, one of GROUPINGS.

  • min_count – drop wells with fewer than this many objects.

  • alpha – significance threshold. Default DEFAULT_ALPHA.

  • min_effect – minimum |Cliff's delta| to call an edge effect. Default DEFAULT_MIN_EFFECT.

  • min_gradient_rho – minimum |Spearman rho| to call a gradient. Default DEFAULT_MIN_GRADIENT_RHO.

  • core_depth – ring depth defining “the core” for the profile.

  • max_rings – how many rings inward to profile.

  • plate_format – force 96 / 384 / 1536 instead of inferring.

Returns:

an EdgeEffectReport. Degenerate input comes back with ok=False and an explanation in notes — never a NaN and never an exception.

Example

from spacr.plate_qc import detect_edge_effect, format_edge_report
report = detect_edge_effect(df, 'cell_area', min_count=20)
print(format_edge_report(report))

See also

row_column_trends() — the per-row/per-column detail behind the gradient statistics.

spacr.plate_qc.format_edge_report(report: EdgeEffectReport) → str[source]

Render an EdgeEffectReport as text, effect size first.

The layout is deliberate: the verdict and the percentage difference come before the p-value, because on a 384-well plate the p-value is the least informative number in the report.

Parameters:

report – the report to render.

Returns:

a multi-line string, safe to drop into a label or a log.

spacr.plate_qc.infer_plate_format(n_rows: int, n_cols: int) → Tuple[int | None, int, int][source]

Infer the plate format containing a n_rows × n_cols extent.

Picks the smallest standard format whose nominal grid contains every observed label, so an assay run only in rows A-H of a 384 plate is still recognised as a 96 grid — and one run in rows A-H across 24 columns is recognised as a 384 plate whose lower half is empty, rather than having row H mistaken for the bottom edge.

Parameters:
  • n_rows – largest observed row index.

  • n_cols – largest observed column index.

Returns:

(n_wells_or_None, nominal_rows, nominal_cols). The format is None for a grid bigger than 1536, in which case the observed extent is returned unchanged.

spacr.plate_qc.layout_matrix(layout: pandas.DataFrame, column: str = 'value') → pandas.DataFrame[source]

Pivot a layout onto the full nominal plate grid.

The result always spans every row and column of the inferred format, with NaN — never 0 — where a well is absent, so a heatmap cannot pass an empty well off as a measurement.

Parameters:
  • layout – frame from plate_layout().

  • column – which layout column to pivot ('value' or 'n').

Returns:

DataFrame indexed by row letter (A, B, …) with integer column labels 1..n_cols.

spacr.plate_qc.load_plate_frame(db_path: str, table: str, value_col: str, limit: int | None = None) → pandas.DataFrame[source]

Read the well identifiers plus value_col from table.

Only the columns needed to place and colour a well are selected — a spaCR feature table can be 500 columns wide and half a million rows long, and SELECT * on one is a minute of nothing happening.

Parameters:
  • db_path – path to measurements.db (opened read-only).

  • table – table to read.

  • value_col – the measurement column to plot.

  • limit – optional LIMIT for previews.

Returns:

a long DataFrame, one row per object.

Raises:

ValueError – for an unknown table or column, or when the table carries no usable well identifier at all.

spacr.plate_qc.numeric_columns(db_path: str, table: str, sample: int = 500) → List[str][source]

Return the columns of table that hold plottable numbers.

Declared affinity is the first filter, but spaCR writes plenty of feature tables through pandas.to_sql where everything lands as REAL/INTEGER anyway — including object_label and the row index. So a column also has to actually contain numbers in the first sample rows, and the well-identifier columns are excluded by name.

Parameters:
  • db_path – path to measurements.db.

  • table – table to inspect.

  • sample – rows sampled to confirm the values are numeric.

Returns:

candidate column names, in table order.

spacr.plate_qc.parse_column_label(label: Any) → int | None[source]

Return the 1-based column index of label, or None.

Understands 'c12', 'C12', 'column12', '12' and 12.

Parameters:

label – column identifier.

Returns:

1-based column index, or None when unparseable.

spacr.plate_qc.parse_row_label(label: Any) → int | None[source]

Return the 1-based row index of label, or None.

Understands every row spelling spaCR and plate readers produce: 'r3', 'R3', 'row3', 3, 'C' (letter rows) and 'AA' (1536 plates go past Z).

Parameters:

label – row identifier of any of the above shapes.

Returns:

1-based row index, or None when unparseable.

spacr.plate_qc.plate_layout(df: pandas.DataFrame, value_col: str | None = None, plate: str | None = None, grouping: str = 'mean', min_count: int = 0, plate_format: int | None = None) → pandas.DataFrame[source]

Collapse a long per-object frame into one row per well.

This is the grid everything else in the module works on: the heatmap draws it, detect_edge_effect() tests it, and write_layout_csv() exports it.

Aggregation and the well-identifier handling follow spacr.plot.generate_plate_heatmap() so the two agree well for well, with two deliberate departures, each of which fixes a way the plotter misleads:

  • Missing wells stay missing. generate_plate_heatmap ends in .fillna(0), which paints an unpipetted or filtered-out well as a real measurement of zero. Here an absent well is absent.

  • The grid is nominal, not observed. The plotter’s axes span the wells that are present; these span the whole inferred format, so a row nobody pipetted still holds its place on the plate and the ring geometry detect_edge_effect() needs stays intact.

The third departure has since been closed from the other side. The plotter used to pin rows to r1..r16 and columns to c1..c27, silently dropping every well of a 1536 plate past row P; it now reads its axes off the data through parse_row_label() / parse_column_label() — these functions — so 'B' and 'AA' are row labels in both places, and neither module owns a private letter walk.

Parameters:
  • df – long frame with a prc identifier (or rowID + columnID, or a well column) and value_col. A layout produced by a previous call is passed straight back.

  • value_col – measurement to aggregate. Ignored (and optional) when grouping='count'.

  • plate – plate to keep. None takes the first plate present and records the choice in the layout’s notes.

  • grouping – one of GROUPINGS.

  • min_count – drop wells with fewer than this many objects. The number dropped is recorded in layout.attrs['n_dropped_min_count'].

  • plate_format – force a format (96 / 384 / 1536) instead of inferring one.

Returns:

tidy DataFrame with LAYOUT_COLUMNS, carrying the inferred geometry and drop counts in .attrs.

Raises:

ValueError – for an unknown grouping, a missing value_col, or a frame with no usable well identifier.

Example

from spacr.plate_qc import plate_layout, layout_matrix
wells = plate_layout(df, 'cell_area', grouping='median',
                     min_count=20)
grid = layout_matrix(wells)      # 16 x 24, NaN where empty
spacr.plate_qc.plates_in(df: pandas.DataFrame) → List[str][source]

Return the plate IDs present in df, sorted.

Parameters:

df – raw long measurements or an existing plate layout.

Accepts either a raw long frame or a layout from plate_layout(). Returns [] for anything unusable rather than raising — this feeds a combo box.

Per-row and per-column summaries, plus the drift across each axis.

One output row per plate row and per plate column, each carrying the number of wells behind it — a row average over three surviving wells is not the same claim as one over twenty-four, and the n_wells column is what stops it being read as one.

The Spearman statistic attached to each axis is computed over the individual wells (not over the 16 row means), because that is where the degrees of freedom are.

Parameters:
  • df – long per-object frame, or a layout from plate_layout().

  • value_col – measurement to aggregate (see plate_layout()).

  • plate – plate to summarise; None takes the first.

  • grouping – per-well aggregation, one of GROUPINGS.

  • min_count – drop wells with fewer than this many objects.

Returns:

DataFrame with axis, label, index, n_wells, n_objects, mean, median, std, delta_vs_plate_median, spearman_rho and spearman_p. Empty (but correctly typed) when there are no wells.

spacr.plate_qc.row_label(row_index: int) → str[source]

Return the letter label of a 1-based row index: 3 → 'C'.

Parameters:

row_index – 1-based plate row index to convert.

spacr.plate_qc.table_columns(db_path: str, table: str) → List[str][source]

Return the column names of table, in declaration order.

Parameters:
  • db_path – path to the SQLite database to inspect read-only.

  • table – table or view whose declared columns are returned.

Raises:

ValueError – when the database has no such table. The name is checked against sqlite_master before it is ever interpolated into SQL — identifiers cannot be bound, so this is the gate.

spacr.plate_qc.tables(db_path: str) → List[str][source]

Return the user tables + views of db_path, alphabetically.

Parameters:

db_path – path to the SQLite database to inspect read-only.

spacr.plate_qc.well_id(row_index: int, column_index: int) → str[source]

Return the canonical well name, e.g. (3, 7) → 'C07'.

Parameters:
  • row_index – 1-based plate row index.

  • column_index – 1-based plate column index.

Agrees with spacr.schema.well_id() on every real well; it differs only in refusing to raise, because a layout table has to render every cell it was handed and '?00' is a more useful report than a traceback.

spacr.plate_qc.write_layout_csv(layout: pandas.DataFrame, path: str) → str[source]

Write the well grid to path as CSV, one row per well.

The tidy form is exported rather than the pivoted matrix: it carries the object count, the ring index and the edge flag alongside the value, which is what anybody re-analysing the plate outside spaCR actually needs.

Parameters:
  • layout – frame from plate_layout().

  • path – destination file. Parent directories are created.

Returns:

the absolute path written.

Raises:

ValueError – when path is empty.

Nested helpers

_default_lims_fetch.SameOriginRedirect.redirect_request(self, req, fp, code, msg, response_headers, newurl)

Refuse a redirected request that changes the LIMS origin.

spacr/plate_qc.py:1810

_locate_records.whole(value)

Convert whole-valued floats to integers before parsing well labels.

spacr/plate_qc.py:1959