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
5 changes: 3 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ Start with these documents when extending the engine:
## Analysis and visualization

- [Analysis recipes](analysis/recipes.md) covers lazy Polars workflows for colony geometry, species, lineage, contact graphs, and signal fields.
- [Species and signal labels](models/channel-labels.md) describes native and SBML channel metadata.
- [Viewer guide](../viewer/README.md) covers static scenes, interactive sessions, controls, development, and tests.
- [Scene format v2](formats/scene-v2.md) defines the data exchanged with visualization clients.
- [Scene format v3](formats/scene-v3.md) defines the data exchanged with visualization clients.
- [Live viewer protocol v1](protocols/live-viewer-v1.md) defines the authenticated loopback protocol for interactive sessions.

## Execution environments
Expand All @@ -66,7 +67,7 @@ The [testing and validation guide](development/validation.md) distinguishes comp
## Formats and protocols

- [Run manifest v1](formats/run-manifest-v1.md) defines reproducible batch jobs and parameter sweeps.
- [Scene format v2](formats/scene-v2.md) defines data-only visualization frames.
- [Scene format v3](formats/scene-v3.md) defines data-only visualization frames.
- [Live viewer protocol v1](protocols/live-viewer-v1.md) defines interactive viewer messages and authority boundaries.
- [Checkpoint design](architecture/0004-checkpoints.md) defines restart state and schema migration.
- [Analysis dataset design](architecture/0013-analysis-datasets.md) defines Parquet/Zarr schemas and provenance.
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/0004-checkpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ The public checkpoint is UTF-8 JSON with the format identifier `microsimulator-c
- producer, source-backend, and caller-supplied provenance; and
- a SHA-256 digest of the canonical simulation payload.

Version 2 additionally records an optional validated signal-grid specification and its complete signal-major concentration field. Version 3 adds the typed coupled cell/grid rate plan. Version 4 adds an optional data-only controller payload with its own SHA-256 digest. Native checkpoints write a JSON `null` controller. A non-null controller cannot be silently discarded by `load_checkpoint`; callers use `load_checkpoint_bundle` and restore it with the matching controller. Version 5 records the signal integration kind and its iterative-solver parameters. Version 6 records whether each rod cell is fixed in mechanics. Version 7 adds an optional spatial affine source/loss field to the signal-grid specification. Writers emit only v7; readers explicitly migrate v1 through v6, using Forward Euler defaults for older signal grids, movable cells for checkpoints predating v6, and no affine field reaction for checkpoints predating v7.
Version 2 additionally records an optional validated signal-grid specification and its complete signal-major concentration field. Version 3 adds the typed coupled cell/grid rate plan. Version 4 adds an optional data-only controller payload with its own SHA-256 digest. Native checkpoints write a JSON `null` controller. A non-null controller cannot be silently discarded by `load_checkpoint`; callers use `load_checkpoint_bundle` and restore it with the matching controller. Version 5 records the signal integration kind and its iterative-solver parameters. Version 6 records whether each rod cell is fixed in mechanics. Version 7 adds an optional spatial affine source/loss field to the signal-grid specification. Version 8 adds boxes and cylinders to the constraint set. Version 9 adds required `channel_metadata` with ordered `species` and `signals` arrays and a separate `integrity.channel_metadata` SHA-256 digest using the same canonical JSON encoding as controller state. Entries are strings or null, and lengths must match the native channel counts. Writers emit only v9; readers explicitly migrate v1 through v8, using Forward Euler defaults for older signal grids, movable cells for checkpoints predating v6, no affine field reaction for checkpoints predating v7, and unnamed channel labels for checkpoints predating v9. Each old payload is verified with its original integrity rules before supplying defaults. `load_checkpoint_bundle` exposes labels without executing model code; `load_checkpoint` refuses named metadata it would otherwise discard. See the [channel authoring guide](../models/channel-labels.md).

For v1–v8, absent metadata remains compact as `ChannelMetadata(species=None, signals=None)` in the returned bundle. Migration does not allocate null arrays from claimed native channel counts before native restoration validates the checkpoint. Scene export resolves these unspecified groups only after enforcing its separate 4096-channel presentation budget per group. Native counts are not capped by that scene budget, and v9 checkpoint metadata retains its explicit count-matched arrays.

Files are written to a temporary sibling, flushed, and atomically replaced. Loading rejects duplicate JSON keys, non-finite numbers, unknown fields for the declared version, unsupported versions, oversized files, digest mismatches, and any state that fails native domain validation. No module is imported and no source text, callback, pickle opcode, or other executable representation is accepted.

Expand Down
2 changes: 1 addition & 1 deletion docs/formats/scene-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,4 @@ The scene preserves all channels. A viewer chooses a channel and slice as presen

## Compatibility

Writers always emit the current version. Readers accept version 2 exactly and fail closed on other versions until an explicit migration is defined. Backend conformance compares frame semantics while ignoring the expected backend identity fields. Pixel output is tested separately by the viewer.
Current writers emit [version 3](scene-v3.md). Current readers verify version-2 frames against this original schema and digest, enforce the presentation budget of 4096 species and 4096 signals independently, then supply unnamed channel metadata in memory. The budget applies even to empty colonies and prevents a tiny document's claimed count from causing unbounded label allocation. It does not change native simulation or checkpoint channel limits. Backend conformance compares frame semantics while ignoring the expected backend identity fields. Pixel output is tested separately by the viewer.
20 changes: 20 additions & 0 deletions docs/formats/scene-v3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# MicroSimulator scene format v3

Version 3 retains the [version 2 envelope, geometry, constraints and grid representation](scene-v2.md) and adds one required field inside the integrity-protected frame:

```json
"channel_metadata": {
"species": ["Green reporter", "Red reporter"],
"signals": ["Nutrient", null]
}
```

Both groups are required arrays. `species` has exactly `species_count` entries; `signals` has exactly `signal_grid.signal_count` entries, or zero when the grid is null. Each entry is a Unicode scalar string or null. Null is the canonical serialized representation of an unspecified slot; unspecified groups are expanded to null-filled arrays. Empty and whitespace-only strings are retained verbatim and use the same display fallback as null. Duplicate names are valid. Unknown metadata fields and invalid lengths or types are errors.

Scene presentation has a channel-count budget of **4096 species and 4096 signals independently**, inclusive (`MAX_SCENE_CHANNELS` in Python and TypeScript). This budget applies to both v2 and v3, including empty colonies. Readers check each claimed count before expanding missing labels, copying channel data, or constructing viewer controls; an oversized count raises a scene-format error identifying the count and budget. Python applies the same limit to `SceneFrame` construction, `capture_scene`, parsing/loading, and encoding/saving. The existing encoded-size and grid-shape checks remain separate. This presentation budget does not limit native simulation counts or checkpoint restoration, and exporters reject oversized scenes rather than silently truncating channels.

The entire frame, including channel metadata, is hashed with RFC 8785 canonical JSON and SHA-256. Digests detect corruption; they do not authenticate a publisher. Labels must be rendered as text, never interpreted as HTML or executable code.

Readers accept versions 2 and 3. They verify a version-2 frame's original digest and exact version-2 keys first, then return the current in-memory representation with null-filled channel arrays. A version-2 file containing a `channel_metadata` field is invalid, even with a matching digest. Readers reject all other versions. Writers emit only version 3.

Python `SceneFrame.channel_metadata` and TypeScript `SceneFrame.channelMetadata` expose the same ordered values. TypeScript `channelLabel(frame, "species" | "signals", index)` provides missing-label fallback and duplicate-name disambiguation. Indices identify channels; display names never identify settings or alter stored numerical values. See the [authoring guide](../models/channel-labels.md) for native, low-level and SBML examples.
82 changes: 82 additions & 0 deletions docs/models/channel-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Species and signal labels

Numerical channel indices determine rate-plan inputs, storage order, and viewer preferences. Labels only describe those indices. They are never inferred from Python variable names.

## Native models

Pass immutable `ChannelMetadata` to `NativeController` after configuring the simulation's species count and signal grid:

```python
from microsimulator import ChannelMetadata, NativeController

