Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/tutorials/biophysics-and-growth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/discrete-state-and-contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/intracellular-dynamics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/microfluidics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/tutorials/signaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions examples/culture_dish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions examples/microfluidic_trap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions examples/tutorials/biophysics.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Simulation,
StepPlan,
Vec3,
capped_founder_length,
)
from microsimulator.checkpoint import CheckpointBundle, JSONValue

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions examples/tutorials/biopixel_trap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion examples/tutorials/conjugation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
NativeController,
StepPlan,
Vec3,
capped_founder_length,
)
from microsimulator.checkpoint import CheckpointBundle, JSONValue

Expand Down Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions examples/tutorials/danino_clock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions examples/tutorials/gene_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions examples/tutorials/pillar_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion examples/tutorials/plasmid_segregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
CellInit,
ModelContext,
Simulation,
capped_founder_length,
capture_random_state,
restore_random_state,
)
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions examples/tutorials/signaling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions examples/tutorials/simbol_circuits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion python/src/microsimulator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -256,6 +256,7 @@
"backend_device_count",
"build_legacy_model",
"build_model",
"capped_founder_length",
"capture_random_state",
"capture_scene",
"dumps_scene",
Expand Down
47 changes: 46 additions & 1 deletion python/src/microsimulator/division.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand All @@ -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],
Expand Down
55 changes: 55 additions & 0 deletions python/tests/test_division.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading