diff --git a/docs/tutorials/biophysics-and-growth.md b/docs/tutorials/biophysics-and-growth.md index dcea61b..0b72a64 100644 --- a/docs/tutorials/biophysics-and-growth.md +++ b/docs/tutorials/biophysics-and-growth.md @@ -32,13 +32,17 @@ The controller stores one stochastic division target per stable cell ID. On each ### Length and volume -The tutorial uses centerline length as its division threshold. MicroSimulator uses the effective capsule volume +The tutorial uses centerline length as its division threshold. MicroSimulator uses the conserved biochemical biomass volume ```text -V = pi r^2 (length + 2r) +B = pi r^2 (length + 2r) ``` -for concentration dilution and cell-grid exchange. If an experiment requires a volume-based division rule, compute that threshold explicitly in the regulation callback. +for concentration dilution and cell-grid exchange. This differs from geometric capsule volume, `V_geom = pi r^2 length + (4/3) pi r^3`. A length threshold is neither of these volumes. If an experiment requires a volume-based division rule, compute that threshold explicitly in the regulation callback. + +During new tutorial construction, each founder target is sampled exactly once from the model random stream. The requested founder centerline length is preserved when valid and otherwise capped at that target. Native single-precision lengths are rounded downward when needed to stay at or below the sampled value; the target itself is unchanged. No threshold rejection sampling is used. Regulation retains the strict `length > target` comparison: a zero-time step does not divide a newly initialized founder, while later growth can. + +`UniformLengthDivision.initialize_founders(simulation, state, rng, founders)` applies this opt-in policy before adding cells. Custom policies use `capped_founder_length(requested, target)` after sampling. The culture-dish founders use the same policy, preserving each requested `3.0 + 0.2 * index` length when valid. No ordinary tutorial intentionally starts above its target. The lower-level `growth_and_division.py` demonstrates explicit division without a stochastic threshold, and `native_controller.py` permits explicit `initial_length` parameters for model experiments; its ordinary default 3.0 is below its 4.0 threshold. Existing `initialize(state, rng, cell_ids)` and raw `Simulation.add_cell()` remain available for intentionally oversized cells. Resume restores saved geometry and targets without calling a founder initializer. The existing source-digest guard still requires the exact model file recorded in a checkpoint; retain that file when continuing a run made with an older tutorial version. Conjugation uses its original Gaussian target distribution; an invalid negative target raises an error instead of being resampled or silently changed. ## 2. Two founder types diff --git a/docs/tutorials/discrete-state-and-contacts.md b/docs/tutorials/discrete-state-and-contacts.md index cc9ff72..c7b8e5e 100644 --- a/docs/tutorials/discrete-state-and-contacts.md +++ b/docs/tutorials/discrete-state-and-contacts.md @@ -2,6 +2,8 @@ This tutorial uses plasmid segregation and conjugation to show how discrete biological state, stochastic events, and contact-dependent behavior fit into a MicroSimulator model. +New founders preserve their requested length unless it exceeds the single sampled division target. This also handles the rare short Gaussian target in the conjugation model without rejection sampling. Checkpoint restoration keeps stored lengths and targets. See [founder initialization](biophysics-and-growth.md#length-and-volume). + ## 1. Incompatible plasmid segregation ```console diff --git a/docs/tutorials/intracellular-dynamics.md b/docs/tutorials/intracellular-dynamics.md index 9998c6e..1bcb155 100644 --- a/docs/tutorials/intracellular-dynamics.md +++ b/docs/tutorials/intracellular-dynamics.md @@ -2,6 +2,8 @@ This tutorial introduces intracellular concentrations, growth dilution, typed rate equations, gene-circuit feedback, and quantitative time-course analysis. The runnable scenarios are collected in `examples/tutorials/gene_expression.py`. +All five gene-expression scenarios request a founder centerline length of 3.5 and cap it at the one sampled division target. Initial concentrations are unchanged; the smaller biomass can change total initial amount. See [founder initialization and volume conventions](biophysics-and-growth.md#length-and-volume). + ## The native species contract A simulation declares one immutable species count. Each cell contains exactly that many finite single-precision concentrations. A typed rate plan returns one concentration-per-time derivative for each channel. diff --git a/docs/tutorials/microfluidics.md b/docs/tutorials/microfluidics.md index a294b6e..36d68b9 100644 --- a/docs/tutorials/microfluidics.md +++ b/docs/tutorials/microfluidics.md @@ -2,6 +2,8 @@ This tutorial connects device geometry, flowing media, and cell biology in runnable MicroSimulator models. The [modeling guide](../microfluidics.md) introduces the workflow and the choice of flow solver. Four examples cover the range: +The microfluidic-trap, Danino, biopixel, and pillar tutorial founders request centerline length 3.5, capped at their single sampled target in [3.2, 3.8]. Attachment, position, radius, and concentrations are preserved. This affects new construction only; saved geometry is restored unchanged. See [founder initialization and volume conventions](biophysics-and-growth.md#length-and-volume). + | Model | Device | Demonstrates | | --- | --- | --- | | [`examples/culture_dish.py`](../../examples/culture_dish.py) | round dish | one inside-cylinder constraint as a dish | diff --git a/docs/tutorials/signaling.md b/docs/tutorials/signaling.md index bf4733b..e617be3 100644 --- a/docs/tutorials/signaling.md +++ b/docs/tutorials/signaling.md @@ -2,6 +2,8 @@ This tutorial introduces extracellular grids, diffusion, cell-grid exchange, sender-receiver communication, and two-strain mutualism. Run the scenarios in `examples/tutorials/signaling.py` with a small time step such as `0.01`. +Each new founder requests centerline length 3.5 and is capped at its sampled division threshold. Its radius, position, cell type, and initial concentrations are preserved. See [founder initialization](biophysics-and-growth.md#length-and-volume). + ## Grid geometry and units A `SignalGridSpec` declares channel count, lattice shape, physical origin, spacing, diffusion coefficients, advection velocities, integration method, and six boundary conditions. Grid levels are concentrations. A coupled rate plan returns: diff --git a/examples/culture_dish.py b/examples/culture_dish.py index b4c50a2..f1378d8 100644 --- a/examples/culture_dish.py +++ b/examples/culture_dish.py @@ -58,7 +58,7 @@ def build(context: ModelContext) -> NativeController: simulation = context.simulation(reserved_capacity=20_000) _add_dish(simulation) - founder_ids = [] + founders: list[CellInit] = [] for index in range(FOUNDER_COUNT): placement = context.rng.uniform(0.0, 2.0 * math.pi) # The square root spreads founders uniformly over the seeded area @@ -75,10 +75,10 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.0 + 0.2 * index founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_ids.append(simulation.add_cell(founder)) + founders.append(founder) state: dict[str, JSONValue] = {"scope": "culture-dish"} - DIVISION.initialize(state, context.rng, tuple(founder_ids)) + DIVISION.initialize_founders(simulation, state, context.rng, tuple(founders)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/microfluidic_trap.py b/examples/microfluidic_trap.py index 2d812b7..6fd8af6 100644 --- a/examples/microfluidic_trap.py +++ b/examples/microfluidic_trap.py @@ -152,9 +152,8 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.5 founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "microfluidic-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/biophysics.py b/examples/tutorials/biophysics.py index e5edb13..a41a9f9 100644 --- a/examples/tutorials/biophysics.py +++ b/examples/tutorials/biophysics.py @@ -23,6 +23,7 @@ Simulation, StepPlan, Vec3, + capped_founder_length, ) from microsimulator.checkpoint import CheckpointBundle, JSONValue @@ -155,9 +156,11 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = _growth_rate(scenario, cell_type) founder.cell_type = cell_type - founder_id = simulation.add_cell(founder) lower, upper = _target_range(scenario, cell_type, founder=True) - targets[str(founder_id)] = context.rng.uniform(lower, upper) + target = context.rng.uniform(lower, upper) + founder.length = capped_founder_length(founder.length, target) + founder_id = simulation.add_cell(founder) + targets[str(founder_id)] = target regulate, divided = _callbacks(scenario) mechanics = MechanicsConfig(gamma=20.0 if scenario == "box" else 10.0) diff --git a/examples/tutorials/biopixel_trap.py b/examples/tutorials/biopixel_trap.py index 900c089..c7ee663 100644 --- a/examples/tutorials/biopixel_trap.py +++ b/examples/tutorials/biopixel_trap.py @@ -182,9 +182,8 @@ def build(context: ModelContext) -> NativeController: founder.length = 3.5 founder.radius = CELL_RADIUS founder.growth_rate = 1.0 - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "biopixel-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/conjugation.py b/examples/tutorials/conjugation.py index 20c7371..83e6167 100644 --- a/examples/tutorials/conjugation.py +++ b/examples/tutorials/conjugation.py @@ -16,6 +16,7 @@ NativeController, StepPlan, Vec3, + capped_founder_length, ) from microsimulator.checkpoint import CheckpointBundle, JSONValue @@ -92,8 +93,10 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.4 founder.growth_rate = 1.0 founder.cell_type = cell_type + target = founder.length + context.rng.gauss(1.9, 0.45) + founder.length = capped_founder_length(founder.length, target) founder_id = simulation.add_cell(founder) - targets[str(founder_id)] = founder.length + context.rng.gauss(1.9, 0.45) + targets[str(founder_id)] = target regulate, divided = _callbacks(transfer_probability) return NativeController( diff --git a/examples/tutorials/danino_clock.py b/examples/tutorials/danino_clock.py index 31c88bc..57291a3 100644 --- a/examples/tutorials/danino_clock.py +++ b/examples/tutorials/danino_clock.py @@ -244,9 +244,8 @@ def build(context: ModelContext) -> NativeController: founder.radius = CELL_RADIUS founder.growth_rate = 1.0 founder.species = [context.rng.uniform(0.0, 0.2), context.rng.uniform(0.0, 0.2), 0.0] - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"scope": "clock-nutrient-field-and-trap"} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/gene_expression.py b/examples/tutorials/gene_expression.py index 0dea834..ae31918 100644 --- a/examples/tutorials/gene_expression.py +++ b/examples/tutorials/gene_expression.py @@ -112,11 +112,10 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = _growth_rate(scenario) founder.species = initial_species - founder_id = simulation.add_cell(founder) division, regulate = _callbacks(scenario) state: dict[str, JSONValue] = {"scenario": scenario} - division.initialize(state, context.rng, (founder_id,)) + division.initialize_founders(simulation, state, context.rng, (founder,)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/pillar_channel.py b/examples/tutorials/pillar_channel.py index b3e7340..c8dcfe5 100644 --- a/examples/tutorials/pillar_channel.py +++ b/examples/tutorials/pillar_channel.py @@ -214,7 +214,7 @@ def build(context: ModelContext) -> NativeController: simulation.set_coupled_rate_plan(_rate_plan()) _add_walls(simulation) - founder_ids: list[int] = [] + founder_ids: list[CellInit] = [] for x, y in FOUNDER_SITES: founder = CellInit() founder.position = Vec3(x, y, 0.0) @@ -223,9 +223,9 @@ def build(context: ModelContext) -> NativeController: founder.radius = CELL_RADIUS founder.growth_rate = 1.0 founder.fixed = True - founder_ids.append(simulation.add_cell(founder)) + founder_ids.append(founder) state: dict[str, JSONValue] = {"scope": "pillar-channel"} - DIVISION.initialize(state, context.rng, tuple(founder_ids)) + DIVISION.initialize_founders(simulation, state, context.rng, tuple(founder_ids)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/plasmid_segregation.py b/examples/tutorials/plasmid_segregation.py index df37491..cc48ad2 100644 --- a/examples/tutorials/plasmid_segregation.py +++ b/examples/tutorials/plasmid_segregation.py @@ -11,6 +11,7 @@ CellInit, ModelContext, Simulation, + capped_founder_length, capture_random_state, restore_random_state, ) @@ -164,10 +165,12 @@ def build(context: ModelContext) -> PlasmidController: first_count = copies_per_cell // 2 second_count = copies_per_cell - first_count founder.species = [first_count / copies_per_cell, second_count / copies_per_cell] + target = context.rng.uniform(3.5, 4.0) + founder.length = capped_founder_length(founder.length, target) founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = { "plasmids": {str(founder_id): {"a": first_count, "b": second_count}}, - "division_targets": {str(founder_id): context.rng.uniform(3.5, 4.0)}, + "division_targets": {str(founder_id): target}, } return PlasmidController( simulation, diff --git a/examples/tutorials/signaling.py b/examples/tutorials/signaling.py index 1a6cbb9..bc54abd 100644 --- a/examples/tutorials/signaling.py +++ b/examples/tutorials/signaling.py @@ -170,7 +170,7 @@ def build(context: ModelContext) -> NativeController: if scenario == "communication" else ((0, 0.0),) ) - founders: list[int] = [] + founders: list[CellInit] = [] for cell_type, x in founder_specs: founder = CellInit() founder.position = Vec3(x, 0.0, 0.0) @@ -179,11 +179,11 @@ def build(context: ModelContext) -> NativeController: founder.growth_rate = 1.0 if scenario == "mutualism" else 2.0 founder.cell_type = cell_type founder.species = [0.0] * species_count - founders.append(simulation.add_cell(founder)) + founders.append(founder) division, regulate = _callbacks(scenario) state: dict[str, JSONValue] = {"scenario": scenario} - division.initialize(state, context.rng, tuple(founders)) + division.initialize_founders(simulation, state, context.rng, tuple(founders)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/examples/tutorials/simbol_circuits.py b/examples/tutorials/simbol_circuits.py index 63bec1b..921e933 100644 --- a/examples/tutorials/simbol_circuits.py +++ b/examples/tutorials/simbol_circuits.py @@ -208,9 +208,8 @@ def build(context: ModelContext) -> NativeController: founder.radius = 0.5 founder.growth_rate = 1.0 founder.species = initial_species - founder_id = simulation.add_cell(founder) state: dict[str, JSONValue] = {"circuit": circuit} - DIVISION.initialize(state, context.rng, (founder_id,)) + DIVISION.initialize_founders(simulation, state, context.rng, (founder,)) return NativeController( simulation, model_id=MODEL_ID, diff --git a/python/src/microsimulator/__init__.py b/python/src/microsimulator/__init__.py index 156f409..d41483a 100644 --- a/python/src/microsimulator/__init__.py +++ b/python/src/microsimulator/__init__.py @@ -92,7 +92,7 @@ capture_random_state, restore_random_state, ) -from .division import UniformLengthDivision +from .division import UniformLengthDivision, capped_founder_length from .legacy import LegacyCell, LegacyCompatibilityError, LegacyModelAdapter from .legacy_loader import build_legacy_model, resume_legacy_model from .legacy_pickle import LegacyPickleError, LegacyPickleImport, import_legacy_pickle @@ -256,6 +256,7 @@ "backend_device_count", "build_legacy_model", "build_model", + "capped_founder_length", "capture_random_state", "capture_scene", "dumps_scene", diff --git a/python/src/microsimulator/division.py b/python/src/microsimulator/division.py index 0b013d0..a8c756e 100644 --- a/python/src/microsimulator/division.py +++ b/python/src/microsimulator/division.py @@ -8,7 +8,9 @@ from dataclasses import dataclass from typing import cast -from ._core import Vec3 # pyright: ignore[reportMissingModuleSource] +import numpy as np + +from ._core import CellInit, Simulation, Vec3 # pyright: ignore[reportMissingModuleSource] from .checkpoint import JSONValue from .controller import ControllerStateError, ControllerStep, DivisionEvent, DivisionRequest @@ -19,6 +21,23 @@ def _valid_cell_id(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value > 0 +def capped_founder_length(requested: float, target: float) -> float: + """Return a native-representable centerline length no greater than target. + + This is an opt-in model initialization policy. It never changes the sampled + target, and must not be applied when restoring existing cells. + """ + if any(not math.isfinite(value) or value < 0.0 for value in (requested, target)): + raise ValueError("founder length and target must be finite and non-negative") + bounded = min(requested, target) + if bounded > float(np.finfo(np.float32).max): + raise ValueError("founder length exceeds native single precision range") + result = np.float32(bounded) + if float(result) > bounded: + result = np.nextafter(result, np.float32(0.0)) + return float(result) + + @dataclass(frozen=True, slots=True) class UniformLengthDivision: """Divide above per-cell thresholds sampled from one uniform distribution.""" @@ -44,6 +63,32 @@ def __post_init__(self) -> None: def _sample(self, rng: random.Random) -> float: return rng.uniform(self.minimum, self.maximum) + def initialize_founders( + self, + simulation: Simulation, + state: dict[str, JSONValue], + rng: random.Random, + founders: tuple[CellInit, ...], + ) -> tuple[int, ...]: + """Sample once per founder, cap its length, then add it to the simulation. + + Mutates only each input's length. Use ``initialize`` with existing IDs + instead for intentionally oversized founders. Neither initializer runs + during checkpoint restoration. + """ + if self.state_key in state: + raise ControllerStateError(f"controller state already contains {self.state_key!r}") + targets: dict[str, JSONValue] = {} + ids: list[int] = [] + for founder in founders: + target = self._sample(rng) + founder.length = capped_founder_length(founder.length, target) + cell_id = simulation.add_cell(founder) + ids.append(cell_id) + targets[str(cell_id)] = target + state[self.state_key] = {"targets": targets} + return tuple(ids) + def initialize( self, state: dict[str, JSONValue], diff --git a/python/tests/test_division.py b/python/tests/test_division.py index 3bda60b..7bed3d0 100644 --- a/python/tests/test_division.py +++ b/python/tests/test_division.py @@ -103,3 +103,58 @@ def test_uniform_length_division_rejects_missing_target_state() -> None: ) with pytest.raises(ControllerStateError, match="length_division"): controller.step(0.1) + + +def test_founder_initialization_caps_native_precision_without_resampling() -> None: + from microsimulator import capped_founder_length + + stream = random.Random(71) + expected = random.Random(71) + policy = UniformLengthDivision(2.5, 3.0) + simulation = Simulation(BackendKind.CPU, species_count=2) + founders: list[CellInit] = [] + for index, length in enumerate((3.5, 1.0, 3.5, 2.75)): + founder = CellInit() + founder.position = Vec3(index * 10.0, 2.0, 3.0) + founder.direction = Vec3(0.0, 1.0, 0.0) + founder.length = length + founder.radius = 0.4 + founder.cell_type = index + founder.species = [2.0, 3.0] + founders.append(founder) + state: dict[str, JSONValue] = {} + ids = policy.initialize_founders(simulation, state, stream, tuple(founders)) + target_state = cast(dict[str, JSONValue], state[policy.state_key]) + targets = cast(dict[str, float], target_state["targets"]) + for index, (cell_id, requested) in enumerate(zip(ids, (3.5, 1.0, 3.5, 2.75), strict=True)): + target = expected.uniform(2.5, 3.0) + cell = simulation.cell(cell_id) + assert targets[str(cell_id)] == target + assert cell.length <= target + assert cell.length == capped_founder_length(requested, target) + assert (cell.position.x, cell.position.y, cell.position.z) == (index * 10.0, 2.0, 3.0) + assert (cell.direction.x, cell.direction.y, cell.direction.z) == (0.0, 1.0, 0.0) + assert abs(cell.radius - 0.4) < 1.0e-7 + assert cell.cell_type == index + assert cell.species == [2.0, 3.0] + assert stream.getstate() == expected.getstate() + with pytest.raises(ControllerStateError, match="already contains"): + policy.initialize_founders(simulation, state, stream, ()) + + +def test_capped_founder_length_rounds_down_when_nearest_float_exceeds_target() -> None: + from microsimulator import capped_founder_length + + target = 2.99999999 + founder = CellInit() + founder.length = target + assert founder.length > target # nearest native float rounds up + founder.length = capped_founder_length(3.5, target) + assert founder.length <= target + assert capped_founder_length(1.5, target) == 1.5 + assert capped_founder_length(0.0, 0.0) == 0.0 + for invalid in (-1.0, float("inf"), float("nan")): + with pytest.raises(ValueError): + capped_founder_length(invalid, 3.0) + with pytest.raises(ValueError): + capped_founder_length(3.0, invalid) diff --git a/python/tests/test_tutorials.py b/python/tests/test_tutorials.py index c23cab9..0c04d93 100644 --- a/python/tests/test_tutorials.py +++ b/python/tests/test_tutorials.py @@ -240,3 +240,105 @@ def test_plasmid_tutorial_resume_is_exact(tmp_path: Path) -> None: ) for cell in uninterrupted.simulation.cells() ] + + +_FOUNDER_MODELS: tuple[tuple[str, dict[str, JSONValue], float], ...] = ( + *_MODELS, + ("../culture_dish.py", {}, 0.001), + ("../microfluidic_trap.py", {}, 0.001), +) + + +def _founder_targets(model: SimulationController) -> dict[str, float]: + controller = cast(dict[str, JSONValue], model.controller_state()) + state = cast(dict[str, JSONValue], controller["state"]) + if "division_targets" in state: + return cast(dict[str, float], state["division_targets"]) + policy = cast(dict[str, JSONValue], state["length_division"]) + return cast(dict[str, float], policy["targets"]) + + +@pytest.mark.parametrize("seed", (0, 7, 17, 71)) +@pytest.mark.parametrize(("filename", "parameters", "dt"), _FOUNDER_MODELS) +def test_tutorial_founders_do_not_divide_without_growth( + filename: str, + parameters: dict[str, JSONValue], + dt: float, + seed: int, +) -> None: + model, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=seed, parameters=parameters), + ) + assert isinstance(model, SimulationController) + targets = _founder_targets(model) + ids = [cell.id for cell in model.simulation.cells()] + assert set(targets) == {str(cell_id) for cell_id in ids} + assert all(cell.length <= targets[str(cell.id)] for cell in model.simulation.cells()) + model.step(0.0) + assert [cell.id for cell in model.simulation.cells()] == ids + # Existing strict comparison still divides each founder after its length grows. + for cell in model.simulation.cells(): + model.simulation.set_cell_geometry( + cell.id, + cell.position, + cell.direction, + targets[str(cell.id)] + 0.01, + ) + model.step(0.0) + assert not set(ids) & {cell.id for cell in model.simulation.cells()} + + +@pytest.mark.parametrize(("filename", "parameters", "dt"), _FOUNDER_MODELS) +def test_tutorial_founder_initialization_is_deterministic_and_resume_does_not_cap( + filename: str, + parameters: dict[str, JSONValue], + dt: float, + tmp_path: Path, +) -> None: + context = ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters) + first, provenance = build_model(_TUTORIALS / filename, context) + second, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters), + ) + assert isinstance(first, SimulationController) + assert isinstance(second, SimulationController) + assert first.controller_state() == second.controller_state() + assert [cell.length for cell in first.simulation.cells()] == [ + cell.length for cell in second.simulation.cells() + ] + for cell in first.simulation.cells(): + first.simulation.set_cell_geometry(cell.id, cell.position, cell.direction, 8.0) + path = tmp_path / "oversized.cm2.json" + run_simulation(first, steps=0, dt=dt, output=path, provenance=provenance) + restored, _ = build_model( + _TUTORIALS / filename, + ModelContext(BackendKind.CPU, 0, seed=71, parameters=parameters), + checkpoint=load_checkpoint_bundle(path), + ) + assert isinstance(restored, SimulationController) + assert restored.controller_state() == first.controller_state() + assert all(cell.length == 8.0 for cell in restored.simulation.cells()) + + +def test_conjugation_rare_short_gaussian_target_is_not_resampled() -> None: + import random + + model, _ = build_model( + _TUTORIALS / "conjugation.py", + ModelContext(BackendKind.CPU, 0, seed=3103), + ) + assert isinstance(model, SimulationController) + expected = random.Random(3103) + targets = _founder_targets(model) + cells = model.simulation.cells() + # Native CellInit stores the requested 1.9 in single precision. + requested = 1.899999976158142 + for cell in cells: + assert targets[str(cell.id)] == requested + expected.gauss(1.9, 0.45) + assert cell.length <= targets[str(cell.id)] + assert cells[0].length < requested + ids = [cell.id for cell in cells] + model.step(0.0) + assert [cell.id for cell in model.simulation.cells()] == ids