"""The ``simulate()`` orchestrator - compose a ``Spec`` into a ``Recording``.
This is the package's headline entry point: it walks ``spec.steps`` in order,
building and running each against a shared ``Scene``, then hands the exhausted
scene to :func:`~minisim.recording.finalize`. The loop itself is
deliberately tiny - the same readable ``for step in spec.steps`` a learner
follows - with two responsibilities beyond running the steps:
* **Motion margin sizing.** If the spec contains a ``brain_motion`` step, the
tissue canvas must be padded by ≥ the maximum shift so real off-FOV tissue
moves into view (see :mod:`minisim.steps.motion`). ``simulate``
computes that margin from the motion spec and allocates the padded ``Scene``,
so callers never hand-size it.
* **Per-stage snapshots.** With ``Output.save_intermediates`` set, the working
movie is captured after each *movie-affecting* (non-``cell``-domain) step,
keyed by the step's stage ``name`` (``cells_only``, ``neuropil``, …,
``sensor``). Cell-domain steps fill per-cell records, not the movie, so
snapshotting them would only duplicate the prior (often blank) frame.
``until=<stage name>`` stops the run right after that stage - the partial-build
path the training notebook uses to reveal the pipeline one effect at a time.
"""
from __future__ import annotations
import numpy as np
from minisim.perf import PerfTracker, measure
from minisim.recording import Recording, finalize
from minisim.scene import Scene
from minisim.spec import Acquisition, Spec
from minisim.steps.base import PipelineContext
from minisim.steps.sensor import combined_falloff_field
[docs]
def simulate(
spec: Spec, *, until: str | None = None, perf: PerfTracker | None = None
) -> Recording:
"""Run a full recording specification and return the typed ``Recording``.
Seeds the RNG from ``spec.seed`` (so a spec + seed fully determines the
output), sizes the motion margin, runs the steps in ``spec.steps`` order -
already the canonical pipeline order, since ``Spec`` sorts the list on
construction, so the order a caller listed them in is irrelevant - optionally
snapshots each movie stage, and finalizes. ``until`` stops after the named
stage (a ``step.name``, e.g. ``"vignette"``); an ``until`` that matches no
step raises rather than silently running the whole pipeline.
Pass a :class:`~minisim.perf.PerfTracker` as ``perf`` to record per-step (and
``finalize``) wall time; it is a no-op when ``None`` (the default), so an
un-profiled run pays nothing for the instrumentation.
"""
acq = spec.acquisition
rng = np.random.default_rng(spec.seed)
scene = Scene.zeros(acq, rng, margin_px=_motion_margin_px(spec, acq))
context = build_context(spec, acq)
stopped = False
stage_names: list[str] = []
for step_spec in spec.steps:
step = step_spec.build(acq, rng)
step.prepare(context)
with measure(perf, step.name, domain=step.domain):
step(scene)
stage_names.append(step.name)
if spec.output.save_intermediates and step.domain != "cell":
scene.snapshots[step.name] = scene.movie.copy()
if until is not None and step.name == until:
stopped = True
break
if until is not None and not stopped:
raise ValueError(
f"until={until!r} matched no step in this spec; stage names are {stage_names}."
)
with measure(perf, "finalize"):
return finalize(scene, spec)
def build_context(spec: Spec, acq: Acquisition) -> PipelineContext:
"""Resolve the :class:`~minisim.steps.base.PipelineContext` for a run.
Looks up the sensor-domain specs that earlier cell-domain steps depend on (the
illumination profile, the sensor spec, and the illumination × vignette photon
budget) so each step can pull what it needs from the context in ``prepare``.
Each slot falls back to ``None`` / a uniform field when its step is absent.
Shared with the streaming writer (:func:`minisim.video._iter_count_frames`) so
both paths resolve the same context and make identical focus/bleaching
decisions, keeping the streamed counts bit-for-bit equal to ``simulate``.
"""
illumination = next((s for s in spec.steps if s.kind == "illumination_profile"), None)
vignette = next((s for s in spec.steps if s.kind == "vignette"), None)
sensor_spec = next((s for s in spec.steps if s.kind == "sensor"), None)
photon_field = combined_falloff_field(acq, illumination, vignette)
return PipelineContext(
illumination=illumination, sensor_spec=sensor_spec, photon_field=photon_field
)
def _motion_margin_px(spec: Spec, acq: Acquisition) -> int:
"""Tissue-canvas margin (px) needed to keep the FOV crop on real tissue.
Zero when the spec has no ``brain_motion`` step. Otherwise the maximum shift
(the bound for a random walk, or the largest entry of an explicit trajectory)
converted to pixels and rounded up, plus a one-pixel guard for the bilinear
interpolation boundary. ``brain_motion`` then fails fast if a shift somehow
exceeds this margin.
"""
motion = next((s for s in spec.steps if s.kind == "brain_motion"), None)
if motion is None:
return 0
if motion.trajectory_um is not None:
max_um = max(
(max(abs(dy), abs(dx)) for dy, dx in motion.trajectory_um), default=0.0
)
else:
max_um = motion.max_shift_um
px = acq.um_to_px(max_um)
return int(np.ceil(px)) + 1 if px > 0 else 0