Sweep parameters#

sweep() takes a base Spec and a dict of dotted-path → list of values and yields one validated spec per point in their Cartesian product. Because the axes are physically meaningful (depth in µm, NA, density in cells/mm³), a sweep traces a scientifically interpretable surface, for example “recall vs depth at NA 0.45”.

Define the axes#

Paths address any field in the spec:

  • nested models: "acquisition.optics.na",

  • a step by its (unique) kind: "steps.place_neurons.density_per_mm3",

  • a top-level field: "seed".

from minisim import sweep

for variant in sweep(base_spec, {
    "acquisition.optics.na": [0.3, 0.45, 0.6],
    "acquisition.focal_depth_in_tissue_um": [50.0, 100.0, 150.0],
}):
    print(variant.axes)   # e.g. {'acquisition.optics.na': 0.45, ...}

Each yielded SweptSpec is a real Spec (it drops straight into simulate) tagged with an axes dict recording the chosen value per path. Every cross-field validator re-runs per combination, so an axis value that produces an invalid spec raises immediately rather than at simulate time.

Collect a benchmark surface#

Combine a sweep with the benchmarking recipe to build a tidy table keyed on the physical axes:

import pandas as pd
from minisim import simulate, hungarian_match

rows = []
for variant in sweep(base_spec, {
    "acquisition.optics.na": [0.3, 0.45, 0.6],
    "acquisition.focal_depth_in_tissue_um": [50.0, 100.0, 150.0],
}):
    rec = simulate(variant)
    det = rec.ground_truth.detectable_subset()
    A_est, _, _ = run_my_pipeline(rec.observed)
    match = hungarian_match(A_est, det.A_observed)
    rows.append({**variant.axes, "recall": match.recall()})

df = pd.DataFrame(rows)   # one row per (na, depth) point, ready to pivot/plot

Worked example: a focus × separation grid#

A concrete two-axis sweep. Place two independent cells at a known depth, then sweep the focal plane (a scalar axis) against their lateral separation (swept by overriding the whole place_neurons.populations field with a fresh pair per value). At each grid point we do the most naive trace extraction there is - a small ROI dropped on each cell’s true position, pixels averaged per frame - and correlate the two ROI traces. The cells fire independently, so their true correlation is ~0; anything above that is optical blur bleeding one cell into the other’s ROI.

import numpy as np
import pandas as pd

from minisim import (
    Acquisition, CellActivity, CellOptics, Composite, NeuronPopulation,
    PlaceNeurons, Sensor, Spec, Tissue, simulate, sweep,
)
from minisim.presets import miniscope_v4

DEPTHS_UM, ROI_DIAM_UM = (100.0, 110.0), 20.0
FOCAL_PLANES_UM = [85.0, 95.0, 105.0, 115.0, 125.0]
SEPARATIONS_UM = [0.0, 10.0, 20.0, 30.0, 40.0, 50.0]


def two_cells(sep_um):                        # a pair straddling the axis at y = 0
    return NeuronPopulation(
        positions_um=[(DEPTHS_UM[0], 0.0, -sep_um / 2), (DEPTHS_UM[1], 0.0, sep_um / 2)],
        soma_radius_um=5.0, morphology="cytosolic",
    )


def roi_trace(observed, acq, y_um, x_um):     # brute-force extraction: mean of an ROI
    h, w = observed.shape[1:]
    cr, cc = acq.um_to_index(y_um, x_um, (h, w))      # true position -> pixel
    radius_px = (ROI_DIAM_UM / 2) / acq.pixel_size_um
    rr, cols = np.ogrid[:h, :w]
    mask = (rr - cr) ** 2 + (cols - cc) ** 2 <= radius_px**2
    return observed[:, mask].mean(axis=1)


# V4 optics with the sensor cropped to 128 px (so the grid runs in seconds), a
# default Tissue scatter model, and the standard cell chain. focal_depth and the
# cell pair here are placeholders that the sweep overrides per grid point.
v4 = miniscope_v4()
small = v4.image_sensor.model_copy(update={"n_px_height": 128, "n_px_width": 128})
base = Spec(
    acquisition=Acquisition(
        optics=v4.optics, image_sensor=small, tissue=Tissue(),
        fps=10.0, duration_s=60.0,           # 60 s so the true correlation settles to ~0
    ),
    seed=0,
    steps=[
        PlaceNeurons(populations=[two_cells(0.0)]),
        CellActivity(), CellOptics(), Composite(),
        Sensor(photons_per_unit=250.0),
    ],
)

rows = []
for variant in sweep(base, {
    "acquisition.focal_depth_in_tissue_um": FOCAL_PLANES_UM,            # scalar axis
    "steps.place_neurons.populations": [[two_cells(s)] for s in SEPARATIONS_UM],
}):
    rec = simulate(variant)
    obs, gt, acq = np.asarray(rec.observed), rec.ground_truth, rec.spec.acquisition
    (_, y0, x0), (_, y1, x1) = gt.centers_um
    t0, t1 = roi_trace(obs, acq, y0, x0), roi_trace(obs, acq, y1, x1)
    sep = x1 - x0
    rows.append({
        "focal_depth_um": variant.axes["acquisition.focal_depth_in_tissue_um"],
        "separation_um": sep,
        "roi_corr": 1.0 if sep == 0.0 else float(np.corrcoef(t0, t1)[0, 1]),
    })

grid = pd.DataFrame(rows).pivot(
    index="focal_depth_um", columns="separation_um", values="roi_corr"
)

Sweeping a list-valued field (populations) works because each combination is re-validated from its canonical dump - the same round-trip simulate_cached and JSON reload use - so a hand-placed pair survives the sweep intact.

a 5x6 grid of max-projection thumbnails over focal plane and separation, each with the ROI drawn to scale

Max projection at each grid point (focal plane down, separation across; red circle = the 20 µm ROI, to scale). The pair is sharpest and best-separated on the in-focus 105 µm row and blurs back together off-focus.#

a heatmap of ROI-trace correlation over focal plane and separation

The grid table as a heatmap. Correlation is 1.0 when the ROIs overlap (Δ = 0), stays high while the cells share blur (Δ ≤ 10 µm), and relaxes to the ~0.1 independent-cell floor once they separate (Δ ≥ 30 µm). At the marginal Δ = 20 µm the focal plane matters most - the in-focus 105 µm row roughly halves the crosstalk.#

The full figure-generating script is scripts/gen_overlap_focus_grid.py.

Notes#

  • axes is excluded from serialization, so SweptSpec.cache_key() equals the equivalent plain spec’s. Sweeping does not perturb cache dedup, and the tag vanishes when a recording is saved.

  • An empty axes dict yields the base spec once, with axes={}.

  • For expensive sweeps, swap simulate for simulate_cached() so repeated points load instead of recompute.