return NativeController(
simulation,
model_id="my-model",
model_version=1,
rng=context.rng,
channel_metadata=ChannelMetadata(
species=("Green reporter", "Red reporter"),
signals=("Nutrient", "Extracellular cue"),
),
)
```

Each supplied tuple must contain exactly one entry per corresponding numerical channel. Use `None` for an unnamed entry, or omit a whole group to leave all its channels unnamed. The constructor validates counts immediately; the runner checks again before the first step, and exporters validate against the current state. Empty strings and whitespace-only strings display the same fallback as `None`, while retaining their exact supplied text in files. Labels must be Unicode scalar strings; unpaired surrogates are rejected. Presentation collapses and trims ASCII whitespace like an HTML option label before checking for duplicates; serialized metadata retains the original text. Duplicate names are valid and display their numerical indices for disambiguation. If a supplied name imitates one of those generated labels (for example, `GFP`, `GFP`, and `GFP [0]`), all labels in that species or signal group receive their indices so every displayed name remains distinct. Labels, including HTML-like strings, render as text.

The complete [named-channel model](../../examples/named_channels.py) declares two species and two signals:

```sh
uv run microsimulator run --model examples/named_channels.py --backend cpu --seed 17 --steps 2 --dt 0.01 --output named.json
uv run microsimulator view --model examples/named_channels.py --resume named.json --backend cpu --dt 0.01
```

`NativeController.from_checkpoint` restores the persisted labels automatically. A custom controller may optionally expose a typed `channel_metadata: ChannelMetadata` attribute; this is not a required member of `SimulationController`. Its `resume` function must restore `checkpoint.channel_metadata`. The native model runner rejects a resumed model that changes the saved labels. Unnamed native models and legacy adapters need no changes.

## Data-only export and low-level APIs

Labels are stored in checkpoint version 9 independently of the controller payload, so recovering them never requires running model code. Scene version 3 carries the same ordered arrays. Use the bundle's labels explicitly when exporting or saving native state directly:

```python
from microsimulator import capture_scene, load_checkpoint_bundle, save_checkpoint, save_scene

