"""Helpers for using minisim as test fixtures for an analysis pipeline.
minisim's second core use is supplying reproducible, ground-truth-carrying
recordings to the test suite of a calcium-imaging pipeline (minian, CaImAn,
suite2p, ...). The rest of the package gives you everything needed for that - a
typed :class:`~minisim.Spec`, :func:`~minisim.simulate`, and the recovery
:mod:`~minisim.metrics` - but assembling a small fixture and scoring a pipeline
against it both take several coordinated calls and a few conventions. This module
collapses each into one:
* :func:`make_recording` - a small, fast, deterministic :class:`~minisim.Recording`
for CI, in one call. The same ``seed`` always yields the same recording.
* :func:`score` - run the common recovery scorecard (cell recall/precision, trace
correlation, deconvolved-activity correlation / variance explained, optional
motion error) in one call, returning a :class:`Report`.
Both are thin wrappers over the public API, built on the a-la-carte metrics
(:func:`~minisim.hungarian_match`, :func:`~minisim.trace_pearson`, ...), which stay
available for anything they do not cover.
**Dependency direction.** minisim depends only on its own stack, so a pipeline can
take ``minisim.testing`` as a *test-only* dependency. See the how-to guide
``docs/howto/use_in_test_suite.md`` for the recommended ``pytest`` wiring.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Literal
import numpy as np
from minisim.metrics import (
activity_similarity,
global_shift_from_trajectories,
hungarian_match,
shift_rmse,
trace_pearson,
)
from minisim.recording import GroundTruth, Recording
from minisim.simulate import simulate
from minisim.spec import (
Acquisition,
AnyStep,
BrainMotion,
CellActivity,
CellOptics,
Composite,
ImageSensor,
NeuronPopulation,
Optics,
Output,
PlaceNeurons,
Sensor,
Spec,
)
__all__ = ["Estimate", "Report", "make_recording", "score"]
# ---------------------------------------------------------------------------
# make_recording - a one-call CI fixture
# ---------------------------------------------------------------------------
# The default fixture exposure (photons per intensity unit). Chosen so the default
# scene's cells sit bright and clearly above the detection threshold with headroom
# below the 8-bit ADC ceiling. Fixed (not auto-leveled), and the same for every
# scene, so the fixture is reproducible and a confound added via ``extra_steps``
# changes detectability only through its own physics, not through a re-leveled
# exposure. Override via the ``sensor`` argument for other regimes.
_DEFAULT_PHOTONS_PER_UNIT = 40.0
def _grid_positions_um(
n: int,
fov_um: float,
depth_um: float,
rng: np.random.Generator,
*,
margin_frac: float = 0.15,
) -> list[tuple[float, float, float]]:
"""``n`` well-separated ``(z, y, x)`` soma centers on a jittered grid.
Lays the cells on the smallest near-square grid that holds ``n`` of them,
spanning the central ``1 - 2·margin_frac`` of the FOV (so footprints stay off
the edge), all at the same depth ``z = depth_um``. A small deterministic jitter
(seeded by ``rng``) keeps them off a perfect lattice without risking overlap.
The optical-center frame puts ``(0, 0)`` on the axis, so positions are centered.
"""
cols = math.ceil(math.sqrt(n))
rows = math.ceil(n / cols)
usable = fov_um * (1.0 - 2.0 * margin_frac)
# Cell-center coordinates evenly spaced across `usable`, centered on the axis.
ys = np.linspace(-usable / 2.0, usable / 2.0, rows) if rows > 1 else np.array([0.0])
xs = np.linspace(-usable / 2.0, usable / 2.0, cols) if cols > 1 else np.array([0.0])
spacing = usable / max(rows, cols, 1)
jitter = 0.15 * spacing
out: list[tuple[float, float, float]] = []
for r in range(rows):
for c in range(cols):
if len(out) >= n:
break
dy, dx = rng.uniform(-jitter, jitter, size=2)
out.append((depth_um, float(ys[r] + dy), float(xs[c] + dx)))
return out
[docs]
def make_recording(
*,
n_cells: int = 6,
n_px: int = 128,
pixel_size_um: float = 1.0,
duration_s: float = 2.0,
fps: float = 20.0,
seed: int = 0,
depth_um: float = 50.0,
focal_depth_um: float | Literal["auto"] = "auto",
soma_radius_um: float = 5.0,
morphology: Literal["soma", "cytosolic"] = "soma",
population: NeuronPopulation | None = None,
motion: bool = False,
activity: CellActivity | None = None,
sensor: Sensor | None = None,
extra_steps: Sequence[AnyStep] = (),
save_intermediates: bool = False,
) -> Recording:
"""A small, fast, deterministic recording with ground truth, for CI tests.
Places ``n_cells`` well-separated somata on a jittered grid at a single
``depth_um`` and runs the minimal forward chain (``place_neurons →
cell_activity → optics → composite → sensor``) on a square ``n_px × n_px``
sensor at ``pixel_size_um`` per pixel. Cell count, positions, and every pixel
are fully determined by the arguments, so the same ``seed`` (and the same
arguments) always yields the same recording - the property a fixture needs.
Defaults are tuned for CI: a 128 px FOV at 1 µm/px (128 µm), six cells at
50 µm depth, two seconds at 20 fps, with a lively default activity and a fixed,
well-chosen exposure (the focal plane is ``"auto"``), so every placed cell reliably
fires, is in focus, and is brightly but non-saturatingly exposed - hence
*detectable* - with no manual tuning. Shrink ``n_px`` / ``duration_s`` for an even
faster fixture, or raise ``n_cells`` for a denser one.
Parameters
----------
n_cells
Number of somata to place (exactly this many; grid-arranged and separated).
n_px
Square sensor side, pixels (both height and width).
pixel_size_um
Object-space size of one pixel, µm. Realized via a sensor pitch of
``8.0`` µm and a matching magnification, so the FOV is ``n_px · pixel_size_um``.
duration_s, fps, seed
Sampling and the master RNG seed.
depth_um
Common depth of every soma below the tissue surface, µm. With the default
``focal_depth_um="auto"`` the focal plane resolves here and the cells are in
focus. Ignored when ``population`` is given (the population carries its own
depth distribution).
focal_depth_um
Focal plane depth in tissue, µm, or ``"auto"`` (the default) to let the
simulator pick the most-recoverable plane. Pin it to a fixed depth (e.g.
``0.0``, the surface) to hold the plane still while sweeping cell depth - the
setup a recall-vs-depth study needs, since auto-focus refocuses on each band
and would otherwise keep every cell in focus and detectable.
soma_radius_um, morphology
Cell shape: ``"soma"`` (body only, fast) or ``"cytosolic"`` (soma plus
proximal dendrites). Ignored when ``population`` is given.
population
A :class:`~minisim.NeuronPopulation` to place verbatim, bypassing the
well-separated grid. Use minisim's native volumetric placement - a
``density_per_mm3`` over a ``depth_range_um``, optionally Poisson-disk-spaced
by ``min_distance_um`` - to get a realistic, variable layout where footprints
overlap and a depth spread leaves some cells off the focal plane (so not every
planted cell is detectable). ``None`` (the default) keeps the exact-count
grid; when given, ``n_cells``, ``depth_um``, ``soma_radius_um``, and
``morphology`` are ignored (the population sets them).
motion
When ``True``, append a default :class:`~minisim.BrainMotion` step, so
``ground_truth.shifts`` is populated (lets you exercise motion correction).
activity, sensor
Override the :class:`~minisim.CellActivity` model or the
:class:`~minisim.Sensor` exposure step; ``None`` uses defaults (the default
sensor uses a fixed, well-exposed ``photons_per_unit``). Pass an explicit
``Sensor(photons_per_unit=...)`` for a dimmer or brighter recording.
extra_steps
Additional steps to append (``Neuropil``, ``Vignette``, ``Leakage``, ...);
the spec re-sorts into canonical order, so order here is free.
save_intermediates
Persist per-stage snapshots (see :class:`~minisim.Output`).
Returns
-------
Recording
``rec.observed`` is the movie, ``rec.ground_truth`` the exact truth.
"""
fov_um = n_px * pixel_size_um
# Realize pixel_size_um as pitch / magnification with a fixed 8 µm pitch.
pitch_um = 8.0
magnification = pitch_um / pixel_size_um
if population is None:
if n_cells < 1:
raise ValueError(f"n_cells ({n_cells}) must be >= 1.")
positions = _grid_positions_um(
n_cells, fov_um, depth_um, np.random.default_rng(seed)
)
population = NeuronPopulation(
positions_um=positions,
soma_radius_um=soma_radius_um,
morphology=morphology,
)
acquisition = Acquisition(
optics=Optics(magnification=magnification),
image_sensor=ImageSensor(
n_px_height=n_px, n_px_width=n_px, pixel_pitch_um=pitch_um
),
fps=fps,
duration_s=duration_s,
focal_depth_in_tissue_um=focal_depth_um,
)
steps: list[AnyStep] = [
PlaceNeurons(populations=[population]),
# Lively by default: every cell fires a transient in the short clip.
activity or CellActivity(p_quiescent_to_active=0.05),
CellOptics(),
Composite(),
# A fixed, well-chosen exposure (see _DEFAULT_PHOTONS_PER_UNIT); override via
# the sensor arg for a dimmer/brighter run.
sensor or Sensor(photons_per_unit=_DEFAULT_PHOTONS_PER_UNIT),
]
if motion:
steps.append(BrainMotion())
steps.extend(extra_steps)
spec = Spec(
acquisition=acquisition,
seed=seed,
steps=steps,
output=Output(save_intermediates=save_intermediates),
)
return simulate(spec)
# ---------------------------------------------------------------------------
# score - a one-call recovery scorecard
# ---------------------------------------------------------------------------
_UNSET: object = object() # distinguishes "not passed" from an explicit None
[docs]
@dataclass(frozen=True, init=False)
class Estimate:
"""What a pipeline recovered, as the input to :func:`score`.
The footprints are the only required field. Traces / activity / shifts are
optional; when omitted, the matching score in the :class:`Report` is ``nan``
(or ``None`` for motion). Arrays may be ``numpy`` or ``xarray`` (minian's CNMF
returns ``xr.DataArray``); both are accepted.
Each field has two interchangeable spellings - the terse CNMF/minian symbol a
pipeline already emits, and a spelled-out alias for anyone who does not speak
that dialect. **Both work as keyword arguments**, and both are readable
attributes::
Estimate(A=A, C=C, S=S) # CNMF names
Estimate(footprints=A, traces=C, activity=S) # spelled out
* ``A`` / ``footprints`` - spatial footprints, ``(n_units, height, width)``.
* ``C`` / ``traces`` - calcium traces, ``(n_units, frame)``.
* ``S`` / ``activity`` - the deconvolved estimate, ``(n_units, frame)``. **Not a
spike train**: it is a non-negative estimate of neural activity rate, scaled by
an unknown factor mapping activity to calcium-kernel amplitude. :func:`score`
compares it without binarizing and up to that unknown scale (see
:func:`~minisim.activity_similarity`).
* ``shifts`` - estimated per-frame ``(dy, dx)`` motion, ``(frame, 2)``. A motion
*correction* trajectory (the negation of the applied shift); :func:`score`
negates it to compare against ``GroundTruth.shifts`` and to recover the global
footprint offset.
A frozen dataclass (not a ``NamedTuple``) on purpose: keyword construction with
no positional contract, so a future field can be added without breaking callers
that pin the current ones - the property a long-lived public scoring contract
needs.
"""
A: object
C: object | None = None
S: object | None = None
shifts: object | None = None
def __init__(
self,
A: object = _UNSET,
C: object | None = None,
S: object | None = None,
shifts: object | None = None,
*,
footprints: object = _UNSET,
traces: object | None = None,
activity: object | None = None,
) -> None:
a = footprints if footprints is not _UNSET else A
if a is _UNSET:
raise TypeError(
"Estimate requires the footprints (pass A=... or footprints=...)."
)
if footprints is not _UNSET and A is not _UNSET:
raise TypeError("pass footprints= or A=, not both.")
# Frozen dataclass: bypass the immutability guard to set the canonical fields.
object.__setattr__(self, "A", a)
object.__setattr__(self, "C", traces if C is None else C)
object.__setattr__(self, "S", activity if S is None else S)
object.__setattr__(self, "shifts", shifts)
@property
def footprints(self) -> object:
"""Spelled-out alias for :attr:`A` (spatial footprints)."""
return self.A
@property
def traces(self) -> object | None:
"""Spelled-out alias for :attr:`C` (calcium traces)."""
return self.C
@property
def activity(self) -> object | None:
"""Spelled-out alias for :attr:`S` (the deconvolved activity estimate)."""
return self.S
[docs]
@dataclass(frozen=True)
class Report:
"""The recovery scorecard :func:`score` returns.
Cell counts and spatial scores are always present; temporal scores are ``nan``
when the matching estimate field was not supplied, and ``shift_rmse`` is
``None`` when motion truth or a motion estimate is absent.
**The recall denominator is reported explicitly**, because it is not obvious:
by default :func:`score` scores against the *detectable* cells, so ``recall``
is over ``n_true`` (= ``n_detectable``), which can be fewer than the cells that
were planted. The three counts make that legible at a glance - ``recall = 1.0``
with ``n_detectable=4 < n_requested=6`` means "recovered every detectable cell,
but two planted cells were too dim to detect", not "recovered everything". See
:func:`score`'s ``restrict_to_detectable``.
"""
n_true: int # ground-truth cells scored against = the recall denominator
n_est: int # estimated cells supplied
n_matched: int # matched pairs clearing the similarity threshold (true positives)
recall: float # n_matched / n_true
precision: float # n_matched / n_est
f1: float # harmonic mean of precision and recall (0 when both are 0)
mean_overlap: (
float # mean footprint similarity over matched pairs (the match metric)
)
trace_corr: float # median Pearson r of matched traces (nan if no C / no match)
activity_corr: (
float # median Pearson r of matched activity (nan if no S / no match)
)
activity_variance_explained: float # median PVE of true activity by scaled estimate
activity_scale: float # median recovered amplitude factor alpha (the unknown gain)
shift_rmse: float | None # motion RMSE in px, offset-aligned (None if no motion)
n_requested: int # total ground-truth cells planted (the full population)
n_detectable: int # cells clearing the detection floor (the detectable subset)
match_metric: str = "iou" # footprint similarity used for matching
footprint_shift: tuple[int, int] = (0, 0) # global (dy, dx) applied to the estimate
[docs]
def summary(self) -> str:
"""A compact one-line-per-metric string, handy for a test failure message."""
lines = [
f"cells: recall={self.recall:.2f} precision={self.precision:.2f} "
f"f1={self.f1:.2f} (matched {self.n_matched}/{self.n_true}, "
f"mean {self.match_metric} {self.mean_overlap:.2f})",
f"population: {self.n_detectable} detectable of {self.n_requested} "
f"planted (recall denominator = {self.n_true})",
f"traces: median r={self.trace_corr:.2f}",
f"activity: median r={self.activity_corr:.2f} "
f"variance explained={self.activity_variance_explained:.2f} "
f"(scale={self.activity_scale:.2g})",
]
if self.footprint_shift != (0, 0):
lines.append(f"footprint shift applied: {self.footprint_shift} px (dy, dx)")
if self.shift_rmse is not None:
lines.append(f"motion: RMSE={self.shift_rmse:.2f} px (offset-aligned)")
return "\n".join(lines)
[docs]
def score(
estimate: Estimate,
ground_truth: GroundTruth,
*,
match_metric: str = "iou",
match_threshold: float = 0.5,
restrict_to_detectable: bool = True,
footprint_shift: tuple[float, float] | str | None = "auto",
) -> Report:
"""Score a pipeline's :class:`Estimate` against the ground truth, in one call.
Runs the standard recovery pipeline: match estimated footprints to true ones by
spatial overlap (:func:`~minisim.hungarian_match` against ``A_observed``, the
recoverable target), then score the temporal recovery of the matched pairs. The
conventions the a-la-carte recipe asks you to remember are applied here:
* matches against ``A_observed`` (not the optics-free ``A_planted``);
* scores recall over the **detectable** cells by default (the fair denominator;
see :meth:`~minisim.GroundTruth.detectable_subset`). The returned
:class:`Report` always carries ``n_requested`` (cells planted) and
``n_detectable`` alongside ``n_true`` (the denominator used), so a high
``recall`` over a shrunken denominator can never be mistaken for "recovered
everything";
* **absorbs a global footprint offset** before matching, so a uniform shift from
the pipeline's motion-correction frame is not scored as a miss (see
``footprint_shift``);
* reduces per-pair trace correlations with a nan-safe median;
* scores the deconvolved activity without binarizing and up to an unknown scale
(:func:`~minisim.activity_similarity`), reducing each quantity with a nan-safe
median;
* treats an estimated motion trajectory as a *correction* (negated) and aligns
away a constant origin offset when comparing to ``GroundTruth.shifts``.
Parameters
----------
estimate
The pipeline output. Only :attr:`Estimate.A` is required.
ground_truth
The recording's ``ground_truth``.
match_metric
Footprint similarity for matching (see :data:`~minisim.metrics.SIMILARITY_METRICS`):
``"iou"`` (binary, default), ``"cosine"``, or ``"weighted_jaccard"`` (the
weighted metrics let pixel weights, not just lit pixels, drive the match).
match_threshold
Minimum similarity for a matched pair to count as a true positive.
restrict_to_detectable
Score against :meth:`~minisim.GroundTruth.detectable_subset` (default).
Set ``False`` to score against every planted cell, detectable or not.
footprint_shift
How to handle a global translational offset between the estimated
(motion-corrected) footprints and the true ones. ``"auto"`` (default) reads
the offset off the motion trajectories when both ``estimate.shifts`` and
``ground_truth.shifts`` are present (exact), and otherwise estimates it by
aligning the footprint centroids. Pass a ``(dy, dx)`` to force a known shift,
or ``None`` to disable. The applied integer shift is recorded on the
:class:`Report`.
Returns
-------
Report
The scorecard. Unsupplied estimate fields score ``nan`` / ``None``.
"""
# Record the full population and the detectable count up front, so the Report
# always shows what the recall denominator (match.n_true) was drawn from -
# whether or not the detectable filter is applied.
n_requested = ground_truth.n_units
n_detectable = int(np.asarray(ground_truth.detectable).sum())
gt = ground_truth.detectable_subset() if restrict_to_detectable else ground_truth
shift = _resolve_footprint_shift(footprint_shift, estimate, gt)
match = hungarian_match(estimate.A, gt.A_observed, metric=match_metric, shift=shift)
matched = match.matched_pairs(match_threshold)
n_matched = len(matched)
recall = n_matched / match.n_true if match.n_true else 0.0
precision = n_matched / match.n_est if match.n_est else 0.0
f1 = (
2.0 * precision * recall / (precision + recall) if (precision + recall) else 0.0
)
if estimate.C is not None and match.pairing:
r = trace_pearson(estimate.C, gt.C, match.pairing)
trace_corr = float(np.nanmedian(r)) if r.size else float("nan")
else:
trace_corr = float("nan")
if estimate.S is not None and match.pairing:
act = activity_similarity(estimate.S, gt.S, match.pairing)
activity_corr = (
float(np.nanmedian(act.correlation))
if act.correlation.size
else float("nan")
)
activity_var = (
float(np.nanmedian(act.variance_explained))
if act.variance_explained.size
else float("nan")
)
activity_scale = (
float(np.nanmedian(act.scale)) if act.scale.size else float("nan")
)
else:
activity_corr = activity_var = activity_scale = float("nan")
rmse = (
shift_rmse(estimate.shifts, gt.shifts, correction=True, align=True)
if estimate.shifts is not None and gt.shifts is not None
else None
)
return Report(
n_true=match.n_true,
n_est=match.n_est,
n_matched=n_matched,
recall=recall,
precision=precision,
f1=f1,
mean_overlap=match.mean_similarity,
trace_corr=trace_corr,
activity_corr=activity_corr,
activity_variance_explained=activity_var,
activity_scale=activity_scale,
shift_rmse=rmse,
n_requested=n_requested,
n_detectable=n_detectable,
match_metric=match.metric,
footprint_shift=match.shift,
)
def _resolve_footprint_shift(
footprint_shift: tuple[float, float] | str | None,
estimate: Estimate,
gt: GroundTruth,
) -> tuple[float, float] | str | None:
"""Pick the ``shift`` to hand :func:`~minisim.hungarian_match`.
For ``"auto"``: when both the estimated and true motion trajectories are present
the offset is read off them exactly (the constant difference between the
pipeline's correction and the applied motion); otherwise it falls back to
``"auto"`` centroid estimation. Any explicit value (a ``(dy, dx)`` or ``None``)
passes straight through.
"""
if footprint_shift != "auto":
return footprint_shift
if estimate.shifts is not None and gt.shifts is not None:
return global_shift_from_trajectories(
estimate.shifts, gt.shifts, correction=True
)
return "auto"