Source code for phenotypic.analysis.qc._grid_occupancy

"""Grid-occupancy quality check.

A metadata-driven variant of :class:`ExpectedVsDetectedCount`. Instead of
comparing the raw detected row count against the expected count, it compares
the number of **distinct filled grid cells** (doublets collapse to one)
against the expected cell count from the user-provided layout frame, and
flags plates whose occupancy falls below threshold.
"""

from __future__ import annotations

from typing import Any, ClassVar

import pandas as pd
import plotly.graph_objects as go

from phenotypic.analysis.qc._expected_vs_detected import ExpectedVsDetectedCount
from phenotypic.schema import GRID, QUALITY_OCCUPANCY
from phenotypic.sdk_ import ColumnRef


[docs] class GridOccupancy(ExpectedVsDetectedCount): """Flag groups whose grid occupancy (filled cells / expected) is low. Inherits the entire metadata-form surface of :class:`ExpectedVsDetectedCount` — the single ``metadata`` field (an in-memory DataFrame *or* a ``.csv``/``.parquet`` path), its store-verbatim coercion, the ``pipeline.json`` serialization round-trip (the *source path* persists under the ``metadata`` key and the frame re-reads on load), and the per-key expected-count precompute. The expected cell count for a group is the number of metadata rows for that ``groupby`` key (one row per expected pin position). Where the parent counts ``len(group)`` (raw detections, doublets included), this check counts ``group[cell_label].nunique()`` distinct occupied cells, so a doublet (two colonies sharing one grid cell) still counts once. The two columns play distinct roles: * ``on`` (inherited default ``"Object_Label"``) is the base-class required/guard column and the curation member value — it is unique per colony, so it is **not** what occupancy counts over. * ``cell_label`` (default ``"Grid_RowMajorIdx"``) is the grid-cell id the occupancy ``nunique`` collapses doublets over. Counting distinct *labels* would count colonies, not cells; counting distinct *cells* is what makes the metric doublet-insensitive. The metric is ``filled / expected``. ``_HIGHER_IS_BAD`` is ``False``: a *lower* occupancy is worse, so a row fails when ``metric <= fail_threshold`` and warns at ``metric <= warn_threshold`` (hence ``warn_threshold >= fail_threshold``). A group present in the measurements but absent from the metadata frame (``expected == 0``) is recorded in :attr:`unmatched_groups` and given ``metric = 0.0`` so it fails — mirroring the parent's "force a flag on a metadata mismatch" behavior, adapted to the lower-is-bad direction. The ``QC_Occupancy_Expected = 0`` column distinguishes such a mismatch from a genuinely empty plate. Args: metadata: Layout (in-memory DataFrame or ``.csv``/``.parquet`` path) whose row count per ``groupby`` key is the expected cell count. Same semantics, coercion, and serialization as :class:`ExpectedVsDetectedCount` (the path form round-trips through JSON). groupby: Columns that define one plate. Usually ``["MetadataImage_ImageName"]``. Must be present in both the metadata frame and the measurement frame. on: Base-class required column and curation member value. Defaults to ``"Object_Label"``; occupancy does not count over it. cell_label: Grid-cell id column whose distinct count is the filled cell count. Defaults to ``"Grid_RowMajorIdx"``. Must be present in the measurement frame passed to :meth:`analyze`. warn_threshold: Occupancy at/below which ``Status`` becomes ``"warn"``. Defaults to ``0.95``. fail_threshold: Occupancy at/below which ``Status`` becomes ``"fail"`` and ``Flag=True``. Defaults to ``0.90``. Raises: KeyError: If ``cell_label`` is absent from the measurement frame, or (inherited) if any ``groupby`` column is absent from the metadata frame. FileNotFoundError: (inherited) If ``metadata`` is a path that does not exist. ValueError: (inherited) If ``metadata`` is a path with an unsupported suffix, or if it is ``None`` (a check serialized from an in-memory frame, which cannot round-trip). Examples: Basic — 96-cell metadata vs. a measurement frame with 92 colonies but only 90 distinct filled cells (two doublets). Occupancy reads the filled-cell count, not the colony count: >>> import pandas as pd >>> from phenotypic.analysis.qc import GridOccupancy >>> metadata = pd.DataFrame({ ... "MetadataImage_ImageName": ["p1.png"] * 96, ... "Object_Label": list(range(96)), ... }) >>> measurements = pd.DataFrame({ ... "MetadataImage_ImageName": ["p1.png"] * 92, ... "Object_Label": list(range(92)), ... "Grid_RowMajorIdx": list(range(90)) + [5, 17], ... }) >>> chk = GridOccupancy( ... metadata=metadata, groupby=["MetadataImage_ImageName"] ... ) >>> out = chk.analyze(measurements) >>> int(out["QC_Occupancy_Filled"].iloc[0]) 90 >>> round(float(out["QC_Occupancy_Metric"].iloc[0]), 4) 0.9375 """ name: ClassVar[str] = "Occupancy" _HIGHER_IS_BAD: ClassVar[bool] = False _exposes_agg_func: ClassVar[bool] = False _measurement_infoclass: ClassVar[type | None] = QUALITY_OCCUPANCY supports_object_curation: ClassVar[bool] = False warn_threshold: float = 0.95 fail_threshold: float = 0.90 cell_label: ColumnRef = str(GRID.ROW_MAJOR_IDX)
[docs] def analyze(self, data: pd.DataFrame) -> pd.DataFrame: """Guard the cell-id column, then run the inherited ``analyze``. The base ``QualityCheck.analyze`` only guards ``self.on`` and ``self.groupby``; occupancy additionally needs ``cell_label`` so it can collapse doublets, so its absence is surfaced here before the per-group loop runs. Args: data: Measurement frame to evaluate. Returns: The augmented frame from the inherited ``analyze`` (which also resets :attr:`unmatched_groups`). Raises: KeyError: If ``cell_label`` is missing from ``data``. """ if self.cell_label not in data.columns: raise KeyError( "GridOccupancy requires the cell-id column " f"{self.cell_label!r} in the measurement frame" ) return super().analyze(data)
def _compute(self, group: pd.DataFrame) -> pd.DataFrame: """Compute occupancy for one group and broadcast across its rows. Counts distinct ``cell_label`` values (doublet-insensitive), looks up the group's expected cell count, and records the key tuple in :attr:`unmatched_groups` when no metadata counterpart was found. Args: group: One group as produced by ``data.groupby(self.groupby, dropna=False)``. Returns: The group frame (a copy) with four new columns appended: ``QC_Occupancy_Filled``, ``QC_Occupancy_Expected``, ``QC_Occupancy_Vacant``, ``QC_Occupancy_Metric``. """ filled = int(group[self.cell_label].nunique()) key = self._group_key(group) expected = self._lookup_expected(key) if expected == 0: self.unmatched_groups.append(key) metric = 0.0 # lower-is-bad → forces a fail on metadata mismatch else: metric = filled / expected out = group.copy() out[str(QUALITY_OCCUPANCY.FILLED)] = filled out[str(QUALITY_OCCUPANCY.EXPECTED)] = expected out[str(QUALITY_OCCUPANCY.VACANT)] = max(expected - filled, 0) out[self.metric_col()] = float(metric) return out
[docs] def to_table(self) -> pd.DataFrame: """Return one group-level row per group (occupancy is per-plate). Occupancy reports filled-vs-expected counts broadcast across a group's rows, so per-colony rows carry no extra signal. Collapse to one row per group: the base ``summary()`` (renamed to the generic QC columns) plus the occupancy-specific counts. Returns: A group-level frame: ``[*groupby, QC_Occupancy_Filled, QC_Occupancy_Expected, QC_Occupancy_Vacant, QC_Occupancy_Metric, QC_Occupancy_Status, QC_Occupancy_Flag]``. """ df = self._latest_measurements occ_cols = [ str(QUALITY_OCCUPANCY.FILLED), str(QUALITY_OCCUPANCY.EXPECTED), str(QUALITY_OCCUPANCY.VACANT), ] first = ( df.groupby(self.groupby, dropna=False)[ [c for c in occ_cols if c in df.columns] ] .first() .reset_index() ) summary = self.summary().rename( columns={ "qc_worst_metric": self.metric_col(), "qc_status": self.status_col(), } ) merged = first.merge( summary[[*self.groupby, self.metric_col(), self.status_col()]], on=list(self.groupby), how="left", ) # Group-level flag: any member flagged → fail-status drives the flag. merged[self.flag_col()] = merged[self.status_col()] == "fail" return merged
[docs] def dash(self, **kwargs: Any) -> go.Figure: """Render a horizontal bar of per-group occupancy, colored by status. Args: **kwargs: Passed through to ``Figure.update_layout`` — accepted keys are ``title`` and ``height``. Returns: A :class:`plotly.graph_objects.Figure` with one bar trace and a dashed reference line at ``fail_threshold``. Raises: RuntimeError: If :meth:`analyze` has not been called yet. """ df = self._latest_measurements if df.empty: raise RuntimeError("call analyze() first") metric_col = self.metric_col() status_col = self.status_col() per = ( df.groupby(self.groupby, dropna=False) .agg({metric_col: "first", status_col: "first"}) .reset_index() ) labels = per[self.groupby].astype(str).agg(" | ".join, axis=1) status_colors = { "pass": "#2E86AB", "warn": "#F4A261", "fail": "#E63946", } colors = per[status_col].map(status_colors).fillna("#888888") fig = go.Figure( go.Bar( x=per[metric_col].astype(float), y=labels, orientation="h", marker={"color": colors.tolist()}, ) ) fig.add_vline( x=self.fail_threshold, line={"color": "#E63946", "dash": "dash"} ) fig.update_layout( title=kwargs.get( "title", "Grid Occupancy (filled / expected cells)" ), xaxis_title="Occupancy", xaxis={"range": [0, 1]}, yaxis_title=" | ".join(self.groupby), height=kwargs.get("height", max(240, 24 * len(labels) + 80)), ) return fig