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.
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.#
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#
axesis excluded from serialization, soSweptSpec.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
axesdict yields the base spec once, withaxes={}.For expensive sweeps, swap
simulateforsimulate_cached()so repeated points load instead of recompute.