bundle = load_checkpoint_bundle("named.json")
frame = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata)
save_scene(frame, "named.scene.json")
save_checkpoint(
bundle.simulation,
"copy.json",
provenance=bundle.provenance,
controller=bundle.controller,
channel_metadata=bundle.channel_metadata,
)
```

`capture_scene` and `save_checkpoint` also accept `channel_metadata` for a bare `Simulation` when no controller is needed. A bare native simulation does not own Python presentation metadata. Therefore `load_checkpoint` refuses a file with non-null labels, just as it refuses a non-null controller payload: use `load_checkpoint_bundle` to avoid silently losing labels. Such a named, bare-native checkpoint is exported or continued through the bundle API; `run --resume` without a model retains its existing unnamed-only contract. Standard named models use the controller resume command above.

Scenes support at most 4096 species and 4096 signals per frame, independently, including unnamed channels and empty colonies. `MAX_SCENE_CHANNELS` exposes this presentation budget; oversized export fails with `SceneError` before copying native state or expanding labels. Native simulation and checkpoint counts retain their existing semantics. When loading checkpoints predating v9, `CheckpointBundle.channel_metadata` keeps both unspecified groups as `None`, without allocating labels from native counts. Bounded scene export supplies the null-filled arrays; callers that explicitly need resolved metadata can use `.resolved(species_count, signal_count)`.

Within a live dataset, channel choices stay keyed by kind and index. Renaming a channel does not change its concentration or select another channel. Frames, reset, and replay retain the same labels through the shared scene parser. Opening another dataset establishes a new presentation identity.

## SBML labels

`SBMLRateModel.channel_metadata` explicitly maps nonempty species names to labels and falls back to SBML species identifiers when names are missing. It preserves the imported species order:

```python
from microsimulator import CellInit, NativeController, load_sbml

