"""The quantifier seam: per-position compute units, discovered by subclassing.
A :class:`Quantifier` turns a position's source files (:class:`PositionInputs`)
into a persisted, plottable quantity. Contacts is the first one; new quantities
(nucleus-track kinematics, nucleus-vs-cell offset, cell shape, …) drop in as
modules under :mod:`itasc.contact_analysis.quantifiers` without
touching the studio.
The mechanics mirror :mod:`itasc.napari.contact_analysis.plugins`: subclassing with a
non-empty ``quantity_id`` auto-registers the quantifier, and
:func:`available_quantifiers` imports every module in the ``quantifiers`` package
so its plugins self-register. This module stays backend-only (no Qt / napari) so
the standalone wheel and headless batch runs can use it.
"""
from __future__ import annotations
import importlib
import pkgutil
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, ClassVar
__all__ = ["OUTPUT_SUBDIR", "PositionInputs", "Quantifier", "available_quantifiers"]
#: Per-position subfolder for quantifier artifacts that persist to disk. Numbered
#: to mirror the staged layout (``0_input`` … ``3_cell``). The dynamics
#: quantifiers land here (``cell_dynamics.h5``, ``nucleus_dynamics.h5``); the
#: cheap quantities (shape, relational, density, neighbor count) are pooled in
#: memory during ``run()`` rather than written to disk. Note ``contact_analysis.h5``
#: is *not* here — :class:`~itasc.contact_analysis.quantifiers.contacts.ContactsQuantifier`
#: overrides :meth:`Quantifier.default_output` to write it to the position root,
#: beside the committed ``cell_labels.tif`` / ``nucleus_labels.tif``.
OUTPUT_SUBDIR = "4_contact_analysis"
#: quantity_id -> quantifier class, populated by ``__init_subclass__``.
_REGISTRY: dict[str, type[Quantifier]] = {}
[docs]
class Quantifier:
"""Base class for per-position quantifiers.
Subclasses set ``quantity_id`` / ``display_name`` (and usually ``requires``)
and implement :meth:`build` and :meth:`read`. A quantifier **owns its own
persistence**: :meth:`build` writes whatever artifact format suits the
quantity and :meth:`read` parses it back — the framework imposes no schema.
"""
#: Stable key; an empty value marks an intermediate (non-registered) base.
quantity_id: ClassVar[str] = ""
#: Human-readable label shown wherever a quantity is selected.
display_name: ClassVar[str] = ""
#: ``PositionInputs`` field names this quantifier needs to build.
requires: ClassVar[tuple[str, ...]] = ()
#: The ``PositionInputs`` field this quantifier's artifact *populates*, if any
#: (e.g. contacts populates ``contact_analysis_path``). A quantifier whose
#: :attr:`requires` names another's :attr:`produces` is *derived from* it — the
#: studio uses this to draw the build-dependency graph. Empty for a leaf
#: quantity that only consumes raw source inputs.
produces: ClassVar[str] = ""
#: Default artifact file name (relative to a position); empty for an
#: intermediate base that does not persist.
default_output_name: ClassVar[str] = ""
#: The index columns of this quantifier's ``object_table`` — its natural grain
#: (e.g. ``("frame", "cell_id")``). A non-empty value marks the quantity as
#: **aggregated**: :mod:`itasc.contact_analysis.shape_tables` pools it
#: across positions into a table named by :attr:`quantity_id`, keyed on these
#: columns. Empty ⇒ not aggregated into an index-keyed table (e.g. contacts).
#: Value columns are namespaced by ``quantity_id`` so a later joined view across
#: tables never has colliding names.
table_keys: ClassVar[tuple[str, ...]] = ()
#: Whether the studio threads the shared plot/build params (z-score shuffle
#: count, density field-of-view) into :meth:`build` via ``params``. Off by
#: default so a quantifier with its own ``params`` schema (contacts edge
#: extraction, shape, dynamics) is never handed the shared bar's keys.
wants_build_params: ClassVar[bool] = False
#: Shared build-param keys that must be present and positive for this
#: quantifier to build, mapped to the human label shown in the UI (e.g. cell
#: density needs ``{"fov_area_mm2": "FOV area (mm²)"}``). The studio greys the
#: metric out — rather than letting the build fail — until they are supplied.
#: Empty for a quantifier that needs no shared param.
required_build_params: ClassVar[dict[str, str]] = {}
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
if cls.quantity_id:
_REGISTRY[cls.quantity_id] = cls
[docs]
def can_build(self, inputs: PositionInputs) -> bool:
"""True when *inputs* supplies every field named in :attr:`requires`."""
return all(getattr(inputs, name, None) is not None for name in self.requires)
[docs]
def missing_build_params(self, params: Mapping[str, Any] | None) -> tuple[str, ...]:
"""Labels of :attr:`required_build_params` that *params* doesn't satisfy.
A required key is satisfied when present and a positive number; anything
else (absent, blank, ``None``, non-positive) counts as missing. The studio
uses the returned labels to grey the metric out and explain why. Empty
tuple ⇒ every required shared param is supplied."""
params = params or {}
missing: list[str] = []
for key, label in self.required_build_params.items():
value = params.get(key)
if not (isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0):
missing.append(label)
return tuple(missing)
[docs]
def default_output(self, inputs: PositionInputs) -> Path:
"""Where this quantifier's artifact lives for *inputs*, by default.
``position_dir / OUTPUT_SUBDIR / default_output_name`` — every quantity
lands in the shared :data:`OUTPUT_SUBDIR` folder, so each subclass sets
just a bare file name. The studio uses this to decide a build's
destination, so a second quantifier no longer inherits the contacts
artifact path. Subclasses may override for richer layouts.
"""
if not self.default_output_name:
raise NotImplementedError(
f"{type(self).__name__} sets no default_output_name"
)
return inputs.position_dir / OUTPUT_SUBDIR / self.default_output_name
[docs]
def is_built(self, output_path: Path) -> bool:
"""True when the artifact at *output_path* already exists."""
return Path(output_path).is_file()
[docs]
def object_table(self, output_path: Path) -> Mapping[str, Any] | None:
"""A tidy, column-major per-object table for the plotting backend.
At least a ``frame`` key plus a per-object key (e.g. ``cell_id``).
Returns ``None`` when this quantifier produces no per-object table; the
plotting backend then skips it.
"""
return None
[docs]
def compute_object_table(
self, inputs: PositionInputs, *, params: dict | None = None
) -> Mapping[str, Any] | None:
"""The pooled tidy table for one position, computed directly from *inputs*.
A **pooled** quantifier (one that declares ``table_keys``) implements this:
it returns the same column-major table that ``object_table`` used to return
after a disk round-trip, but never touches disk. ``None`` when this position
yields no rows. Producers (``contacts``) are not pooled and do not implement
it. *params* carries the shared build knobs (e.g. ``fov_area_mm2``) for the
quantifiers that opt in; per-position values (pixel size, frame interval)
arrive via *inputs*.
"""
raise NotImplementedError
[docs]
def build(
self,
inputs: PositionInputs,
output_path: Path,
*,
params: dict | None = None,
progress_cb: Callable[[int, int, str], None] | None = None,
) -> Path: # pragma: no cover - overridden
raise NotImplementedError
[docs]
def read(self, output_path: Path) -> Any: # pragma: no cover - overridden
raise NotImplementedError
def _import_quantifier_modules() -> None:
"""Import every (non-private) ``quantifiers`` submodule so it self-registers."""
from . import quantifiers
for info in pkgutil.iter_modules(quantifiers.__path__):
if info.name.startswith("_"):
continue
importlib.import_module(f"{quantifiers.__name__}.{info.name}")
[docs]
def available_quantifiers() -> list[type[Quantifier]]:
"""Return registered quantifier classes, sorted by display name."""
_import_quantifier_modules()
return sorted(_REGISTRY.values(), key=lambda cls: cls.display_name.lower())