rates = load_sbml("model.xml")
simulation = context.simulation(species_count=rates.species_count)
simulation.set_species_rate_plan(rates.rate_plan)
cell = CellInit()
cell.species = list(rates.initial_levels)
simulation.add_cell(cell)
return NativeController(
simulation,
model_id="my-sbml-model",
model_version=1,
rng=context.rng,
channel_metadata=rates.channel_metadata,
)
```

SBML species identifiers remain authoritative for compilation. If the simulation also has extracellular signals, declare those explicitly with `ChannelMetadata(species=rates.channel_metadata.species, signals=(...))`. The SBML importer does not infer extracellular signal identities.
43 changes: 43 additions & 0 deletions examples/named_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Two intracellular reporters and two extracellular signals with explicit labels."""

from microsimulator import (
CellInit,
ChannelMetadata,
CheckpointBundle,
GridShape,
ModelContext,
NativeController,
SignalGridSpec,
Vec3,
)


def build(context: ModelContext) -> NativeController:
simulation = context.simulation(species_count=2)
grid = SignalGridSpec()
grid.signal_count = 2
shape = GridShape()
shape.x, shape.y, shape.z = 4, 4, 1
grid.shape = shape
grid.spacing = Vec3(1.0, 1.0, 1.0)
grid.diffusion = [0.1, 0.2]
grid.advection = [Vec3(), Vec3()]
simulation.configure_signal_grid(grid, [0.25] * 16 + [0.75] * 16)
cell = CellInit()
cell.length = 2.0
cell.species = [0.25, 0.75]
simulation.add_cell(cell)
return NativeController(
simulation,
model_id="named-channels",
model_version=1,
rng=context.rng,
channel_metadata=ChannelMetadata(
species=("Green reporter", "Red reporter"),
signals=("Nutrient", "Extracellular cue"),
),
)


def resume(context: ModelContext, checkpoint: CheckpointBundle) -> NativeController:
return NativeController.from_checkpoint(checkpoint, model_id="named-channels", model_version=1)
5 changes: 5 additions & 0 deletions python/src/microsimulator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
backend_available,
backend_device_count,
)
from .channels import ChannelMetadata, ChannelMetadataError
from .checkpoint import (
CHECKPOINT_FORMAT,
CHECKPOINT_VERSION,
Expand Down Expand Up @@ -120,6 +121,7 @@
from .sbml import SBMLImportError, SBMLRateModel, load_sbml, parse_sbml
from .scene import (
MAX_SCENE_BYTES,
MAX_SCENE_CHANNELS,
SCENE_FORMAT,
SCENE_VERSION,
SceneBackend,
Expand Down Expand Up @@ -148,6 +150,7 @@
"MAX_LEGACY_EXAMPLE_MATRIX_BYTES",
"MAX_RUN_MANIFEST_BYTES",
"MAX_SCENE_BYTES",
"MAX_SCENE_CHANNELS",
"RUN_MANIFEST_FORMAT",
"RUN_MANIFEST_VERSION",
"SCENE_FORMAT",
Expand All @@ -163,6 +166,8 @@
"CellInit",
"CellSnapshot",
"CellUpdate",
"ChannelMetadata",
"ChannelMetadataError",
"CheckpointBundle",
"CheckpointError",
"CheckpointSourceBackend",
Expand Down
2 changes: 1 addition & 1 deletion python/src/microsimulator/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ def _load_sources(
for index, value in enumerate(checkpoints):
path = Path(value)
bundle = load_checkpoint_bundle(path, backend=backend, device_index=device_index)
scene = capture_scene(bundle.simulation)
scene = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata)
if previous_time is not None and scene.time < previous_time:
raise AnalysisError(
f"checkpoint {path} has time {scene.time:.9g}, before prior time "
Expand Down
Loading