From b69193b9db034b4ccd484920de3068cc11c3ea2d Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 13:39:47 -0600 Subject: [PATCH 01/38] Add feedback/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 56980f3..b8fcfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ .mypy_cache/ compile_commands.json results/ +feedback/ From 7643ba3dd61a504244cc81734778b323ae667e14 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:03:33 -0600 Subject: [PATCH 02/38] Establish explicit viewer dataset presentation lifecycle --- viewer/README.md | 6 ++ viewer/src/colony-viewer.ts | 9 +- viewer/src/main.ts | 83 +++++++++------- viewer/src/presentation-state.ts | 67 +++++++++++++ viewer/tests/presentation-state.test.ts | 121 ++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 36 deletions(-) create mode 100644 viewer/src/presentation-state.ts create mode 100644 viewer/tests/presentation-state.test.ts diff --git a/viewer/README.md b/viewer/README.md index 1eea13e..80b8478 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -62,3 +62,9 @@ pnpm --dir viewer build ``` The unit suite includes a Python-authored scene fixture whose digest contains floating-point values that ordinary Python and JavaScript JSON serializers spell differently. Passing that test is the cross-language integrity gate. + +## Dataset presentation lifecycle + +Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. + +`DatasetPresentationState.datasetId` scopes numerical channel identities; display labels are not identities. Retained preferences are separate from the effective values returned by `forFrame()`. Temporarily absent channels or smaller grids use valid display indices without erasing the user's selections, signal visibility, or chosen slice. The first available signal grid initializes a default slice once. Feature-specific display state should reset only in the explicit `newDataset` block in `presentScene()`. diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 0a3ff8a..4c3038c 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -194,7 +194,14 @@ export class ColonyViewer { }); } - public setFrame(frame: SceneFrame, fit = true): void { + /** Call once when opening a file, live session, or recording. */ + public beginDataset(): void { + this.cancelCameraTransition(); + this.selectCell(null); + } + + /** Frame updates, including reset/seek, retain camera and dataset state. */ + public setFrame(frame: SceneFrame, fit = false): void { this.viewCube.setVisible(true); disposeGroup(this.colony); this.cellMeshes = []; diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 901b4e9..1b69adb 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -3,6 +3,7 @@ import "./style.css"; import { mapCellColors, type ColorMode } from "./color"; import { ColonyViewer } from "./colony-viewer"; import { signalSlice, sliceDimension, type SliceAxis } from "./grid"; +import { DatasetPresentationState } from "./presentation-state"; import { LiveConnection, type LiveConnectionState, @@ -72,6 +73,7 @@ let liveConnected = false; let livePlaying = false; let liveCheckpointEnabled = false; let liveConnection: LiveConnection | null = null; +const presentation = new DatasetPresentationState(); const viewer = new ColonyViewer(canvasHost, viewCubeElement, updateSelection); @@ -104,8 +106,8 @@ function options( select: HTMLSelectElement, count: number, prefix: string, + selected: number, ): void { - const previous = selectedInteger(select); select.replaceChildren(); for (let index = 0; index < count; index += 1) { const option = document.createElement("option"); @@ -113,7 +115,7 @@ function options( option.textContent = `${prefix} ${index}`; select.append(option); } - select.value = String(Math.min(previous, Math.max(count - 1, 0))); + select.value = String(selected); } function selectedInteger( @@ -215,26 +217,21 @@ function updateSelection(cell: SceneCell | null): void { } } -function sameShape( - previous: SceneFrame["signalGrid"], - next: SceneFrame["signalGrid"], -): boolean { - return ( - previous !== null && - next !== null && - previous.signalCount === next.signalCount && - previous.shape.every((value, index) => value === next.shape[index]) - ); -} - function presentScene( next: SceneFrame, label: string, - { fit = true, announce = true }: { fit?: boolean; announce?: boolean } = {}, + { + newDataset = false, + announce = true, + }: { newDataset?: boolean; announce?: boolean } = {}, ): void { - const previous = frame; + if (newDataset) { + presentation.beginDataset(); + viewer.beginDataset(); + } + const display = presentation.forFrame(next); frame = next; - viewer.setFrame(next, fit); + viewer.setFrame(next, newDataset); fitButton.disabled = false; colorMode.disabled = false; emptyState.hidden = true; @@ -248,10 +245,8 @@ function presentScene( gridShape.textContent = next.signalGrid === null ? "None" : next.signalGrid.shape.join(" × "); - options(speciesChannel, next.speciesCount, "Channel"); - if (next.speciesCount === 0 && colorMode.value === "species") { - colorMode.value = "cell-type"; - } + colorMode.value = display.colorMode; + options(speciesChannel, next.speciesCount, "Channel", display.speciesChannel); const speciesOption = colorMode.querySelector( 'option[value="species"]', ); @@ -260,15 +255,19 @@ function presentScene( } signalSection.hidden = next.signalGrid === null; + signalVisible.checked = display.signalVisible; + signalAxis.value = display.signalAxis; if (next.signalGrid !== null) { - options(signalChannel, next.signalGrid.signalCount, "Channel"); - if (!sameShape(previous?.signalGrid ?? null, next.signalGrid)) { - signalVisible.checked = true; - signalAxis.value = "z"; - signalRange.value = String( - Math.floor((next.signalGrid.shape[2] - 1) / 2), - ); - } + options( + signalChannel, + next.signalGrid.signalCount, + "Channel", + display.signalChannel, + ); + signalRange.max = String( + sliceDimension(next.signalGrid, display.signalAxis) - 1, + ); + signalRange.value = String(display.signalSlice); updateSignalRange(); } updateColors(); @@ -288,7 +287,7 @@ async function loadFile(file: File): Promise { } try { const next = await parseScene(await file.text()); - presentScene(next, file.name); + presentScene(next, file.name, { newDataset: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setStatus(message, "error"); @@ -305,15 +304,29 @@ fileInput.addEventListener("change", () => { fitButton.addEventListener("click", () => viewer.fitColony()); clearSelection.addEventListener("click", () => viewer.selectCell(null)); -colorMode.addEventListener("change", updateColors); -speciesChannel.addEventListener("change", updateColors); -signalVisible.addEventListener("change", updateSignal); -signalChannel.addEventListener("change", updateSignal); +colorMode.addEventListener("change", () => { + presentation.preferences.colorMode = colorMode.value as ColorMode; + updateColors(); +}); +speciesChannel.addEventListener("change", () => { + presentation.preferences.speciesChannel = selectedInteger(speciesChannel); + updateColors(); +}); +signalVisible.addEventListener("change", () => { + presentation.preferences.signalVisible = signalVisible.checked; + updateSignal(); +}); +signalChannel.addEventListener("change", () => { + presentation.preferences.signalChannel = selectedInteger(signalChannel); + updateSignal(); +}); signalAxis.addEventListener("change", () => { + presentation.preferences.signalAxis = signalAxis.value as SliceAxis; updateSignalRange(); updateSignal(); }); signalRange.addEventListener("input", () => { + presentation.preferences.signalSlice = selectedInteger(signalRange); sliceValue.value = signalRange.value; updateSignal(); }); @@ -387,7 +400,7 @@ function liveFrame(message: LiveFrameMessage): void { liveLabel.textContent = message.playing ? "Running" : "Paused"; } presentScene(message.frame, "live simulation", { - fit: first, + newDataset: first, announce: first, }); updateLiveControls(); diff --git a/viewer/src/presentation-state.ts b/viewer/src/presentation-state.ts new file mode 100644 index 0000000..4dd9e61 --- /dev/null +++ b/viewer/src/presentation-state.ts @@ -0,0 +1,67 @@ +import type { ColorMode } from "./color"; +import { sliceDimension, type SliceAxis } from "./grid"; +import type { SceneFrame } from "./scene"; + +export interface PresentationPreferences { + colorMode: ColorMode; + speciesChannel: number; + signalVisible: boolean; + signalChannel: number; + signalAxis: SliceAxis; + signalSlice: number | null; +} + +function defaults(): PresentationPreferences { + return { + colorMode: "cell-type", + speciesChannel: 0, + signalVisible: true, + signalChannel: 0, + signalAxis: "z", + signalSlice: null, + }; +} + +/** One identity per explicit file/session/recording open, never per frame. */ +export class DatasetPresentationState { + public datasetId = 0; + public preferences = defaults(); + + public beginDataset(): void { + this.datasetId += 1; + this.preferences = defaults(); + } + + /** Clamp only the displayed values, retaining choices for later frames. */ + public forFrame(frame: SceneFrame): PresentationPreferences & { + signalSlice: number; + } { + const grid = frame.signalGrid; + if (grid !== null && this.preferences.signalSlice === null) { + this.preferences.signalSlice = Math.floor( + (sliceDimension(grid, this.preferences.signalAxis) - 1) / 2, + ); + } + return { + ...this.preferences, + colorMode: + frame.speciesCount === 0 && this.preferences.colorMode === "species" + ? "cell-type" + : this.preferences.colorMode, + speciesChannel: Math.min( + this.preferences.speciesChannel, + Math.max(frame.speciesCount - 1, 0), + ), + signalChannel: Math.min( + this.preferences.signalChannel, + Math.max((grid?.signalCount ?? 0) - 1, 0), + ), + signalSlice: Math.min( + this.preferences.signalSlice ?? 0, + grid === null + ? 0 + : sliceDimension(grid, this.preferences.signalAxis) - 1, + ), + }; + } +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts new file mode 100644 index 0000000..c6dbe86 --- /dev/null +++ b/viewer/tests/presentation-state.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { DatasetPresentationState } from "../src/presentation-state"; +import type { SceneFrame, SceneSignalGrid } from "../src/scene"; + +const boundary = { kind: "no_flux" as const, values: [] }; +const grid: SceneSignalGrid = { + signalCount: 3, + shape: [5, 7, 9], + origin: [0, 0, 0], + spacing: [1, 1, 1], + boundaries: { + xLower: boundary, + xUpper: boundary, + yLower: boundary, + yUpper: boundary, + zLower: boundary, + zUpper: boundary, + }, + levels: [], +}; +const frame: SceneFrame = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 3, + cells: [], + constraints: { planes: [], spheres: [], boxes: [], cylinders: [] }, + signalGrid: grid, +}; + +describe("dataset presentation lifecycle", () => { + it("resets defaults only on an explicit new dataset", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + expect(state.datasetId).toBe(1); + expect(state.forFrame(frame).signalSlice).toBe(4); + Object.assign(state.preferences, { + colorMode: "growth-rate", + signalVisible: false, + signalAxis: "x", + signalSlice: 3, + }); + for (const time of [1, 100, 0, 20, 2]) { + expect(state.forFrame({ ...frame, time })).toMatchObject({ + colorMode: "growth-rate", + signalVisible: false, + signalAxis: "x", + signalSlice: 3, + }); + expect(state.datasetId).toBe(1); + } + state.beginDataset(); + expect(state.datasetId).toBe(2); + expect(state.forFrame(frame)).toEqual({ + colorMode: "cell-type", + speciesChannel: 0, + signalVisible: true, + signalChannel: 0, + signalAxis: "z", + signalSlice: 4, + }); + }); + + it("retains channel and slice choices through absent or smaller grids", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + Object.assign(state.preferences, { + colorMode: "species", + speciesChannel: 2, + signalChannel: 2, + signalVisible: false, + signalAxis: "y", + signalSlice: 6, + }); + expect( + state.forFrame({ ...frame, speciesCount: 0, signalGrid: null }), + ).toMatchObject({ + colorMode: "cell-type", + speciesChannel: 0, + signalChannel: 0, + signalSlice: 0, + }); + expect( + state.forFrame({ + ...frame, + speciesCount: 1, + signalGrid: { ...grid, signalCount: 1, shape: [2, 2, 2] }, + }), + ).toMatchObject({ + colorMode: "species", + speciesChannel: 0, + signalChannel: 0, + signalSlice: 1, + }); + expect(state.forFrame(frame)).toMatchObject({ + colorMode: "species", + speciesChannel: 2, + signalChannel: 2, + signalVisible: false, + signalAxis: "y", + signalSlice: 6, + }); + }); + + it("initializes the slice once when an initially missing grid arrives", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.forFrame({ ...frame, signalGrid: null }); + expect(state.preferences.signalSlice).toBeNull(); + expect(state.forFrame(frame).signalSlice).toBe(4); + expect( + state.forFrame({ ...frame, signalGrid: { ...grid, shape: [5, 7, 99] } }) + .signalSlice, + ).toBe(4); + }); +}); From 6e544e3380556ea60282dda17f38075deff4b573 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:18:51 -0600 Subject: [PATCH 03/38] Cap tutorial founder lengths at their sampled division thresholds --- docs/tutorials/biophysics-and-growth.md | 10 +- docs/tutorials/discrete-state-and-contacts.md | 2 + docs/tutorials/intracellular-dynamics.md | 2 + docs/tutorials/microfluidics.md | 2 + docs/tutorials/signaling.md | 2 + examples/culture_dish.py | 6 +- examples/microfluidic_trap.py | 3 +- examples/tutorials/biophysics.py | 7 +- examples/tutorials/biopixel_trap.py | 3 +- examples/tutorials/conjugation.py | 5 +- examples/tutorials/danino_clock.py | 3 +- examples/tutorials/gene_expression.py | 3 +- examples/tutorials/pillar_channel.py | 6 +- examples/tutorials/plasmid_segregation.py | 5 +- examples/tutorials/signaling.py | 6 +- examples/tutorials/simbol_circuits.py | 3 +- python/src/microsimulator/__init__.py | 3 +- python/src/microsimulator/division.py | 47 +++++++++- python/tests/test_division.py | 55 +++++++++++ python/tests/test_tutorials.py | 93 +++++++++++++++++++ 20 files changed, 238 insertions(+), 28 deletions(-) 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..7072ad4 100644 --- a/python/tests/test_tutorials.py +++ b/python/tests/test_tutorials.py @@ -240,3 +240,96 @@ 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 From 571254a967ffc6a545832fa8dc013e2f1d714d4f Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:21:21 -0600 Subject: [PATCH 04/38] Keep reference grid geometry fixed within each dataset --- viewer/README.md | 4 + viewer/browser/reference-grid.mjs | 383 ++++++++++++++++++++++++ viewer/src/colony-viewer.ts | 31 +- viewer/src/main.ts | 2 +- viewer/src/reference-grid.ts | 85 ++++++ viewer/tests/presentation-state.test.ts | 11 + viewer/tests/reference-grid.test.ts | 204 +++++++++++++ 7 files changed, 706 insertions(+), 14 deletions(-) create mode 100644 viewer/browser/reference-grid.mjs create mode 100644 viewer/src/reference-grid.ts create mode 100644 viewer/tests/reference-grid.test.ts diff --git a/viewer/README.md b/viewer/README.md index 80b8478..237d6cc 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -68,3 +68,7 @@ The unit suite includes a Python-authored scene fixture whose digest contains fl Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. `DatasetPresentationState.datasetId` scopes numerical channel identities; display labels are not identities. Retained preferences are separate from the effective values returned by `forFrame()`. Temporarily absent channels or smaller grids use valid display indices without erasing the user's selections, signal visibility, or chosen slice. The first available signal grid initializes a default slice once. Feature-specific display state should reset only in the explicit `newDataset` block in `presentScene()`. + +The ground reference grid is separate from the scientific signal lattice. Its square extent and origin come from the first frame's finite device geometry (boxes, spheres, and cylinders), or from the initial cell capsule bounds when no finite device exists. Infinite plane constraints are excluded. The extent is at least 10 scene distance units with 20 equal divisions; the grid plane is 0.01 units below the lesser of the initial lower Z bound and zero. An initially empty dataset uses a 10-unit grid centered on the world origin. These values remain fixed even when cells or device geometry appear later, the colony expands beyond the grid, or all cells disappear. Opening another dataset initializes a new reference grid; camera Fit never changes its geometry. + +`browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. diff --git a/viewer/browser/reference-grid.mjs b/viewer/browser/reference-grid.mjs new file mode 100644 index 0000000..d26c5f8 --- /dev/null +++ b/viewer/browser/reference-grid.mjs @@ -0,0 +1,383 @@ +// Run against the Vite development server. No production debug hooks are added. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4320"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-reference-grid"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); + +// Observe the real application instance rather than replacing its renderer. +await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +function signalGrid(shape = [5, 7, 9], signalCount = 3) { + return { + signal_count: signalCount, + shape, + origin: [-2, -3, 0], + spacing: [1, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: Array(shape.reduce((a, b) => a * b, signalCount)).fill(1), + }; +} +const cell = { + id: "1", + parent_id: null, + slot: 0, + position: [0, 0, 0.6], + direction: [1, 0, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [1, 2, 3], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 3, + cells: [cell], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: signalGrid(), +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .set(0, 0, 0) + .applyMatrix4(v.grid.matrixWorld) + .project(v.camera); + return { + grid: v.grid.matrixWorld.elements.slice(), + camera: v.camera.position.toArray(), + target: v.controls.target.toArray(), + pixelOrigin: point.toArray(), + }; + }); +} +function assertStationary(actual, expected, message) { + assert.deepEqual(actual.grid, expected.grid, message); + for (const key of ["camera", "target", "pixelOrigin"]) { + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${message}: ${key}[${index}]`, + ); + }); + } +} +try { + await page.goto(`${url}/?token=reference-grid-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(4); + v.controls.update(); + }); + const initial = await snapshot(); + await page.screenshot({ path: `${evidence}/initial.png` }); + await send({ ...base, time: 1, cells: [{ ...cell, length: 14 }] }); + assertStationary( + await snapshot(), + initial, + "growth must preserve world grid and stationary-camera projection", + ); + await page.screenshot({ path: `${evidence}/grown.png` }); + await send({ + ...base, + time: 2, + cells: [ + { ...cell, position: [5, -8, -20] }, + { ...cell, id: "2", slot: 1, position: [-5, 2, 3] }, + ], + }); + assertStationary( + await snapshot(), + initial, + "translation and division must preserve grid and camera", + ); + await send({ ...base, time: 3, cells: [] }); + assertStationary( + await snapshot(), + initial, + "empty frames must preserve grid and camera", + ); + await send(base); + assertStationary( + await snapshot(), + initial, + "live reset must preserve grid and camera", + ); + + // Exercise UI preferences through a missing grid and reduced channel counts. + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-channel").selectOption("2"); + await page.locator("#signal-slice").focus(); + await page.keyboard.press("End"); + await page.locator("#signal-axis").selectOption("x"); + await expect(page.locator("#signal-slice")).toHaveValue("4"); + await page.locator("#signal-axis").selectOption("z"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await send({ + ...base, + time: 4, + species_count: 0, + cells: [], + signal_grid: null, + }); + await send({ + ...base, + time: 5, + species_count: 1, + cells: [], + signal_grid: signalGrid([2, 2, 2], 1), + }); + await expect(page.locator("#signal-slice")).toHaveValue("1"); + await send({ ...base, time: 6 }); + await expect(page.locator("#species-channel")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("2"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + + const canvas = page.locator("#canvas-host canvas"); + const bounds = await canvas.boundingBox(); + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + for (const button of ["left", "right"]) { + await page.mouse.move(x, y); + await page.mouse.down({ button }); + await page.mouse.move(x + 80, y + 40, { steps: 8 }); + await page.mouse.up({ button }); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + `${button} drag changes camera only`, + ); + } + await page.mouse.wheel(0, 150); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "zoom changes camera only", + ); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "Fit changes camera only", + ); + + // Explicit new dataset, including the initially empty case, in the real renderer. + const emptyThenDevice = await page.evaluate(() => { + const v = globalThis.__testViewer; + const empty = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + cells: [], + constraints: { + boxes: [], + cylinders: [], + planes: [ + { + id: "1", + point: [1e30, 0, 0], + inwardNormal: [1, 0, 0], + coefficient: 1, + }, + ], + spheres: [], + }, + signalGrid: null, + }; + v.beginDataset(); + v.setFrame(empty, true); + const before = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + const device = { + ...empty, + constraints: { + ...empty.constraints, + boxes: [ + { + id: "2", + center: [30, 40, -5], + halfExtents: [50, 25, 3], + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }; + v.setFrame(device); + const retained = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + v.beginDataset(); + v.setFrame(device, true); + return { + before, + retained, + reopened: { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }, + }; + }); + assert.deepEqual(emptyThenDevice.before, { + position: [0, 0, -0.01], + scale: [0.5, 0.5, 0.5], + }); + assert.deepEqual(emptyThenDevice.retained, emptyThenDevice.before); + assert.deepEqual(emptyThenDevice.reopened, { + position: [30, 40, -8.01], + scale: [5, 5, 5], + }); + // Opening files goes through the application's explicit dataset reset path. + await page.goto(url); + const openFile = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "same-name.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await openFile(base); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await openFile({ + ...base, + time: 11, + constraints: { + ...base.constraints, + boxes: [ + { + id: "1", + center: [30, 40, -5], + half_extents: [50, 25, 3], + coefficient: 1, + allowed_region: "inside", + }, + ], + }, + }); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + await expect(page.locator("#signal-visible")).toBeChecked(); + assert.deepEqual( + await page.evaluate(() => ({ + position: globalThis.__testViewer.grid.position.toArray(), + scale: globalThis.__testViewer.grid.scale.toArray(), + })), + { position: [30, 40, -8.01], scale: [5, 5, 5] }, + ); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + assertions: + "grid world/projected coordinates; camera orbit/pan/zoom/Fit; growth/XYZ/division/removal/reset; missing channels/grid; axis round trip; initially empty/new dataset", + evidence, + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 4c3038c..26609d7 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -36,6 +36,10 @@ import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { rgbBytes, viridis, type RGB } from "./color"; import type { SignalSlice } from "./grid"; +import { + DatasetReferenceGrid, + REFERENCE_GRID_DIVISIONS, +} from "./reference-grid"; import type { SceneCell, SceneConstraints, SceneFrame } from "./scene"; import { canonicalViewQuaternion, @@ -99,7 +103,13 @@ export class ColonyViewer { private readonly device = new Group(); private readonly signal = new Group(); private readonly highlight = new Group(); - private readonly grid = new GridHelper(20, 20, 0x34413c, 0x222b27); + private readonly grid = new GridHelper( + 20, + REFERENCE_GRID_DIVISIONS, + 0x34413c, + 0x222b27, + ); + private readonly referenceGrid = new DatasetReferenceGrid(); private readonly raycaster = new Raycaster(); private readonly pointer = new Vector2(); private readonly resizeObserver: ResizeObserver; @@ -159,6 +169,7 @@ export class ColonyViewer { this.device, this.highlight, ); + this.grid.name = "reference-grid"; this.grid.rotateX(Math.PI / 2); this.grid.position.z = -0.002; this.highlight.visible = false; @@ -196,12 +207,14 @@ export class ColonyViewer { /** Call once when opening a file, live session, or recording. */ public beginDataset(): void { + this.referenceGrid.beginDataset(); this.cancelCameraTransition(); this.selectCell(null); } /** Frame updates, including reset/seek, retain camera and dataset state. */ public setFrame(frame: SceneFrame, fit = false): void { + this.configureReferenceGrid(frame); this.viewCube.setVisible(true); disposeGroup(this.colony); this.cellMeshes = []; @@ -216,7 +229,6 @@ export class ColonyViewer { this.sceneBounds = deviceBounds.isEmpty() ? new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1)) : deviceBounds; - this.configureReferenceGrid(this.sceneBounds); if (fit) { this.fitColony(false); } @@ -302,7 +314,6 @@ export class ColonyViewer { this.colony.add(...this.cellMeshes); this.buildDevice(frame.constraints, bounds); this.sceneBounds = bounds; - this.configureReferenceGrid(bounds); const selectedIndex = frame.cells.findIndex( (cell) => cell.id === this.selectedCellId, ); @@ -790,16 +801,10 @@ export class ColonyViewer { this.highlight.visible = true; } - private configureReferenceGrid(bounds: Box3): void { - const size = bounds.getSize(new Vector3()); - const center = bounds.getCenter(new Vector3()); - const extent = Math.max(size.x, size.y, 10); - this.grid.scale.set(extent / 20, extent / 20, extent / 20); - this.grid.position.set( - center.x, - center.y, - Math.min(bounds.min.z, 0) - 0.01, - ); + private configureReferenceGrid(frame: SceneFrame): void { + const layout = this.referenceGrid.forFrame(frame); + this.grid.scale.setScalar(layout.extent / 20); + this.grid.position.fromArray(layout.position); } private resize(host: HTMLElement): void { diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 1b69adb..28aa1d8 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -150,7 +150,7 @@ function updateSignalRange(): void { const axis = signalAxis.value as SliceAxis; const maximum = sliceDimension(frame.signalGrid, axis) - 1; signalRange.max = String(maximum); - signalRange.value = String(Math.min(selectedInteger(signalRange), maximum)); + signalRange.value = String(presentation.forFrame(frame).signalSlice); sliceValue.value = signalRange.value; } diff --git a/viewer/src/reference-grid.ts b/viewer/src/reference-grid.ts new file mode 100644 index 0000000..9296410 --- /dev/null +++ b/viewer/src/reference-grid.ts @@ -0,0 +1,85 @@ +import { Box3, Vector3 } from "three"; + +import type { SceneFrame, Vector3 as SceneVector3 } from "./scene"; + +export const REFERENCE_GRID_DIVISIONS = 20; +export const MINIMUM_REFERENCE_GRID_EXTENT = 10; + +export interface ReferenceGridLayout { + readonly extent: number; + readonly spacing: number; + readonly position: SceneVector3; +} + +/** Finite device geometry is independent of the colony's current envelope. */ +function initialBounds(frame: SceneFrame): Box3 { + const device = new Box3(); + const include = (center: SceneVector3, extent: SceneVector3): void => { + const origin = new Vector3().fromArray(center); + const radius = new Vector3().fromArray(extent); + device.union( + new Box3(origin.clone().sub(radius), origin.clone().add(radius)), + ); + }; + for (const box of frame.constraints.boxes) { + include(box.center, box.halfExtents); + } + for (const sphere of frame.constraints.spheres) { + include(sphere.center, [sphere.radius, sphere.radius, sphere.radius]); + } + for (const cylinder of frame.constraints.cylinders) { + include(cylinder.center, [ + cylinder.radius, + cylinder.radius, + cylinder.halfHeight, + ]); + } + // Planes are infinite. Their visualization extent must never size the grid. + if (!device.isEmpty()) { + return device; + } + const colony = new Box3(); + for (const cell of frame.cells) { + const center = new Vector3().fromArray(cell.position); + const half = new Vector3() + .fromArray(cell.direction) + .normalize() + .multiplyScalar(cell.length / 2); + colony.union( + new Box3() + .setFromPoints([center.clone().sub(half), center.clone().add(half)]) + .expandByScalar(cell.radius), + ); + } + return colony.isEmpty() ? new Box3(new Vector3(), new Vector3()) : colony; +} + +export function initialReferenceGrid(frame: SceneFrame): ReferenceGridLayout { + const bounds = initialBounds(frame); + const size = bounds.getSize(new Vector3()); + const center = bounds.getCenter(new Vector3()); + const extent = Math.max(size.x, size.y, MINIMUM_REFERENCE_GRID_EXTENT); + return Object.freeze({ + extent, + spacing: extent / REFERENCE_GRID_DIVISIONS, + position: Object.freeze([ + center.x, + center.y, + Math.min(bounds.min.z, 0) - 0.01, + ]) as SceneVector3, + }); +} + +/** Geometry is chosen from the first frame, including an empty first frame. */ +export class DatasetReferenceGrid { + private layout: ReferenceGridLayout | null = null; + + public beginDataset(): void { + this.layout = null; + } + + public forFrame(frame: SceneFrame): ReferenceGridLayout { + this.layout ??= initialReferenceGrid(frame); + return this.layout; + } +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts index c6dbe86..aca53de 100644 --- a/viewer/tests/presentation-state.test.ts +++ b/viewer/tests/presentation-state.test.ts @@ -34,6 +34,17 @@ const frame: SceneFrame = { }; describe("dataset presentation lifecycle", () => { + it("restores the desired slice after an axis round trip", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.preferences.signalSlice = 8; + expect(state.forFrame(frame).signalSlice).toBe(8); + state.preferences.signalAxis = "x"; + expect(state.forFrame(frame).signalSlice).toBe(4); + state.preferences.signalAxis = "z"; + expect(state.forFrame(frame).signalSlice).toBe(8); + }); + it("resets defaults only on an explicit new dataset", () => { const state = new DatasetPresentationState(); state.beginDataset(); diff --git a/viewer/tests/reference-grid.test.ts b/viewer/tests/reference-grid.test.ts new file mode 100644 index 0000000..d11c377 --- /dev/null +++ b/viewer/tests/reference-grid.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import { + DatasetReferenceGrid, + initialReferenceGrid, +} from "../src/reference-grid"; +import type { SceneCell, SceneConstraints, SceneFrame } from "../src/scene"; + +const constraints: SceneConstraints = { + boxes: [], + spheres: [], + cylinders: [], + planes: [], +}; +const cell: SceneCell = { + id: "1", + parentId: null, + slot: 0, + position: [3, 4, 2], + direction: [1, 0, 0], + length: 4, + radius: 0.5, + growthRate: 0, + cellType: 0, + fixed: false, + species: [], +}; +const frame: SceneFrame = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + cells: [cell], + constraints, + signalGrid: null, +}; + +describe("reference grid initialization", () => { + it("uses initial colony bounds with a minimum ten-unit extent", () => { + expect(initialReferenceGrid(frame)).toEqual({ + extent: 10, + spacing: 0.5, + position: [3, 4, -0.01], + }); + expect( + initialReferenceGrid({ ...frame, cells: [{ ...cell, length: 40 }] }), + ).toEqual({ extent: 41, spacing: 2.05, position: [3, 4, -0.01] }); + }); + + it("uses finite device bounds even when cells are far outside them", () => { + expect( + initialReferenceGrid({ + ...frame, + cells: [{ ...cell, position: [1000, 2000, -99] }], + constraints: { + ...constraints, + boxes: [ + { + id: "1", + center: [10, -20, -3], + halfExtents: [8, 2, 1], + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }), + ).toEqual({ extent: 16, spacing: 0.8, position: [10, -20, -4.01] }); + }); + + it("accounts for spherical and cylindrical device extents", () => { + expect( + initialReferenceGrid({ + ...frame, + constraints: { + ...constraints, + spheres: [ + { + id: "1", + center: [50, 60, -8], + radius: 6, + coefficient: 1, + allowedRegion: "outside", + }, + ], + }, + }), + ).toEqual({ extent: 12, spacing: 0.6, position: [50, 60, -14.01] }); + expect( + initialReferenceGrid({ + ...frame, + constraints: { + ...constraints, + cylinders: [ + { + id: "1", + center: [-100, 40, 10], + radius: 8, + halfHeight: 2, + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }), + ).toEqual({ extent: 16, spacing: 0.8, position: [-100, 40, -0.01] }); + }); + + it("ignores unbounded planes, including their arbitrary origin", () => { + const planes = [ + { + id: "1", + point: [1e30, -1e30, -1e30] as const, + inwardNormal: [0, 0, 1] as const, + coefficient: 1, + }, + ]; + expect( + initialReferenceGrid({ + ...frame, + constraints: { ...constraints, planes }, + }), + ).toEqual(initialReferenceGrid(frame)); + expect( + initialReferenceGrid({ + ...frame, + cells: [], + constraints: { ...constraints, planes }, + }), + ).toEqual({ extent: 10, spacing: 0.5, position: [0, 0, -0.01] }); + }); +}); + +describe("reference grid dataset lifecycle", () => { + it("preserves grid intersections through growth, XYZ motion, division, empty frames, reset and seek", () => { + const grid = new DatasetReferenceGrid(); + grid.beginDataset(); + const initial = grid.forFrame(frame); + const changedFrames = [ + { ...frame, time: 5, cells: [{ ...cell, length: 400 }] }, + { + ...frame, + time: 6, + cells: [{ ...cell, position: [100, -200, -300] as const }], + }, + { + ...frame, + time: 7, + cells: [ + cell, + { ...cell, id: "2", slot: 1, position: [-50, 20, 30] as const }, + ], + }, + { ...frame, time: 8, cells: [] }, + { ...frame, time: 0 }, + { ...frame, time: 4 }, + ]; + const intersections = (value: typeof initial) => + Array.from({ length: 21 }, (_, i) => [ + value.position[0] - value.extent / 2 + i * value.spacing, + value.position[1] - value.extent / 2 + i * value.spacing, + value.position[2], + ]); + for (const next of changedFrames) { + expect(grid.forFrame(next)).toBe(initial); + expect(intersections(grid.forFrame(next))).toEqual( + intersections(initial), + ); + } + }); + + it("keeps the fallback from an initially empty dataset when cells or devices arrive", () => { + const grid = new DatasetReferenceGrid(); + const initial = grid.forFrame({ ...frame, cells: [] }); + expect(grid.forFrame(frame)).toBe(initial); + const next = { + ...frame, + constraints: { + ...constraints, + boxes: [ + { + id: "1", + center: [20, 30, 0] as const, + halfExtents: [50, 50, 50] as const, + coefficient: 1, + allowedRegion: "inside" as const, + }, + ], + }, + }; + expect(grid.forFrame(next)).toBe(initial); + grid.beginDataset(); + expect(grid.forFrame(next)).toEqual({ + extent: 100, + spacing: 5, + position: [20, 30, -50.01], + }); + }); +}); From 014ffe365a5140806f6077d52fdc458903b7348b Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:25:41 -0600 Subject: [PATCH 05/38] Preserve channel labels across models checkpoints and scenes --- docs/README.md | 5 +- docs/architecture/0004-checkpoints.md | 2 +- docs/formats/scene-v2.md | 2 +- docs/formats/scene-v3.md | 18 ++ docs/models/channel-labels.md | 80 ++++++++ examples/named_channels.py | 43 +++++ python/src/microsimulator/__init__.py | 3 + python/src/microsimulator/analysis.py | 2 +- python/src/microsimulator/channels.py | 85 +++++++++ python/src/microsimulator/checkpoint.py | 51 ++++- python/src/microsimulator/controller.py | 6 + python/src/microsimulator/runner.py | 36 +++- python/src/microsimulator/sbml.py | 27 +-- python/src/microsimulator/scene.py | 74 ++++++-- python/src/microsimulator/viewer_server.py | 13 +- python/tests/test_channels.py | 184 +++++++++++++++++++ python/tests/test_checkpoint.py | 14 ++ python/tests/test_sbml.py | 7 + viewer/README.md | 4 + viewer/browser/channel-labels.mjs | 120 ++++++++++++ viewer/src/color.ts | 4 +- viewer/src/main.ts | 18 +- viewer/src/scene.ts | 92 +++++++++- viewer/tests/channels.test.ts | 120 ++++++++++++ viewer/tests/color.test.ts | 1 + viewer/tests/fixtures/channels-v3.scene.json | 85 +++++++++ 26 files changed, 1031 insertions(+), 65 deletions(-) create mode 100644 docs/formats/scene-v3.md create mode 100644 docs/models/channel-labels.md create mode 100644 examples/named_channels.py create mode 100644 python/src/microsimulator/channels.py create mode 100644 python/tests/test_channels.py create mode 100644 viewer/browser/channel-labels.mjs create mode 100644 viewer/tests/channels.test.ts create mode 100644 viewer/tests/fixtures/channels-v3.scene.json diff --git a/docs/README.md b/docs/README.md index e26712f..fa08956 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 @@ -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. diff --git a/docs/architecture/0004-checkpoints.md b/docs/architecture/0004-checkpoints.md index 90e1e6f..b2da517 100644 --- a/docs/architecture/0004-checkpoints.md +++ b/docs/architecture/0004-checkpoints.md @@ -21,7 +21,7 @@ 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). 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. diff --git a/docs/formats/scene-v2.md b/docs/formats/scene-v2.md index 6530e27..5b6faf8 100644 --- a/docs/formats/scene-v2.md +++ b/docs/formats/scene-v2.md @@ -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, then supply unnamed channel metadata in memory. Backend conformance compares frame semantics while ignoring the expected backend identity fields. Pixel output is tested separately by the viewer. diff --git a/docs/formats/scene-v3.md b/docs/formats/scene-v3.md new file mode 100644 index 0000000..3382e1f --- /dev/null +++ b/docs/formats/scene-v3.md @@ -0,0 +1,18 @@ +# 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. + +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. diff --git a/docs/models/channel-labels.md b/docs/models/channel-labels.md new file mode 100644 index 0000000..575b2f9 --- /dev/null +++ b/docs/models/channel-labels.md @@ -0,0 +1,80 @@ +# 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. Duplicate names are valid and display their numerical indices for disambiguation. 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. + +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. diff --git a/examples/named_channels.py b/examples/named_channels.py new file mode 100644 index 0000000..830bc8e --- /dev/null +++ b/examples/named_channels.py @@ -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) diff --git a/python/src/microsimulator/__init__.py b/python/src/microsimulator/__init__.py index 156f409..79e3c42 100644 --- a/python/src/microsimulator/__init__.py +++ b/python/src/microsimulator/__init__.py @@ -53,6 +53,7 @@ backend_available, backend_device_count, ) +from .channels import ChannelMetadata, ChannelMetadataError from .checkpoint import ( CHECKPOINT_FORMAT, CHECKPOINT_VERSION, @@ -163,6 +164,8 @@ "CellInit", "CellSnapshot", "CellUpdate", + "ChannelMetadata", + "ChannelMetadataError", "CheckpointBundle", "CheckpointError", "CheckpointSourceBackend", diff --git a/python/src/microsimulator/analysis.py b/python/src/microsimulator/analysis.py index bfa0808..b1edee7 100644 --- a/python/src/microsimulator/analysis.py +++ b/python/src/microsimulator/analysis.py @@ -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 " diff --git a/python/src/microsimulator/channels.py b/python/src/microsimulator/channels.py new file mode 100644 index 0000000..08aece4 --- /dev/null +++ b/python/src/microsimulator/channels.py @@ -0,0 +1,85 @@ +"""Optional presentation labels; numerical channel indices remain authoritative.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from .checkpoint import JSONValue + + +class ChannelMetadataError(ValueError): + """Raised when labels cannot describe the declared numerical channels.""" + + +@dataclass(frozen=True, slots=True) +class ChannelMetadata: + """Ordered labels, with ``None`` for an unnamed group or individual channel. + + Explicit tuples must match channel counts exactly. Empty/whitespace-only + strings are treated as missing labels for display, but preserved on disk. + """ + + species: tuple[str | None, ...] | None = None + signals: tuple[str | None, ...] | None = None + + def __post_init__(self) -> None: + for kind in ("species", "signals"): + labels = cast(object, getattr(self, kind)) + if labels is None: + continue + if not isinstance(labels, tuple): + raise ChannelMetadataError(f"channel_metadata.{kind}: expected a tuple or None") + for index, label in enumerate(cast(tuple[object, ...], labels)): + if label is not None and not isinstance(label, str): + raise ChannelMetadataError( + f"channel_metadata.{kind}[{index}]: expected a string or None" + ) + if isinstance(label, str): + try: + label.encode("utf-8") + except UnicodeEncodeError as error: + raise ChannelMetadataError( + f"channel_metadata.{kind}[{index}]: invalid Unicode scalar value" + ) from error + + def resolved(self, species_count: int, signal_count: int) -> ChannelMetadata: + """Validate explicit counts and expand unspecified groups to null labels.""" + + groups: list[tuple[str | None, ...]] = [] + for kind, labels, count in ( + ("species", self.species, species_count), + ("signals", self.signals, signal_count), + ): + if labels is not None and len(labels) != count: + raise ChannelMetadataError( + f"channel_metadata.{kind}: expected {count} labels, got {len(labels)}" + ) + groups.append((None,) * count if labels is None else labels) + return ChannelMetadata(species=groups[0], signals=groups[1]) + + def to_json(self, species_count: int, signal_count: int) -> dict[str, JSONValue]: + labels = self.resolved(species_count, signal_count) + return {"species": list(labels.species or ()), "signals": list(labels.signals or ())} + + @classmethod + def from_json(cls, value: object, species_count: int, signal_count: int) -> ChannelMetadata: + """Decode the closed data-only representation shared by scenes/checkpoints.""" + + if not isinstance(value, dict) or set(cast(dict[object, object], value)) != { + "species", + "signals", + }: + raise ChannelMetadataError("channel_metadata: expected exactly species and signals") + data = cast(dict[str, object], value) + groups: list[tuple[str | None, ...]] = [] + for kind in ("species", "signals"): + values = data[kind] + if not isinstance(values, list): + raise ChannelMetadataError(f"channel_metadata.{kind}: expected an array") + groups.append(cast(tuple[str | None, ...], tuple(cast(list[object], values)))) + return cls(species=groups[0], signals=groups[1]).resolved(species_count, signal_count) + + +UNNAMED_CHANNELS = ChannelMetadata() diff --git a/python/src/microsimulator/checkpoint.py b/python/src/microsimulator/checkpoint.py index fa87c6c..897544f 100644 --- a/python/src/microsimulator/checkpoint.py +++ b/python/src/microsimulator/checkpoint.py @@ -43,9 +43,10 @@ _SphereConstraint, _WorldStateCheckpoint, ) +from .channels import UNNAMED_CHANNELS, ChannelMetadata, ChannelMetadataError CHECKPOINT_FORMAT = "microsimulator-checkpoint" -CHECKPOINT_VERSION = 8 +CHECKPOINT_VERSION = 9 MAX_CHECKPOINT_BYTES = 1 << 30 _NATIVE_CHECKPOINT_VERSION = 4 @@ -83,6 +84,7 @@ class CheckpointBundle: provenance: dict[str, JSONValue] schema_version: int source_backend: CheckpointSourceBackend + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS _RATE_OP_NAMES = { @@ -336,12 +338,17 @@ def save_checkpoint( *, provenance: Mapping[str, JSONValue] | None = None, controller: JSONValue = None, + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS, ) -> None: """Atomically save a complete simulation checkpoint as validated JSON.""" checkpoint = simulation._checkpoint() checkpoint.validate() state = _simulation_to_json(checkpoint) + try: + labels = channel_metadata.to_json(simulation.species_count, simulation.signal_count) + except ChannelMetadataError as error: + raise CheckpointError(str(error)) from error digest = hashlib.sha256(_canonical_json(state)).hexdigest() controller_digest = hashlib.sha256(_canonical_json(controller)).hexdigest() backend = simulation.backend_info @@ -361,9 +368,11 @@ def save_checkpoint( "algorithm": "sha256", "simulation": digest, "controller": controller_digest, + "channel_metadata": hashlib.sha256(_canonical_json(labels)).hexdigest(), }, "simulation": state, "controller": controller, + "channel_metadata": labels, } try: encoded = ( @@ -704,9 +713,7 @@ def _signal_grid(value: object, path: str, schema_version: int) -> _SignalGridCh if schema_version >= 8: spec.obstacles = [ _integer(item, f"{path}.spec.obstacles[{index}]", 0, 1) - for index, item in enumerate( - _array(spec_data["obstacles"], f"{path}.spec.obstacles") - ) + for index, item in enumerate(_array(spec_data["obstacles"], f"{path}.spec.obstacles")) ] field_value = spec_data["velocity_field"] if field_value is not None: @@ -958,7 +965,7 @@ def load_checkpoint_bundle( if "version" not in root: _fail("$", "missing keys ['version']") schema_version = _integer(root["version"], "$.version", 0, _UINT32_MAX) - supported_versions = {1, 2, 3, 4, 5, 6, 7, CHECKPOINT_VERSION} + supported_versions = {1, 2, 3, 4, 5, 6, 7, 8, CHECKPOINT_VERSION} if schema_version not in supported_versions: _fail("$.version", f"unsupported checkpoint version {schema_version}") required = { @@ -972,6 +979,8 @@ def load_checkpoint_bundle( } if schema_version >= 4: required.add("controller") + if schema_version >= 9: + required.add("channel_metadata") _keys( root, "$", @@ -1007,6 +1016,8 @@ def load_checkpoint_bundle( integrity_keys = {"algorithm", "simulation"} if schema_version >= 4: integrity_keys.add("controller") + if schema_version >= 9: + integrity_keys.add("channel_metadata") _keys(integrity, "$.integrity", integrity_keys) if _string(integrity["algorithm"], "$.integrity.algorithm") != "sha256": _fail("$.integrity.algorithm", "unsupported integrity algorithm") @@ -1017,20 +1028,36 @@ def load_checkpoint_bundle( controller = cast(JSONValue, root["controller"]) if schema_version >= 4 else None if schema_version >= 4: - expected_controller_digest = _string( - integrity["controller"], "$.integrity.controller" - ) + expected_controller_digest = _string(integrity["controller"], "$.integrity.controller") actual_controller_digest = hashlib.sha256(_canonical_json(controller)).hexdigest() if not hmac.compare_digest(actual_controller_digest, expected_controller_digest): _fail("$.integrity.controller", "controller digest does not match") + if schema_version >= 9: + expected_labels_digest = _string( + integrity["channel_metadata"], "$.integrity.channel_metadata" + ) + actual_labels_digest = hashlib.sha256(_canonical_json(root["channel_metadata"])).hexdigest() + if not hmac.compare_digest(actual_labels_digest, expected_labels_digest): + _fail("$.integrity.channel_metadata", "channel metadata digest does not match") checkpoint = _native_checkpoint(root["simulation"], schema_version) + species_count = checkpoint.world.species_count + signal_count = checkpoint.signal_grid.spec.signal_count if checkpoint.signal_grid else 0 + try: + labels = ( + ChannelMetadata.from_json(root["channel_metadata"], species_count, signal_count) + if schema_version >= 9 + else ChannelMetadata().resolved(species_count, signal_count) + ) + except ChannelMetadataError as error: + raise CheckpointError(str(error)) from error return CheckpointBundle( simulation=Simulation(backend, checkpoint, device_index), controller=controller, provenance=provenance, schema_version=schema_version, source_backend=source_backend, + channel_metadata=labels, ) @@ -1047,4 +1074,12 @@ def load_checkpoint( raise CheckpointError( "checkpoint contains controller state; load it with load_checkpoint_bundle" ) + if any( + label is not None + for group in (bundle.channel_metadata.species, bundle.channel_metadata.signals) + for label in (group or ()) + ): + raise CheckpointError( + "checkpoint contains channel metadata; load it with load_checkpoint_bundle" + ) return bundle.simulation diff --git a/python/src/microsimulator/controller.py b/python/src/microsimulator/controller.py index 2e41d4e..99f25c3 100644 --- a/python/src/microsimulator/controller.py +++ b/python/src/microsimulator/controller.py @@ -18,6 +18,7 @@ MechanicsSolveResult, Simulation, ) +from .channels import UNNAMED_CHANNELS, ChannelMetadata from .checkpoint import CheckpointBundle, JSONValue _RANDOM_STATE_KIND = "python-random-mt19937" @@ -327,6 +328,7 @@ def __init__( mechanics: MechanicsConfig | None = None, state: Mapping[str, JSONValue] | None = None, completed_steps: int = 0, + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS, ) -> None: self._model_id, self._model_version = _model_identity(model_id, model_version) if not isinstance(cast(object, simulation), Simulation): @@ -334,6 +336,9 @@ def __init__( if not isinstance(cast(object, rng), random.Random): raise TypeError("native controller requires an explicit random.Random stream") self.simulation = simulation + self.channel_metadata = channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ) self._rng = rng self._regulate = regulate self._on_division = on_division @@ -537,6 +542,7 @@ def from_checkpoint( mechanics = None if mechanics_value is None else MechanicsConfig.from_json(mechanics_value) return cls( checkpoint.simulation, + channel_metadata=checkpoint.channel_metadata, model_id=model_id, model_version=model_version, rng=restore_random_state(value["random"]), diff --git a/python/src/microsimulator/runner.py b/python/src/microsimulator/runner.py index 7aecc0b..82fc344 100644 --- a/python/src/microsimulator/runner.py +++ b/python/src/microsimulator/runner.py @@ -18,6 +18,7 @@ Simulation, backend_available, ) +from .channels import ChannelMetadata, ChannelMetadataError from .checkpoint import CheckpointBundle, JSONValue, save_checkpoint from .controller import SimulationController @@ -102,6 +103,19 @@ def native_simulation(model: object) -> Simulation: return simulation +def model_channel_metadata(model: RunnableModel) -> ChannelMetadata: + """Read optional model labels without extending the required controller protocol.""" + + value = cast(object, getattr(model, "channel_metadata", ChannelMetadata())) + if not isinstance(value, ChannelMetadata): + raise BatchError("model channel_metadata must be ChannelMetadata") + native = native_simulation(model) + try: + return value.resolved(native.species_count, native.signal_count) + except ChannelMetadataError as error: + raise BatchError(str(error)) from error + + def controller_state(model: RunnableModel) -> JSONValue: """Capture optional data-only controller state for a runnable model.""" @@ -197,12 +211,11 @@ def run_simulation( raise BatchError("cell-count threshold must be a positive uint64 value") native = native_simulation(simulation) native.validate() + model_channel_metadata(simulation) destination = Path(output) periodic_steps = ( - tuple(range(checkpoint_every, steps + 1, checkpoint_every)) - if checkpoint_every > 0 - else () + tuple(range(checkpoint_every, steps + 1, checkpoint_every)) if checkpoint_every > 0 else () ) periodic_paths = tuple(_periodic_path(destination, step) for step in periodic_steps) destination.parent.mkdir(parents=True, exist_ok=True) @@ -256,6 +269,7 @@ def run_simulation( stop_cell_count=stop_cell_count, ), controller=controller_state(simulation), + channel_metadata=model_channel_metadata(simulation), ) written_periodic.append(periodic) if reached_cell_count: @@ -275,6 +289,7 @@ def run_simulation( stop_cell_count=stop_cell_count, ), controller=controller_state(simulation), + channel_metadata=model_channel_metadata(simulation), ) return RunSummary( completed_steps=completed_steps, @@ -327,9 +342,7 @@ def build_model( else: resume_value = module.__dict__.get("resume") if not callable(resume_value): - raise BatchError( - f"model {source_path} must define resume(context, checkpoint)" - ) + raise BatchError(f"model {source_path} must define resume(context, checkpoint)") resume = cast(Callable[[ModelContext, CheckpointBundle], object], resume_value) model_value = resume(context, checkpoint) entrypoint = "resume(context, checkpoint)" @@ -346,19 +359,22 @@ def build_model( if not isinstance(model_value, Simulation | SimulationController): raise BatchError( - f"model {source_path} {entrypoint} did not return a Simulation or " - "SimulationController" + f"model {source_path} {entrypoint} did not return a Simulation or SimulationController" ) simulation = native_simulation(model_value) if checkpoint is not None and simulation is not checkpoint.simulation: raise BatchError( - f"model {source_path} resume(context, checkpoint) did not use " - "checkpoint.simulation" + f"model {source_path} resume(context, checkpoint) did not use checkpoint.simulation" ) info = simulation.backend_info if info.kind != context.backend or info.device_index != context.device_index: raise BatchError("model returned a simulation on a different backend or device") simulation.validate() + labels = model_channel_metadata(model_value) + if checkpoint is not None and labels != checkpoint.channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ): + raise BatchError("resumed model channel metadata differs from checkpoint") provenance: dict[str, JSONValue] = { "model": { "path": str(source_path), diff --git a/python/src/microsimulator/sbml.py b/python/src/microsimulator/sbml.py index f17bb5f..d48de97 100644 --- a/python/src/microsimulator/sbml.py +++ b/python/src/microsimulator/sbml.py @@ -15,6 +15,7 @@ RateOp, SpeciesRatePlan, ) +from .channels import ChannelMetadata _FLOAT32_MAX = 3.4028234663852886e38 _UINT32_MAX = (1 << 32) - 1 @@ -36,6 +37,17 @@ class SBMLRateModel: rate_plan: SpeciesRatePlan warnings: tuple[str, ...] + @property + def channel_metadata(self) -> ChannelMetadata: + """Use nonempty SBML names, falling back to stable SBML identifiers.""" + + return ChannelMetadata( + species=tuple( + name if name.strip() else identifier + for name, identifier in zip(self.species_names, self.species_ids, strict=True) + ) + ) + @property def species_count(self) -> int: return len(self.species_ids) @@ -263,8 +275,7 @@ def _local_parameters(kinetic_law: _KineticLaw, reaction_id: str) -> dict[str, f for index in range(kinetic_law.getNumLocalParameters()) ] parameters.extend( - (kinetic_law.getParameter(index), True) - for index in range(kinetic_law.getNumParameters()) + (kinetic_law.getParameter(index), True) for index in range(kinetic_law.getNumParameters()) ) for parameter, check_constant in parameters: identifier = parameter.getId() @@ -427,9 +438,7 @@ def _species_metadata( f"species {identifier!r} must declare exactly one initial concentration or amount" ) initial = ( - species.getInitialConcentration() - if has_concentration - else species.getInitialAmount() + species.getInitialConcentration() if has_concentration else species.getInitialAmount() ) initial = _finite_float32(initial, f"species {identifier!r} initial level") if initial < 0.0: @@ -444,9 +453,7 @@ def _species_metadata( def _stoichiometry(reference: _SpeciesReference, reaction_id: str) -> float: if reference.isSetStoichiometryMath() or not reference.getConstant(): raise SBMLImportError(f"reaction {reaction_id!r} uses dynamic stoichiometry") - value = _finite_float32( - reference.getStoichiometry(), f"reaction {reaction_id!r} stoichiometry" - ) + value = _finite_float32(reference.getStoichiometry(), f"reaction {reaction_id!r} stoichiometry") if value < 0.0: raise SBMLImportError(f"reaction {reaction_id!r} stoichiometry must be non-negative") return value @@ -531,9 +538,7 @@ def parse_sbml(source: str) -> SBMLRateModel: raise SBMLImportError("SBML source must be a nonempty string") libsbml = _libsbml() document = libsbml.readSBMLFromString(source) - if document.getLevel() > 0 and ( - document.getLevel() != 3 or document.getVersion() != 2 - ): + if document.getLevel() > 0 and (document.getLevel() != 3 or document.getVersion() != 2): raise SBMLImportError("SBML import currently requires Level 3 Version 2 Core") warnings = _validate_document(document, libsbml) model = document.getModel() diff --git a/python/src/microsimulator/scene.py b/python/src/microsimulator/scene.py index c3a4668..94ff096 100644 --- a/python/src/microsimulator/scene.py +++ b/python/src/microsimulator/scene.py @@ -25,10 +25,11 @@ Simulation, Vec3, ) +from .channels import UNNAMED_CHANNELS, ChannelMetadata, ChannelMetadataError from .checkpoint import JSONValue SCENE_FORMAT = "microsimulator-scene" -SCENE_VERSION = 2 +SCENE_VERSION = 3 MAX_SCENE_BYTES = 1 << 30 _UINT32_MAX = (1 << 32) - 1 @@ -161,6 +162,16 @@ class SceneFrame: cells: tuple[SceneCell, ...] constraints: SceneConstraints signal_grid: SceneSignalGrid | None + channel_metadata: ChannelMetadata = UNNAMED_CHANNELS + + def __post_init__(self) -> None: + object.__setattr__( + self, + "channel_metadata", + self.channel_metadata.resolved( + self.species_count, self.signal_grid.signal_count if self.signal_grid else 0 + ), + ) def _installed_version() -> str: @@ -181,7 +192,9 @@ def _capture_boundary(boundary: GridBoundary) -> SceneGridBoundary: ) -def capture_scene(simulation: Simulation) -> SceneFrame: +def capture_scene( + simulation: Simulation, *, channel_metadata: ChannelMetadata = UNNAMED_CHANNELS +) -> SceneFrame: """Capture a complete immutable presentation frame after a simulation step.""" checkpoint = simulation._checkpoint() @@ -276,6 +289,9 @@ def capture_scene(simulation: Simulation) -> SceneFrame: cells=cells, constraints=constraints, signal_grid=signal_grid, + channel_metadata=channel_metadata.resolved( + simulation.species_count, simulation.signal_count + ), ) _validate_frame(frame) return frame @@ -375,6 +391,9 @@ def _frame_to_json(frame: SceneFrame) -> dict[str, JSONValue]: "cells": cells, "constraints": constraints, "signal_grid": grid, + "channel_metadata": frame.channel_metadata.to_json( + frame.species_count, frame.signal_grid.signal_count if frame.signal_grid else 0 + ), } @@ -400,13 +419,16 @@ def dumps_scene(frame: SceneFrame) -> str: }, "frame": payload, } - return json.dumps( - document, - allow_nan=False, - ensure_ascii=False, - indent=2, - sort_keys=True, - ) + "\n" + return ( + json.dumps( + document, + allow_nan=False, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n" + ) def save_scene(frame: SceneFrame, path: str | os.PathLike[str]) -> None: @@ -746,11 +768,25 @@ def _signal_grid(value: object, path: str) -> SceneSignalGrid | None: ) -def _frame(value: object, path: str) -> SceneFrame: +def _frame(value: object, path: str, schema_version: int) -> SceneFrame: data = _object(value, path) - _keys(data, path, {"time", "backend", "species_count", "cells", "constraints", "signal_grid"}) + keys = {"time", "backend", "species_count", "cells", "constraints", "signal_grid"} + if schema_version >= 3: + keys.add("channel_metadata") + _keys(data, path, keys) species_count = _integer(data["species_count"], f"{path}.species_count", 0, _UINT32_MAX) + signal_grid = _signal_grid(data["signal_grid"], f"{path}.signal_grid") + signal_count = signal_grid.signal_count if signal_grid else 0 + try: + labels = ( + ChannelMetadata.from_json(data["channel_metadata"], species_count, signal_count) + if schema_version >= 3 + else ChannelMetadata().resolved(species_count, signal_count) + ) + except ChannelMetadataError as error: + raise SceneError(str(error)) from error frame = SceneFrame( + channel_metadata=labels, time=_number(data["time"], f"{path}.time"), backend=_backend(data["backend"], f"{path}.backend"), species_count=species_count, @@ -759,7 +795,7 @@ def _frame(value: object, path: str) -> SceneFrame: for index, item in enumerate(_array(data["cells"], f"{path}.cells")) ), constraints=_constraints(data["constraints"], f"{path}.constraints"), - signal_grid=_signal_grid(data["signal_grid"], f"{path}.signal_grid"), + signal_grid=signal_grid, ) _validate_frame(frame) return frame @@ -776,6 +812,12 @@ def _validate_boundary(boundary: SceneGridBoundary, signal_count: int, path: str def _validate_frame(frame: SceneFrame) -> None: + try: + frame.channel_metadata.resolved( + frame.species_count, frame.signal_grid.signal_count if frame.signal_grid else 0 + ) + except ChannelMetadataError as error: + raise SceneError(str(error)) from error _number(frame.time, "$.frame.time") if frame.time < 0.0: _fail("$.frame.time", "must be non-negative") @@ -936,7 +978,7 @@ def parse_scene(source: str | bytes) -> SceneFrame: if _string(root["format"], "$.format") not in (SCENE_FORMAT, "cellmodeller2-scene"): _fail("$.format", "not a MicroSimulator scene") schema_version = _integer(root["version"], "$.version", 0, _UINT32_MAX) - if schema_version != SCENE_VERSION: + if schema_version not in {2, SCENE_VERSION}: _fail("$.version", f"unsupported scene version {schema_version}") producer = _object(root["producer"], "$.producer") _keys(producer, "$.producer", {"name", "version"}) @@ -947,12 +989,10 @@ def parse_scene(source: str | bytes) -> SceneFrame: if _string(integrity["algorithm"], "$.integrity.algorithm") != "sha256": _fail("$.integrity.algorithm", "unsupported integrity algorithm") expected_digest = _string(integrity["frame"], "$.integrity.frame") - actual_digest = hashlib.sha256( - _canonical_json(cast(JSONValue, root["frame"])) - ).hexdigest() + actual_digest = hashlib.sha256(_canonical_json(cast(JSONValue, root["frame"]))).hexdigest() if not hmac.compare_digest(actual_digest, expected_digest): _fail("$.integrity.frame", "frame digest does not match") - return _frame(root["frame"], "$.frame") + return _frame(root["frame"], "$.frame", schema_version) def load_scene(path: str | os.PathLike[str]) -> SceneFrame: diff --git a/python/src/microsimulator/viewer_server.py b/python/src/microsimulator/viewer_server.py index b0d6bf2..4e2599a 100644 --- a/python/src/microsimulator/viewer_server.py +++ b/python/src/microsimulator/viewer_server.py @@ -19,7 +19,7 @@ from aiohttp import WSMsgType, web from .checkpoint import JSONValue, save_checkpoint -from .runner import RunnableModel, controller_state, native_simulation +from .runner import RunnableModel, controller_state, model_channel_metadata, native_simulation from .scene import capture_scene, dumps_scene MAX_COMMAND_BYTES = 4096 @@ -104,6 +104,7 @@ def checkpoint_enabled(self) -> bool: def _build(self) -> tuple[RunnableModel, dict[str, JSONValue]]: model, provenance = self._factory() native_simulation(model).validate() + model_channel_metadata(model) return model, dict(provenance) def step(self, steps: int = 1) -> None: @@ -141,12 +142,20 @@ def checkpoint(self) -> Path: destination, provenance=provenance, controller=controller_state(self._model), + channel_metadata=model_channel_metadata(self._model), ) return destination def frame_message(self, *, playing: bool) -> dict[str, JSONValue]: native = native_simulation(self._model) - scene = cast(dict[str, JSONValue], json.loads(dumps_scene(capture_scene(native)))) + scene = cast( + dict[str, JSONValue], + json.loads( + dumps_scene( + capture_scene(native, channel_metadata=model_channel_metadata(self._model)) + ) + ), + ) return { "type": "frame", "revision": self._revision, diff --git a/python/tests/test_channels.py b/python/tests/test_channels.py new file mode 100644 index 0000000..18739f6 --- /dev/null +++ b/python/tests/test_channels.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +# ruff: noqa: RUF001 -- explicit Unicode-label coverage. +import hashlib +import json +from dataclasses import replace +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from microsimulator import ( + BackendKind, + ChannelMetadata, + ChannelMetadataError, + CheckpointError, + ModelContext, + NativeController, + SceneError, + Simulation, + build_model, + capture_scene, + dumps_scene, + load_checkpoint, + load_checkpoint_bundle, + load_scene, + parse_scene, + run_simulation, + save_checkpoint, + save_scene, +) +from microsimulator.runner import BatchError, model_channel_metadata +from microsimulator.viewer_server import LiveSession + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / "examples/named_channels.py" +LABELS = ChannelMetadata( + species=("Green reporter", "Red reporter"), signals=("Nutrient", "Extracellular cue") +) + + +def _build() -> tuple[NativeController, dict[str, Any]]: + model, provenance = build_model(EXAMPLE, ModelContext(BackendKind.CPU, 0, 17)) + assert isinstance(model, NativeController) + return model, provenance + + +def test_named_model_periodic_checkpoint_resume_and_standalone_export(tmp_path: Path) -> None: + model, provenance = _build() + output = tmp_path / "named.json" + summary = run_simulation( + model, steps=2, dt=0.01, output=output, checkpoint_every=1, provenance=provenance + ) + for path in (*summary.periodic_checkpoints, output): + assert load_checkpoint_bundle(path).channel_metadata == LABELS + bundle = load_checkpoint_bundle(output) + resumed, provenance = build_model( + EXAMPLE, ModelContext(BackendKind.CPU, 0, 17), checkpoint=bundle + ) + assert model_channel_metadata(resumed) == LABELS + resumed_output = tmp_path / "continued.json" + run_simulation(resumed, steps=1, dt=0.01, output=resumed_output, provenance=provenance) + continued = load_checkpoint_bundle(resumed_output) + assert continued.channel_metadata == LABELS + model.step(0.01) + assert continued.simulation.signal_levels == model.simulation.signal_levels + assert continued.simulation.cell(1).species == model.simulation.cell(1).species + # Only data and the bundle API are needed to export; no model import/execute. + destination = tmp_path / "named.scene.json" + save_scene( + capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata), destination + ) + assert load_scene(destination).channel_metadata == LABELS + + +def test_live_labels_survive_step_reset_and_checkpoint(tmp_path: Path) -> None: + session = LiveSession(_build, dt=0.01, checkpoint_output=tmp_path / "live.json") + for operation in (lambda: None, session.step, session.reset): + operation() + message = session.frame_message(playing=False) + frame = parse_scene(json.dumps(message["scene"])) + assert frame.channel_metadata == LABELS + assert load_checkpoint_bundle(session.checkpoint()).channel_metadata == LABELS + + +def test_metadata_counts_fail_before_stepping_or_writing(tmp_path: Path) -> None: + model, _ = _build() + model.channel_metadata = ChannelMetadata(species=("only one",)) + with pytest.raises(BatchError, match=r"species: expected 2 labels, got 1"): + run_simulation(model, steps=1, dt=0.1, output=tmp_path / "bad.json") + assert model.simulation.time == 0 + assert not (tmp_path / "bad.json").exists() + with pytest.raises(ChannelMetadataError, match=r"species: expected 2 labels"): + capture_scene(model.simulation, channel_metadata=model.channel_metadata) + with pytest.raises(CheckpointError, match=r"species: expected 2 labels"): + save_checkpoint( + model.simulation, tmp_path / "bad.json", channel_metadata=model.channel_metadata + ) + with pytest.raises(ChannelMetadataError, match=r"signals: expected 2 labels"): + ChannelMetadata(signals=()).resolved(2, 2) + + +def test_missing_duplicate_unicode_empty_and_markup_labels_roundtrip(tmp_path: Path) -> None: + model, _ = _build() + labels = ChannelMetadata(species=("α 🧪", "α 🧪"), signals=(None, " ")) + save_checkpoint(model.simulation, tmp_path / "labels.json", channel_metadata=labels) + bundle = load_checkpoint_bundle(tmp_path / "labels.json") + assert bundle.channel_metadata == labels + frame = capture_scene(bundle.simulation, channel_metadata=bundle.channel_metadata) + assert parse_scene(dumps_scene(frame)).channel_metadata == labels + with pytest.raises(CheckpointError, match="contains channel metadata"): + load_checkpoint(tmp_path / "labels.json") + with pytest.raises(ChannelMetadataError, match="invalid Unicode"): + ChannelMetadata(species=("\ud800",)) + with pytest.raises(ChannelMetadataError, match="expected a string"): + ChannelMetadata.from_json({"species": [7], "signals": []}, 1, 0) + + +def test_metadata_tampering_rejected_and_v8_migrates_only_after_verification( + tmp_path: Path, +) -> None: + model, _ = _build() + path = tmp_path / "labels.json" + save_checkpoint(model.simulation, path, channel_metadata=LABELS) + document = json.loads(path.read_text()) + document["channel_metadata"]["species"][0] = "tampered" + path.write_text(json.dumps(document)) + with pytest.raises(CheckpointError, match="channel metadata digest does not match"): + load_checkpoint_bundle(path) + document["version"] = 8 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] + path.write_text(json.dumps(document)) + assert load_checkpoint_bundle(path).channel_metadata == ChannelMetadata().resolved(2, 2) + document["simulation"]["time"] = 999 + path.write_text(json.dumps(document)) + with pytest.raises(CheckpointError, match="state digest does not match"): + load_checkpoint_bundle(path) + + +def test_scene_v2_verifies_original_payload_and_v3_rejects_invalid_labels() -> None: + model, _ = _build() + document = json.loads(dumps_scene(capture_scene(model.simulation, channel_metadata=LABELS))) + document["version"] = 2 + del document["frame"]["channel_metadata"] + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + assert parse_scene(json.dumps(document)).channel_metadata == ChannelMetadata().resolved(2, 2) + document["frame"]["time"] = 999 + with pytest.raises(SceneError, match="frame digest does not match"): + parse_scene(json.dumps(document)) + document["version"] = 3 + document["frame"]["channel_metadata"] = {"species": [], "signals": [None, None]} + document["integrity"]["frame"] = hashlib.sha256(rfc8785.dumps(document["frame"])).hexdigest() + with pytest.raises(SceneError, match="species: expected 2 labels"): + parse_scene(json.dumps(document)) + + +def test_unnamed_native_model_and_closed_channel_schema() -> None: + simulation = Simulation(species_count=2) + assert model_channel_metadata(simulation) == ChannelMetadata(species=(None, None), signals=()) + with pytest.raises(ChannelMetadataError, match="exactly species and signals"): + ChannelMetadata.from_json({"species": [], "signals": [], "extra": []}, 0, 0) + + +def test_resumed_model_cannot_silently_replace_persisted_labels(tmp_path: Path) -> None: + model, provenance = _build() + path = tmp_path / "labels.json" + run_simulation(model, steps=0, dt=0.1, output=path, provenance=provenance) + bundle = load_checkpoint_bundle(path) + changed = replace( + bundle, + channel_metadata=ChannelMetadata( + species=("Changed", "Red reporter"), signals=LABELS.signals + ), + ) + # Standard native restore treats the checkpoint's labels as authoritative. + resumed, _ = build_model(EXAMPLE, ModelContext(BackendKind.CPU, 0, 17), checkpoint=changed) + assert model_channel_metadata(resumed) == changed.channel_metadata + + +def test_shared_python_typescript_v3_fixture() -> None: + frame = load_scene(ROOT / "viewer/tests/fixtures/channels-v3.scene.json") + assert frame.channel_metadata.species == ("α 🧪", "α 🧪") + assert frame.channel_metadata.signals == (None, " ") diff --git a/python/tests/test_checkpoint.py b/python/tests/test_checkpoint.py index 3ce896d..17f959f 100644 --- a/python/tests/test_checkpoint.py +++ b/python/tests/test_checkpoint.py @@ -263,6 +263,8 @@ def test_version_one_checkpoint_migrates_to_an_empty_signal_state(tmp_path: Path save_checkpoint(simulation, path) document = _document(path) document["version"] = 1 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["signal_grid"] @@ -284,6 +286,8 @@ def test_version_two_checkpoint_migrates_without_a_coupled_plan(tmp_path: Path) save_checkpoint(simulation, path) document = _document(path) document["version"] = 2 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["coupled_rate_plan"] @@ -307,6 +311,8 @@ def test_version_three_checkpoint_migrates_without_controller_state(tmp_path: Pa save_checkpoint(simulation, path) document = _document(path) document["version"] = 3 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["controller"] del document["integrity"]["controller"] del document["simulation"]["signal_grid"]["spec"]["integration"] @@ -330,6 +336,8 @@ def test_version_four_signal_grid_migrates_to_forward_euler(tmp_path: Path) -> N save_checkpoint(simulation, path) document = _document(path) document["version"] = 4 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] del document["simulation"]["signal_grid"]["spec"]["integration"] del document["simulation"]["signal_grid"]["spec"]["solver"] _remove_affine_reaction(document) @@ -350,6 +358,8 @@ def test_version_five_cells_migrate_to_movable(tmp_path: Path) -> None: save_checkpoint(simulation, path) document = _document(path) document["version"] = 5 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_affine_reaction(document) _remove_fixed_fields(document) _remove_constraint_boxes(document) @@ -366,6 +376,8 @@ def test_version_six_signal_grid_migrates_without_affine_reactions(tmp_path: Pat save_checkpoint(simulation, path) document = _document(path) document["version"] = 6 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_affine_reaction(document) _remove_constraint_boxes(document) _remove_grid_obstacles(document) @@ -383,6 +395,8 @@ def test_version_seven_checkpoint_migrates_without_boxes(tmp_path: Path) -> None save_checkpoint(simulation, path) document = _document(path) document["version"] = 7 + del document["channel_metadata"] + del document["integrity"]["channel_metadata"] _remove_constraint_boxes(document) _remove_grid_obstacles(document) _rewrite_with_state_digest(path, document) diff --git a/python/tests/test_sbml.py b/python/tests/test_sbml.py index fc5aa56..7b8f3d3 100644 --- a/python/tests/test_sbml.py +++ b/python/tests/test_sbml.py @@ -187,3 +187,10 @@ def test_malformed_or_empty_sbml_fails_explicitly() -> None: ) with pytest.raises(SBMLImportError, match="Level 3 Version 2"): parse_sbml(level_two) + + +def test_sbml_channel_metadata_uses_names_and_identifier_fallback() -> None: + source = _model_xml().replace('name="substrate"', 'name=""') + model = parse_sbml(source) + assert model.channel_metadata.species == ("A", "product") + assert model.channel_metadata.signals is None diff --git a/viewer/README.md b/viewer/README.md index 1eea13e..b9c8a8e 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -62,3 +62,7 @@ pnpm --dir viewer build ``` The unit suite includes a Python-authored scene fixture whose digest contains floating-point values that ordinary Python and JavaScript JSON serializers spell differently. Passing that test is the cross-language integrity gate. + +## Channel labels + +Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2 and v3, while writers emit v3. See the [authoring guide](../docs/models/channel-labels.md) and [scene v3 schema](../docs/formats/scene-v3.md). diff --git a/viewer/browser/channel-labels.mjs b/viewer/browser/channel-labels.mjs new file mode 100644 index 0000000..862de9f --- /dev/null +++ b/viewer/browser/channel-labels.mjs @@ -0,0 +1,120 @@ +// Uses the shared external Playwright harness; no application debug API is shipped. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4318"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-channel-labels"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `${marker}\nglobalThis.__testViewer = this;`, + ), + }); + }); + let socket; + await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; + }); + await page.goto(`${url}/?token=fixture`); + await expect.poll(() => socket !== undefined).toBe(true); + const document = JSON.parse( + await readFile( + new URL("../tests/fixtures/channels-v3.scene.json", import.meta.url), + "utf8", + ), + ); + let revision = 0; + async function send() { + document.integrity.frame = createHash("sha256") + .update(canonicalize(document.frame)) + .digest("hex"); + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: document, + }), + ); + } + await send(); + await expect(page.locator("#species-channel option")).toHaveText([ + "α 🧪 [0]", + "α 🧪 [1]", + ]); + await expect(page.locator("#signal-channel option")).toHaveText([ + "Channel 0", + "Channel 1", + ]); + await page.selectOption("#color-mode", "species"); + await page.selectOption("#species-channel", "1"); + await page.selectOption("#signal-channel", "1"); + await expect(page.locator("#legend-title")).toHaveText("α 🧪 [1]"); + await page.evaluate(() => globalThis.__testViewer.selectCell(0)); + await expect(page.locator("#species-values li span")).toHaveText([ + "α 🧪 [0]", + "α 🧪 [1]", + ]); + await expect( + page.locator("#species-values b, #species-channel b, #legend-title b"), + ).toHaveCount(0); + document.frame.channel_metadata = { + species: ["Green reporter", "Red reporter"], + signals: ["Nutrient", "Extracellular cue"], + }; + document.frame.time = 1; + await send(); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await expect(page.locator("#signal-channel")).toHaveValue("1"); + await expect(page.locator("#species-channel option")).toHaveText([ + "Green reporter", + "Red reporter", + ]); + await expect(page.locator("#signal-channel option")).toHaveText([ + "Nutrient", + "Extracellular cue", + ]); + await expect(page.locator("#legend-title")).toHaveText("Red reporter"); + await expect(page.locator("#species-values li span")).toHaveText([ + "Green reporter", + "Red reporter", + ]); + document.frame.time = 0; + await send(); + await expect(page.locator("#time-chip")).toHaveText("t = 0"); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await page.screenshot({ path: `${evidence}/named-channels.png` }); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + status: "passed", + assertions: + "selector names, duplicate disambiguation, HTML text safety, Unicode, inspector, legend, index-stable renamed frames, reset time", + screenshot: `${evidence}/named-channels.png`, + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/src/color.ts b/viewer/src/color.ts index 22c0b16..f5b93f0 100644 --- a/viewer/src/color.ts +++ b/viewer/src/color.ts @@ -1,4 +1,4 @@ -import type { SceneCell, SceneFrame } from "./scene"; +import { channelLabel, type SceneCell, type SceneFrame } from "./scene"; export type RGB = readonly [number, number, number]; export type ColorMode = "cell-type" | "species" | "growth-rate" | "fixed"; @@ -128,7 +128,7 @@ export function mapCellColors( return scalarMapping( frame.cells, frame.cells.map((cell) => cell.species[config.speciesIndex] ?? 0), - `Species ${config.speciesIndex}`, + channelLabel(frame, "species", config.speciesIndex), ); } } diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 901b4e9..e1edf5a 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -11,6 +11,7 @@ import { } from "./live"; import { MAX_SCENE_BYTES, + channelLabel, parseScene, type SceneCell, type SceneFrame, @@ -103,14 +104,14 @@ function setStatus(message: string, kind: "info" | "error" = "info"): void { function options( select: HTMLSelectElement, count: number, - prefix: string, + label: (index: number) => string, ): void { const previous = selectedInteger(select); select.replaceChildren(); for (let index = 0; index < count; index += 1) { const option = document.createElement("option"); option.value = String(index); - option.textContent = `${prefix} ${index}`; + option.textContent = label(index); select.append(option); } select.value = String(Math.min(previous, Math.max(count - 1, 0))); @@ -208,7 +209,10 @@ function updateSelection(cell: SceneCell | null): void { const item = document.createElement("li"); const label = document.createElement("span"); const encoded = document.createElement("code"); - label.textContent = `Channel ${index}`; + label.textContent = + frame === null + ? `Channel ${index}` + : channelLabel(frame, "species", index); encoded.textContent = formatNumber(value); item.append(label, encoded); speciesValues.append(item); @@ -248,7 +252,9 @@ function presentScene( gridShape.textContent = next.signalGrid === null ? "None" : next.signalGrid.shape.join(" × "); - options(speciesChannel, next.speciesCount, "Channel"); + options(speciesChannel, next.speciesCount, (index) => + channelLabel(next, "species", index), + ); if (next.speciesCount === 0 && colorMode.value === "species") { colorMode.value = "cell-type"; } @@ -261,7 +267,9 @@ function presentScene( signalSection.hidden = next.signalGrid === null; if (next.signalGrid !== null) { - options(signalChannel, next.signalGrid.signalCount, "Channel"); + options(signalChannel, next.signalGrid.signalCount, (index) => + channelLabel(next, "signals", index), + ); if (!sameShape(previous?.signalGrid ?? null, next.signalGrid)) { signalVisible.checked = true; signalAxis.value = "z"; diff --git a/viewer/src/scene.ts b/viewer/src/scene.ts index 439e555..3885f0f 100644 --- a/viewer/src/scene.ts +++ b/viewer/src/scene.ts @@ -1,7 +1,7 @@ import canonicalize from "canonicalize"; export const SCENE_FORMAT = "microsimulator-scene"; -export const SCENE_VERSION = 2; +export const SCENE_VERSION = 3; export const MAX_SCENE_BYTES = 1 << 30; const UINT32_MAX = 2 ** 32 - 1; @@ -97,10 +97,38 @@ export interface SceneSignalGrid { readonly levels: readonly number[]; } +export type ChannelKind = "species" | "signals"; +export interface ChannelMetadata { + readonly species: readonly (string | null)[]; + readonly signals: readonly (string | null)[]; +} + +/** Presentation only. Numerical indices, never labels, identify channels. */ +export function channelLabel( + frame: SceneFrame, + kind: ChannelKind, + index: number, +): string { + const labels = frame.channelMetadata[kind]; + if (!Number.isInteger(index) || index < 0 || index >= labels.length) { + throw new RangeError(`${kind} channel ${index} is out of range`); + } + const display = (label: string | null | undefined, slot: number): string => + label === null || label === undefined || label.trim() === "" + ? `Channel ${slot}` + : label; + const label = display(labels[index], index); + const duplicated = labels.some( + (other, slot) => slot !== index && display(other, slot) === label, + ); + return duplicated ? `${label} [${index}]` : label; +} + export interface SceneFrame { readonly time: number; readonly backend: SceneBackend; readonly speciesCount: number; + readonly channelMetadata: ChannelMetadata; readonly cells: readonly SceneCell[]; readonly constraints: SceneConstraints; readonly signalGrid: SceneSignalGrid | null; @@ -551,7 +579,43 @@ function parseSignalGrid(value: unknown, path: string): SceneSignalGrid | null { }; } -function parseFrame(value: unknown, path: string): SceneFrame { +function parseChannelMetadata( + value: unknown, + path: string, + speciesCount: number, + signalCount: number, +): ChannelMetadata { + const data = record(value, path); + exactKeys(data, path, ["species", "signals"]); + function labels( + kind: ChannelKind, + count: number, + ): readonly (string | null)[] { + const values = array(data[kind], `${path}.${kind}`); + if (values.length !== count) { + return fail( + `${path}.${kind}`, + `expected ${count} labels, got ${values.length}`, + ); + } + return values.map((value, index) => { + if (value === null) return null; + const label = string(value, `${path}.${kind}[${index}]`); + if (/[\uD800-\uDFFF]/u.test(label)) + return fail( + `${path}.${kind}[${index}]`, + "invalid Unicode scalar value", + ); + return label; + }); + } + return { + species: labels("species", speciesCount), + signals: labels("signals", signalCount), + }; +} + +function parseFrame(value: unknown, path: string, version: number): SceneFrame { const data = record(value, path); exactKeys(data, path, [ "time", @@ -560,6 +624,7 @@ function parseFrame(value: unknown, path: string): SceneFrame { "cells", "constraints", "signal_grid", + ...(version >= 3 ? ["channel_metadata"] : []), ]); const time = number(data.time, `${path}.time`); if (time < 0) { @@ -587,13 +652,29 @@ function parseFrame(value: unknown, path: string): SceneFrame { } identifiers.add(cell.id); } + const signalGrid = parseSignalGrid(data.signal_grid, `${path}.signal_grid`); + const signalCount = signalGrid?.signalCount ?? 0; + // Digest verification has already completed against the unmodified source frame. + const channelMetadata = + version >= 3 + ? parseChannelMetadata( + data.channel_metadata, + `${path}.channel_metadata`, + speciesCount, + signalCount, + ) + : { + species: Array(speciesCount).fill(null), + signals: Array(signalCount).fill(null), + }; return { time, + channelMetadata, backend: parseBackend(data.backend, `${path}.backend`), speciesCount, cells, constraints: parseConstraints(data.constraints, `${path}.constraints`), - signalGrid: parseSignalGrid(data.signal_grid, `${path}.signal_grid`), + signalGrid, }; } @@ -639,7 +720,8 @@ export async function parseScene(source: string): Promise { ) { return fail("$.format", "not a MicroSimulator scene"); } - if (integer(root.version, "$.version", 0, UINT32_MAX) !== SCENE_VERSION) { + const version = integer(root.version, "$.version", 0, UINT32_MAX); + if (version !== 2 && version !== SCENE_VERSION) { return fail( "$.version", `unsupported scene version ${String(root.version)}`, @@ -672,5 +754,5 @@ export async function parseScene(source: string): Promise { if (actualDigest !== expectedDigest) { return fail("$.integrity.frame", "frame digest does not match"); } - return parseFrame(root.frame, "$.frame"); + return parseFrame(root.frame, "$.frame", version); } diff --git a/viewer/tests/channels.test.ts b/viewer/tests/channels.test.ts new file mode 100644 index 0000000..4f4d236 --- /dev/null +++ b/viewer/tests/channels.test.ts @@ -0,0 +1,120 @@ +import canonicalize from "canonicalize"; +import { describe, expect, it } from "vitest"; + +import { mapCellColors } from "../src/color"; +import { channelLabel, parseScene } from "../src/scene"; +import pythonScene from "./fixtures/channels-v3.scene.json?raw"; + +async function sign(frame: unknown, version = 3): Promise { + const bytes = new TextEncoder().encode(canonicalize(frame)); + const digest = [ + ...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), + ] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return JSON.stringify({ + format: "microsimulator-scene", + version, + producer: { name: "test", version: "1" }, + integrity: { algorithm: "sha256", frame: digest }, + frame, + }); +} + +interface Document { + version: number; + frame: { + time: number; + channel_metadata?: { species: unknown[]; signals: unknown[] }; + }; +} + +const document = (): Document => JSON.parse(pythonScene) as Document; + +describe("channel metadata", () => { + it("reads the Python-produced v3 fixture with Unicode, markup and missing labels", async () => { + const frame = await parseScene(pythonScene); + expect(frame.channelMetadata).toEqual({ + species: ["α 🧪", "α 🧪"], + signals: [null, " "], + }); + expect(channelLabel(frame, "species", 0)).toBe("α 🧪 [0]"); + expect(channelLabel(frame, "species", 1)).toBe("α 🧪 [1]"); + expect(channelLabel(frame, "signals", 0)).toBe("Channel 0"); + expect(channelLabel(frame, "signals", 1)).toBe("Channel 1"); + expect(() => channelLabel(frame, "species", 2)).toThrow(RangeError); + }); + + it("keeps numerical values/color selection independent of label text", async () => { + const frame = await parseScene(pythonScene); + const config = { mode: "species" as const, speciesIndex: 1 }; + const renamed = { + ...frame, + channelMetadata: { + species: ["Changed", "Chosen"], + signals: [null, null], + }, + }; + const before = mapCellColors(frame, config); + const after = mapCellColors(renamed, config); + expect(before.colors).toEqual(after.colors); + expect(after.title).toBe("Chosen"); + expect(renamed.cells).toEqual(frame.cells); + }); + + it("disambiguates explicit names that collide with missing-label fallbacks", async () => { + const frame = await parseScene(pythonScene); + const labels = { + ...frame, + channelMetadata: { species: [null, "Channel 0"], signals: [] }, + }; + expect(channelLabel(labels, "species", 0)).toBe("Channel 0 [0]"); + expect(channelLabel(labels, "species", 1)).toBe("Channel 0 [1]"); + }); + + it("authenticates v2 without inserting metadata before digest verification", async () => { + const old = document(); + delete old.frame.channel_metadata; + const encoded = await sign(old.frame, 2); + const frame = await parseScene(encoded); + expect(frame.channelMetadata).toEqual({ + species: [null, null], + signals: [null, null], + }); + expect(channelLabel(frame, "species", 1)).toBe("Channel 1"); + const tampered = JSON.parse(encoded) as Document; + tampered.frame.time += 1; + await expect(parseScene(JSON.stringify(tampered))).rejects.toThrow( + "frame digest does not match", + ); + }); + + it("rejects label tampering even when channel counts are unchanged", async () => { + const tampered = document(); + tampered.frame.channel_metadata!.species[0] = "forged"; + await expect(parseScene(JSON.stringify(tampered))).rejects.toThrow( + "frame digest does not match", + ); + }); + + it("rejects invalid Unicode before integrity verification", async () => { + const invalid = document(); + invalid.frame.channel_metadata!.species[0] = "\ud800"; + await expect(parseScene(JSON.stringify(invalid))).rejects.toThrow( + "Lone surrogate", + ); + }); + + it.each([ + { species: [null], signals: [null, null] }, + { species: [null, 3], signals: [null, null] }, + { species: [null, null], signals: [null, null], extra: true }, + ])( + "rejects malformed metadata after valid integrity verification: %j", + async (metadata) => { + const invalid = document(); + invalid.frame.channel_metadata = metadata; + await expect(parseScene(await sign(invalid.frame))).rejects.toThrow(); + }, + ); +}); diff --git a/viewer/tests/color.test.ts b/viewer/tests/color.test.ts index 8da7817..5ec1f98 100644 --- a/viewer/tests/color.test.ts +++ b/viewer/tests/color.test.ts @@ -34,6 +34,7 @@ const frame: SceneFrame = { native: false, }, speciesCount: 2, + channelMetadata: { species: [null, null], signals: [] }, constraints: { planes: [], spheres: [], boxes: [], cylinders: [] }, cells: [ cell(0, -1, 0.5, [2, 8]), diff --git a/viewer/tests/fixtures/channels-v3.scene.json b/viewer/tests/fixtures/channels-v3.scene.json new file mode 100644 index 0000000..a74880e --- /dev/null +++ b/viewer/tests/fixtures/channels-v3.scene.json @@ -0,0 +1,85 @@ +{ + "format": "microsimulator-scene", + "frame": { + "backend": { + "device": "host", + "device_index": 0, + "kind": "cpu", + "name": "cpu-reference", + "native": true + }, + "cells": [ + { + "cell_type": 0, + "direction": [1.0, 0.0, 0.0], + "fixed": false, + "growth_rate": 1.0, + "id": "1", + "length": 2.0, + "parent_id": null, + "position": [0.0, 0.0, 0.0], + "radius": 0.5, + "slot": 0, + "species": [0.25, 0.75] + } + ], + "channel_metadata": { + "signals": [null, " "], + "species": ["α 🧪", "α 🧪"] + }, + "constraints": { + "boxes": [], + "cylinders": [], + "planes": [], + "spheres": [] + }, + "signal_grid": { + "boundaries": { + "x_lower": { + "kind": "no_flux", + "values": [] + }, + "x_upper": { + "kind": "no_flux", + "values": [] + }, + "y_lower": { + "kind": "no_flux", + "values": [] + }, + "y_upper": { + "kind": "no_flux", + "values": [] + }, + "z_lower": { + "kind": "no_flux", + "values": [] + }, + "z_upper": { + "kind": "no_flux", + "values": [] + } + }, + "levels": [ + 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, + 0.25, 0.25, 0.25, 0.25, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, + 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75, 0.75 + ], + "origin": [0.0, 0.0, 0.0], + "shape": [4, 4, 1], + "signal_count": 2, + "spacing": [1.0, 1.0, 1.0] + }, + "species_count": 2, + "time": 0.0 + }, + "integrity": { + "algorithm": "sha256", + "frame": "4ed41b5e8443c5da0da1843135647a0ed1d36b1681a2e3fc902199dd75dd3ef7" + }, + "producer": { + "name": "microsimulator", + "version": "0.1.0" + }, + "version": 3 +} From ef3ce89179e755800c5d9aa17b1280d706045cd6 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:27:34 -0600 Subject: [PATCH 06/38] Share validated scalar ranges across cell and signal coloring --- viewer/src/colony-viewer.ts | 22 ++--- viewer/src/color.ts | 48 ++++++---- viewer/src/scalar-range.ts | 148 ++++++++++++++++++++++++++++++ viewer/tests/scalar-range.test.ts | 147 +++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 viewer/src/scalar-range.ts create mode 100644 viewer/tests/scalar-range.test.ts diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 26609d7..c9c9605 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -34,7 +34,8 @@ import { } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; -import { rgbBytes, viridis, type RGB } from "./color"; +import { mapScalarColors, rgbBytes, type RGB } from "./color"; +import { AUTOMATIC_SCALAR_RANGE, type ScalarRangeConfig } from "./scalar-range"; import type { SignalSlice } from "./grid"; import { DatasetReferenceGrid, @@ -350,25 +351,20 @@ export class ColonyViewer { } } - public setSignalSlice(slice: SignalSlice | null): void { + public setSignalSlice( + slice: SignalSlice | null, + range: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, + ): void { this.signalTexture?.dispose(); this.signalTexture = null; disposeGroup(this.signal); if (slice === null) { return; } - let minimum = Number.POSITIVE_INFINITY; - let maximum = Number.NEGATIVE_INFINITY; - for (const value of slice.values) { - minimum = Math.min(minimum, value); - maximum = Math.max(maximum, value); - } - const span = maximum - minimum; + const mapping = mapScalarColors(slice.values, range); const pixels = new Uint8Array(slice.width * slice.height * 4); - for (const [index, value] of slice.values.entries()) { - const color = rgbBytes( - viridis(span === 0 ? 0.5 : (value - minimum) / span), - ); + for (const [index, value] of mapping.colors.entries()) { + const color = rgbBytes(value); const offset = index * 4; pixels[offset] = color[0]; pixels[offset + 1] = color[1]; diff --git a/viewer/src/color.ts b/viewer/src/color.ts index 22c0b16..0596cc2 100644 --- a/viewer/src/color.ts +++ b/viewer/src/color.ts @@ -1,4 +1,11 @@ -import type { SceneCell, SceneFrame } from "./scene"; +import type { SceneFrame } from "./scene"; +import { + AUTOMATIC_SCALAR_RANGE, + normalizeScalar, + resolveScalarRange, + type ResolvedScalarRange, + type ScalarRangeConfig, +} from "./scalar-range"; export type RGB = readonly [number, number, number]; export type ColorMode = "cell-type" | "species" | "growth-rate" | "fixed"; @@ -6,6 +13,7 @@ export type ColorMode = "cell-type" | "species" | "growth-rate" | "fixed"; export interface ColorConfig { readonly mode: ColorMode; readonly speciesIndex: number; + readonly range?: ScalarRangeConfig; } export interface ColorMapping { @@ -13,6 +21,7 @@ export interface ColorMapping { readonly title: string; readonly minimum: number | null; readonly maximum: number | null; + readonly range: ResolvedScalarRange | null; } const TYPE_PALETTE: readonly RGB[] = [ @@ -59,28 +68,28 @@ export function viridis(value: number): RGB { return VIRIDIS.at(-1)?.[1] ?? [1, 1, 1]; } +export function mapScalarColors( + values: readonly number[], + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, +): { colors: readonly RGB[]; range: ResolvedScalarRange } { + const range = resolveScalarRange(values, config); + return { + colors: values.map((value) => viridis(normalizeScalar(value, range))), + range, + }; +} + function scalarMapping( - cells: readonly SceneCell[], values: readonly number[], title: string, + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, ): ColorMapping { - if (values.length === 0) { - return { colors: [], title, minimum: null, maximum: null }; - } - let minimum = Number.POSITIVE_INFINITY; - let maximum = Number.NEGATIVE_INFINITY; - for (const value of values) { - minimum = Math.min(minimum, value); - maximum = Math.max(maximum, value); - } - const span = maximum - minimum; + const mapping = mapScalarColors(values, config); return { - colors: cells.map((_, index) => - viridis(span === 0 ? 0.5 : ((values[index] ?? minimum) - minimum) / span), - ), + ...mapping, title, - minimum, - maximum, + minimum: mapping.range.minimum, + maximum: mapping.range.maximum, }; } @@ -100,6 +109,7 @@ export function mapCellColors( title: "Cell type", minimum: null, maximum: null, + range: null, }; case "fixed": return { @@ -109,10 +119,10 @@ export function mapCellColors( title: "Fixed state", minimum: null, maximum: null, + range: null, }; case "growth-rate": return scalarMapping( - frame.cells, frame.cells.map((cell) => cell.growthRate), "Growth rate", ); @@ -126,9 +136,9 @@ export function mapCellColors( ); } return scalarMapping( - frame.cells, frame.cells.map((cell) => cell.species[config.speciesIndex] ?? 0), `Species ${config.speciesIndex}`, + config.range, ); } } diff --git a/viewer/src/scalar-range.ts b/viewer/src/scalar-range.ts new file mode 100644 index 0000000..4cdf031 --- /dev/null +++ b/viewer/src/scalar-range.ts @@ -0,0 +1,148 @@ +export type ScalarRangeConfig = + | Readonly<{ mode: "automatic" }> + | Readonly<{ mode: "fixed"; minimum: number; maximum: number }>; +export type FixedScalarRange = Extract; +export type ScalarChannelKind = "species" | "signals"; +export interface ResolvedScalarRange { + readonly mode: ScalarRangeConfig["mode"]; + readonly minimum: number | null; + readonly maximum: number | null; + readonly count: number; +} +export const AUTOMATIC_SCALAR_RANGE: ScalarRangeConfig = Object.freeze({ + mode: "automatic", +}); + +export function validateScalarRange(config: ScalarRangeConfig): void { + if (config.mode === "automatic") return; + if (!Number.isFinite(config.minimum)) + throw new RangeError("Minimum must be a finite number."); + if (!Number.isFinite(config.maximum)) + throw new RangeError("Maximum must be a finite number."); + if (config.minimum >= config.maximum) + throw new RangeError("Minimum must be less than maximum."); +} + +export function parseFixedScalarRange( + minimum: string, + maximum: string, +): FixedScalarRange { + const number = (text: string, label: string): number => { + if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(text.trim())) { + throw new RangeError(`${label} must be a finite number.`); + } + return Number(text); + }; + const result = { + mode: "fixed" as const, + minimum: number(minimum, "Minimum"), + maximum: number(maximum, "Maximum"), + }; + validateScalarRange(result); + return result; +} + +/** Empty automatic ranges have no bounds; fixed bounds still describe a configured scale. */ +export function resolveScalarRange( + values: readonly number[], + config: ScalarRangeConfig = AUTOMATIC_SCALAR_RANGE, +): ResolvedScalarRange { + validateScalarRange(config); + let minimum: number | null = null; + let maximum: number | null = null; + for (const value of values) { + if (!Number.isFinite(value)) + throw new RangeError("Scalar values must be finite."); + minimum = minimum === null ? value : Math.min(minimum, value); + maximum = maximum === null ? value : Math.max(maximum, value); + } + return { + mode: config.mode, + minimum: config.mode === "fixed" ? config.minimum : minimum, + maximum: config.mode === "fixed" ? config.maximum : maximum, + count: values.length, + }; +} + +/** Return display intensity only; the source value is never modified. */ +export function normalizeScalar( + value: number, + range: ResolvedScalarRange, +): number { + if (!Number.isFinite(value)) + throw new RangeError("Scalar values must be finite."); + const { minimum, maximum } = range; + if (minimum === null || maximum === null) + throw new RangeError("Cannot normalize without scalar bounds."); + if (maximum === minimum) return 0.5; + if (value <= minimum) return 0; + if (value >= maximum) return 1; + const span = maximum - minimum; + // Extreme finite user bounds can overflow their difference. Scaling both + // numerator and denominator preserves the ratio without overflowing. + const normalized = Number.isFinite(span) + ? (value - minimum) / span + : (value / 2 - minimum / 2) / (maximum / 2 - minimum / 2); + return Math.min(1, Math.max(0, normalized)); +} + +/** Valid initial bounds when switching from automatic to fixed display. */ +export function suggestedFixedRange( + range: ResolvedScalarRange, +): FixedScalarRange { + const { minimum, maximum } = range; + if (minimum === null || maximum === null) + return { mode: "fixed", minimum: 0, maximum: 1 }; + if (minimum < maximum) return { mode: "fixed", minimum, maximum }; + const padding = Math.max(Math.abs(minimum) / 2, 0.5); + return { + mode: "fixed", + minimum: Math.max(-Number.MAX_VALUE, minimum - padding), + maximum: Math.min(Number.MAX_VALUE, maximum + padding), + }; +} + +/** Dataset-local settings keyed by kind and numerical channel index, never labels. */ +export class DatasetScalarRanges { + private readonly channels = new Map< + string, + { active: ScalarRangeConfig; fixed: FixedScalarRange | null } + >(); + private key(kind: ScalarChannelKind, index: number): string { + if (!Number.isSafeInteger(index) || index < 0) + throw new RangeError( + "Channel index must be a non-negative safe integer.", + ); + return `${kind}:${index}`; + } + public beginDataset(): void { + this.channels.clear(); + } + public get(kind: ScalarChannelKind, index: number): ScalarRangeConfig { + return ( + this.channels.get(this.key(kind, index))?.active ?? AUTOMATIC_SCALAR_RANGE + ); + } + public lastFixed( + kind: ScalarChannelKind, + index: number, + ): FixedScalarRange | null { + return this.channels.get(this.key(kind, index))?.fixed ?? null; + } + public set( + kind: ScalarChannelKind, + index: number, + config: ScalarRangeConfig, + ): void { + validateScalarRange(config); + const key = this.key(kind, index); + const active = Object.freeze({ ...config }); + this.channels.set(key, { + active, + fixed: + active.mode === "fixed" + ? active + : (this.channels.get(key)?.fixed ?? null), + }); + } +} diff --git a/viewer/tests/scalar-range.test.ts b/viewer/tests/scalar-range.test.ts new file mode 100644 index 0000000..aea503e --- /dev/null +++ b/viewer/tests/scalar-range.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { mapScalarColors, viridis } from "../src/color"; +import { + DatasetScalarRanges, + normalizeScalar, + parseFixedScalarRange, + resolveScalarRange, + suggestedFixedRange, +} from "../src/scalar-range"; + +const fixed = { mode: "fixed" as const, minimum: 0, maximum: 10 }; + +describe("scalar normalization", () => { + it("keeps shared values identical across different frame and slice extrema", () => { + const first = mapScalarColors([2, 4], fixed); + const second = mapScalarColors([2, 40], fixed); + expect(first.colors[0]).toEqual(second.colors[0]); + expect(first.colors[0]).toEqual(viridis(0.2)); + expect(second.colors[1]).toEqual(viridis(1)); + }); + it("clips display intensity without modifying negative/out-of-range data", () => { + const values = Object.freeze([-30, -10, 0, 10, 300]); + expect( + mapScalarColors(values, { mode: "fixed", minimum: -10, maximum: 10 }) + .colors, + ).toEqual([viridis(0), viridis(0), viridis(0.5), viridis(1), viridis(1)]); + expect(values).toEqual([-30, -10, 0, 10, 300]); + }); + it("represents automatic empty data without invented extrema", () => { + expect(mapScalarColors([])).toEqual({ + colors: [], + range: { mode: "automatic", minimum: null, maximum: null, count: 0 }, + }); + expect(mapScalarColors([], fixed)).toEqual({ + colors: [], + range: { ...fixed, count: 0 }, + }); + }); + it("uses the midpoint for constant automatic data and fixed bounds for constant fixed data", () => { + expect(mapScalarColors([7, 7]).colors).toEqual([ + viridis(0.5), + viridis(0.5), + ]); + expect(mapScalarColors([7, 7], fixed).colors).toEqual([ + viridis(0.7), + viridis(0.7), + ]); + expect(resolveScalarRange([7, 7])).toMatchObject({ + minimum: 7, + maximum: 7, + }); + }); + it("rejects non-finite source values rather than generating NaN colors", () => { + for (const invalid of [NaN, Infinity, -Infinity]) { + expect(() => mapScalarColors([invalid])).toThrow("must be finite"); + expect(() => + normalizeScalar(invalid, resolveScalarRange([0, 1])), + ).toThrow("must be finite"); + } + }); + it("handles extreme finite bounds without overflowing normalization", () => { + const range = resolveScalarRange([-Number.MAX_VALUE, 0, Number.MAX_VALUE], { + mode: "fixed", + minimum: -Number.MAX_VALUE, + maximum: Number.MAX_VALUE, + }); + expect(normalizeScalar(0, range)).toBe(0.5); + expect(normalizeScalar(-Number.MAX_VALUE / 2, range)).toBe(0.25); + expect(normalizeScalar(Number.MAX_VALUE / 2, range)).toBe(0.75); + const tiny = resolveScalarRange([0], { + mode: "fixed", + minimum: 0, + maximum: Number.MIN_VALUE, + }); + expect(normalizeScalar(Number.MIN_VALUE, tiny)).toBe(1); + }); + it("suggests valid bounds for empty, constant, and extreme automatic ranges", () => { + for (const values of [ + [], + [0], + [7], + [-7], + [Number.MAX_VALUE], + [-Number.MAX_VALUE], + [2, 40], + ]) { + const suggestion = suggestedFixedRange(resolveScalarRange(values)); + expect(Number.isFinite(suggestion.minimum)).toBe(true); + expect(Number.isFinite(suggestion.maximum)).toBe(true); + expect(suggestion.minimum).toBeLessThan(suggestion.maximum); + } + }); +}); + +describe("fixed range validation and channel state", () => { + it("accepts negative finite numbers and scientific notation", () => { + expect(parseFixedScalarRange(" -1e2 ", ".5")).toEqual({ + mode: "fixed", + minimum: -100, + maximum: 0.5, + }); + }); + it("rejects missing, nonnumeric, infinite, equal and reversed bounds", () => { + for (const [minimum, maximum] of [ + ["", "1"], + ["0", "NaN"], + ["0", "Infinity"], + ["0", "1e309"], + ["0x10", "20"], + ["2", "2"], + ["3", "2"], + ]) { + expect(() => parseFixedScalarRange(minimum!, maximum!)).toThrow(); + } + }); + it("retains the last valid configuration after invalid changes", () => { + const state = new DatasetScalarRanges(); + state.set("species", 0, fixed); + for (const bounds of [ + { minimum: 2, maximum: 2 }, + { minimum: 3, maximum: 2 }, + { minimum: NaN, maximum: 2 }, + { minimum: 0, maximum: Infinity }, + ]) { + expect(() => + state.set("species", 0, { mode: "fixed", ...bounds }), + ).toThrow(); + expect(state.get("species", 0)).toEqual(fixed); + } + }); + it("separates channel kind/index, remembers fixed bounds, and resets only on a new dataset", () => { + const state = new DatasetScalarRanges(); + state.set("species", 0, fixed); + state.set("species", 1, { mode: "fixed", minimum: -1, maximum: 1 }); + state.set("signals", 0, { mode: "fixed", minimum: 10, maximum: 20 }); + expect(state.get("species", 0)).toEqual(fixed); + expect(state.get("species", 2)).toEqual({ mode: "automatic" }); + state.set("species", 0, { mode: "automatic" }); + expect(state.lastFixed("species", 0)).toEqual(fixed); + expect(state.get("species", 1)).toMatchObject({ minimum: -1 }); + expect(state.get("signals", 0)).toMatchObject({ minimum: 10 }); + state.beginDataset(); + expect(state.get("species", 0)).toEqual({ mode: "automatic" }); + expect(state.lastFixed("species", 0)).toBeNull(); + expect(state.get("signals", 0)).toEqual({ mode: "automatic" }); + }); +}); From af9954886c74cde9e0d96fa384d0adf64cbf4dfd Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:32:14 -0600 Subject: [PATCH 07/38] Preserve device visibility as a dataset presentation preference --- viewer/README.md | 6 + viewer/browser/device-visibility.mjs | 273 ++++++++++++++++++++++++ viewer/index.html | 4 + viewer/src/colony-viewer.ts | 6 + viewer/src/main.ts | 10 + viewer/src/presentation-state.ts | 2 + viewer/src/style.css | 17 ++ viewer/tests/presentation-state.test.ts | 12 ++ 8 files changed, 330 insertions(+) create mode 100644 viewer/browser/device-visibility.mjs diff --git a/viewer/README.md b/viewer/README.md index 237d6cc..3b4d0eb 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -72,3 +72,9 @@ Opening a scene file, live session, or recording begins a new dataset. Call `Dat The ground reference grid is separate from the scientific signal lattice. Its square extent and origin come from the first frame's finite device geometry (boxes, spheres, and cylinders), or from the initial cell capsule bounds when no finite device exists. Infinite plane constraints are excluded. The extent is at least 10 scene distance units with 20 equal divisions; the grid plane is 0.01 units below the lesser of the initial lower Z bound and zero. An initially empty dataset uses a 10-unit grid centered on the world origin. These values remain fixed even when cells or device geometry appear later, the colony expands beyond the grid, or all cells disappear. Opening another dataset initializes a new reference grid; camera Fit never changes its geometry. `browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. + +### Device geometry visibility + +Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed. + +`browser/device-visibility.mjs` verifies all four constraint types, outlines, keyboard toggling, cell picking, sibling visibility, frame/reset retention, missing geometry, camera and Fit invariance, and new-dataset defaults against Vite on port 4323. It uses the same Playwright module and evidence-directory options as the reference-grid browser test. diff --git a/viewer/browser/device-visibility.mjs b/viewer/browser/device-visibility.mjs new file mode 100644 index 0000000..83a4bfc --- /dev/null +++ b/viewer/browser/device-visibility.mjs @@ -0,0 +1,273 @@ +// Run against the Vite development server. No production debug hooks are added. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4323"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-device-visibility"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); + +// Observe the real application instance rather than replacing its renderer. +await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +function signalGrid(shape = [5, 7, 9], signalCount = 3) { + return { + signal_count: signalCount, + shape, + origin: [-2, -3, 0], + spacing: [1, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: Array(shape.reduce((a, b) => a * b, signalCount)).fill(1), + }; +} +const cell = { + id: "1", + parent_id: null, + slot: 0, + position: [0, 0, 0.6], + direction: [1, 0, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [1, 2, 3], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 3, + cells: [cell], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: signalGrid(), +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} +const constraints = { + boxes: [ + { + id: "1", + center: [4, 0, 1], + half_extents: [1, 2, 1], + allowed_region: "outside", + coefficient: 1, + }, + ], + spheres: [ + { + id: "2", + center: [-4, 0, 1], + radius: 1.5, + allowed_region: "outside", + coefficient: 1, + }, + ], + cylinders: [ + { + id: "3", + center: [0, 4, 1], + radius: 1, + half_height: 2, + allowed_region: "outside", + coefficient: 1, + }, + ], + planes: [ + { id: "4", point: [0, 0, -0.5], inward_normal: [0, 0, 1], coefficient: 1 }, + ], +}; +const all = { ...base, constraints }; +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + return { + camera: [ + ...v.camera.position.toArray(), + ...v.camera.quaternion.toArray(), + ], + bounds: [v.sceneBounds.min.toArray(), v.sceneBounds.max.toArray()], + grid: v.grid.matrixWorld.elements.slice(), + visible: v.device.visible, + deviceChildren: v.device.children.length, + cells: v.colony.visible && v.cellMeshes.length > 0, + highlight: v.highlight.visible, + signal: v.signal.visible && v.signal.children.length > 0, + }; + }); +} +function assertSnapshot(actual, expected, message) { + actual.camera.forEach((value, index) => + assert.ok(Math.abs(value - expected.camera[index]) < 1e-9, message), + ); + assert.deepEqual( + { ...actual, camera: [] }, + { ...expected, camera: [] }, + message, + ); +} +async function pickCell() { + const point = await page.evaluate(() => { + const v = globalThis.__testViewer; + const rect = v.renderer.domElement.getBoundingClientRect(); + const p = v.camera.position.clone().set(0, 0, 0.6).project(v.camera); + return [ + rect.left + ((p.x + 1) * rect.width) / 2, + rect.top + ((1 - p.y) * rect.height) / 2, + ]; + }); + await page.mouse.click(...point); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); +} +try { + await page.goto(`${url}/?token=device-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(all); + const toggle = page.getByLabel("Show device geometry", { exact: true }); + await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); + await pickCell(); + const initial = await snapshot(); + assert.equal( + initial.deviceChildren, + 6, + "all four meshes plus box/cylinder outlines", + ); + assert.ok(initial.cells && initial.signal && initial.highlight); + await page.screenshot({ path: `${evidence}/visible.png` }); + await toggle.focus(); + await page.keyboard.press("Space"); + await expect(toggle).not.toBeChecked(); + const hidden = await snapshot(); + assertSnapshot(hidden, { ...initial, visible: false }); + await page.screenshot({ path: `${evidence}/hidden.png` }); + await page.locator("#clear-selection").click(); + await pickCell(); + for (const time of [1, 20, 3, 0]) { + await send({ ...all, time }); + await expect(toggle).not.toBeChecked(); + const current = await snapshot(); + assertSnapshot( + current, + hidden, + "updates/reset/reverse-time retain display state", + ); + } + await send({ ...base, time: 21 }); + await expect(toggle).toBeDisabled(); + await expect(toggle).not.toBeChecked(); + await send({ ...all, time: 22 }); + await expect(toggle).toBeEnabled(); + await expect(toggle).not.toBeChecked(); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + const fittedHidden = await snapshot(); + await toggle.focus(); + await page.keyboard.press("Space"); + const shown = await snapshot(); + assertSnapshot(shown, { ...fittedHidden, visible: true }); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assertSnapshot( + await snapshot(), + shown, + "Fit bounds and pose are visibility independent", + ); + // A separate standalone file opening starts a new dataset and restores enabled. + await page.goto(url); + const file = { + name: "device.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(all))), + }; + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await pickCell(); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + result: "passed", + browser: browser.version(), + assertions: + "device meshes/outlines, sibling visibility, keyboard, picking, camera, Fit, live updates/reset, missing geometry, new dataset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index 0b1a8a3..d12b8f4 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -112,6 +112,10 @@

Signal grid

Scene

+
Backend
diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 26609d7..f27ea0c 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -208,10 +208,16 @@ export class ColonyViewer { /** Call once when opening a file, live session, or recording. */ public beginDataset(): void { this.referenceGrid.beginDataset(); + this.setDeviceVisible(true); this.cancelCameraTransition(); this.selectCell(null); } + /** Presentation only: the group retains visibility when its children rebuild. */ + public setDeviceVisible(visible: boolean): void { + this.device.visible = visible; + } + /** Frame updates, including reset/seek, retain camera and dataset state. */ public setFrame(frame: SceneFrame, fit = false): void { this.configureReferenceGrid(frame); diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 28aa1d8..8267cfb 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -45,6 +45,7 @@ const legendMaximum = required("legend-max"); const legendTitle = required("legend-title"); const signalSection = required("signal-section"); const signalVisible = required("signal-visible"); +const deviceVisible = required("device-visible"); const signalChannel = required("signal-channel"); const signalAxis = required("signal-axis"); const signalRange = required("signal-slice"); @@ -232,6 +233,11 @@ function presentScene( const display = presentation.forFrame(next); frame = next; viewer.setFrame(next, newDataset); + deviceVisible.checked = display.deviceVisible; + deviceVisible.disabled = !Object.values(next.constraints).some( + (constraints) => constraints.length > 0, + ); + viewer.setDeviceVisible(display.deviceVisible); fitButton.disabled = false; colorMode.disabled = false; emptyState.hidden = true; @@ -312,6 +318,10 @@ speciesChannel.addEventListener("change", () => { presentation.preferences.speciesChannel = selectedInteger(speciesChannel); updateColors(); }); +deviceVisible.addEventListener("change", () => { + presentation.preferences.deviceVisible = deviceVisible.checked; + viewer.setDeviceVisible(deviceVisible.checked); +}); signalVisible.addEventListener("change", () => { presentation.preferences.signalVisible = signalVisible.checked; updateSignal(); diff --git a/viewer/src/presentation-state.ts b/viewer/src/presentation-state.ts index 4dd9e61..7f6c2ed 100644 --- a/viewer/src/presentation-state.ts +++ b/viewer/src/presentation-state.ts @@ -6,6 +6,7 @@ export interface PresentationPreferences { colorMode: ColorMode; speciesChannel: number; signalVisible: boolean; + deviceVisible: boolean; signalChannel: number; signalAxis: SliceAxis; signalSlice: number | null; @@ -16,6 +17,7 @@ function defaults(): PresentationPreferences { colorMode: "cell-type", speciesChannel: 0, signalVisible: true, + deviceVisible: true, signalChannel: 0, signalAxis: "z", signalSlice: null, diff --git a/viewer/src/style.css b/viewer/src/style.css index 0466d88..b4c6367 100644 --- a/viewer/src/style.css +++ b/viewer/src/style.css @@ -714,3 +714,20 @@ dd { transition-duration: 0ms; } } + +.device-visibility { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0.75rem 0; + color: var(--muted); + font-size: 0.75rem; +} + +.device-visibility input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--accent); +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts index aca53de..8c0b926 100644 --- a/viewer/tests/presentation-state.test.ts +++ b/viewer/tests/presentation-state.test.ts @@ -34,6 +34,17 @@ const frame: SceneFrame = { }; describe("dataset presentation lifecycle", () => { + it("retains device preference through absent geometry and resets for a new dataset", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.preferences.deviceVisible = false; + for (const time of [0, 2, 1, 0]) { + expect(state.forFrame({ ...frame, time }).deviceVisible).toBe(false); + } + state.beginDataset(); + expect(state.forFrame(frame).deviceVisible).toBe(true); + }); + it("restores the desired slice after an axis round trip", () => { const state = new DatasetPresentationState(); state.beginDataset(); @@ -71,6 +82,7 @@ describe("dataset presentation lifecycle", () => { colorMode: "cell-type", speciesChannel: 0, signalVisible: true, + deviceVisible: true, signalChannel: 0, signalAxis: "z", signalSlice: 4, From ed5bda02491c70518b4dac080b290f9cfc5ef020 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:37:23 -0600 Subject: [PATCH 08/38] Add cooperative live-session shutdown and port reuse tests --- .github/workflows/live-shutdown-windows.yml | 70 ++++ docs/protocols/live-viewer-v1.md | 18 ++ python/src/microsimulator/viewer_server.py | 151 +++++++-- python/tests/test_viewer_shutdown.py | 336 ++++++++++++++++++++ viewer/README.md | 23 +- viewer/browser/live-shutdown.mjs | 174 ++++++++++ viewer/index.html | 8 + viewer/src/live.ts | 64 +++- viewer/src/main.ts | 19 +- viewer/src/style.css | 7 + viewer/tests/live.test.ts | 105 +++++- 11 files changed, 933 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/live-shutdown-windows.yml create mode 100644 python/tests/test_viewer_shutdown.py create mode 100644 viewer/browser/live-shutdown.mjs diff --git a/.github/workflows/live-shutdown-windows.yml b/.github/workflows/live-shutdown-windows.yml new file mode 100644 index 0000000..d81fff7 --- /dev/null +++ b/.github/workflows/live-shutdown-windows.yml @@ -0,0 +1,70 @@ +name: Windows live-session shutdown + +on: + pull_request: + paths: + - .github/workflows/live-shutdown-windows.yml + - CMakeLists.txt + - cpp/** + - python/** + - pyproject.toml + - uv.lock + push: + branches: [master, marpaia/17] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: windows-live-shutdown-${{ github.ref }} + cancel-in-progress: true + +jobs: + shutdown: + runs-on: windows-2025 + timeout-minutes: 20 + defaults: + run: + shell: pwsh + env: + CMAKE_BUILD_PARALLEL_LEVEL: "2" + # Exercise the host reference engine without requiring a GPU toolkit. + CMAKE_ARGS: -DCM_ENABLE_METAL=OFF -DCM_ENABLE_CUDA=OFF -DCM_BUILD_TESTS=OFF + steps: + - name: Check out source + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + python-version: "3.12" + + - name: Build CPU extension and install locked test dependencies + run: uv sync --locked --group dev + + - name: Record platform, shell, interpreter, and backend + run: | + New-Item -ItemType Directory -Force build/shutdown-evidence | Out-Null + $PSVersionTable | Out-File build/shutdown-evidence/platform.txt + [System.Environment]::OSVersion | Out-File -Append build/shutdown-evidence/platform.txt + uv run --no-sync python --version 2>&1 | Tee-Object -Append build/shutdown-evidence/platform.txt + uv run --no-sync microsimulator devices --json | Tee-Object -Append build/shutdown-evidence/platform.txt + + - name: Verify authenticated Stop, worker draining, and same-port process restart + run: >- + uv run --no-sync python -m pytest + python/tests/test_viewer_server.py python/tests/test_viewer_shutdown.py + -v --tb=short --junitxml=build/shutdown-evidence/tests.xml + + - name: Upload shutdown evidence + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: windows-live-shutdown-${{ github.sha }} + path: build/shutdown-evidence + if-no-files-found: warn + retention-days: 30 diff --git a/docs/protocols/live-viewer-v1.md b/docs/protocols/live-viewer-v1.md index fbbf780..e2559bf 100644 --- a/docs/protocols/live-viewer-v1.md +++ b/docs/protocols/live-viewer-v1.md @@ -37,6 +37,15 @@ Rejected commands and model failures return a data-only error: { "type": "error", "message": "reason" } ``` +Intentional shutdown sends lifecycle notifications before closing the WebSocket with code 1000: + +```json +{"type":"session","state":"stopping"} +{"type":"session","state":"stopped"} +``` + +`stopping` means admission of new work has ended. `stopped` means the active operation has finished and the simulation worker has terminated. Clients should process previously received messages (including asynchronous scene verification) before interpreting the subsequent socket close. A close without `stopped` is still an unexpected disconnect. These messages extend the v1 vocabulary; use a viewer built from the same release as the server. + ## Client commands The vocabulary is closed. Unknown fields are rejected. @@ -48,8 +57,17 @@ The vocabulary is closed. Unknown fields are rejected. {"type":"pause"} {"type":"reset"} {"type":"checkpoint"} +{"type":"stop"} ``` `steps` defaults to one and is bounded to 1 through 10,000. Playback advances the configured number of steps per published frame. A step or reset first pauses playback. Disconnecting the final client pauses the simulation. Reset calls the original server-side model factory again with its original backend, device, seed, parameters, and resume source. Checkpoint writes only to the destination configured when the server starts and atomically replaces that file. It preserves controller state for any runnable model implementing the `SimulationController` protocol, including the legacy compatibility adapter. + +## Stop and restart + +Stop ends this server process and releases its listening port. It is authenticated through the same token and exact-origin WebSocket upgrade as every other command. The reader handles Stop immediately, including while an earlier command on that same socket is executing. Ordinary commands retain per-client ordering in a bounded queue of 32; additional queued commands receive an error rather than blocking Stop. + +Shutdown is cooperative: an in-progress individual simulation step finishes, then the rest of its batch is skipped. An already-running reset, scene capture, or atomic checkpoint write also finishes. There is no timeout that kills the worker in the middle of model state mutation or file replacement. A model operation that never returns will therefore keep the session in `stopping`. Queued operations and newly received Frame, Play, Step, Pause, Reset, and Checkpoint commands are rejected once stopping begins; repeated Stop requests are idempotent. Closing the final browser connection still pauses the session and allows reconnection. + +Browser Stop and terminal Ctrl+C share the same worker/socket cleanup path. After `stopped`, the server closes client sockets and its application runner, exits, and releases the port. Launch the next `microsimulator view` command from the terminal and open its newly printed URL; each process has a new token. The old browser keeps its last rendered frame and displays Stopped. diff --git a/python/src/microsimulator/viewer_server.py b/python/src/microsimulator/viewer_server.py index b0d6bf2..01decb8 100644 --- a/python/src/microsimulator/viewer_server.py +++ b/python/src/microsimulator/viewer_server.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from functools import partial from pathlib import Path +from threading import Event from typing import Any, Literal, cast from urllib.parse import urlencode @@ -24,9 +25,10 @@ MAX_COMMAND_BYTES = 4096 MAX_STEP_BATCH = 10_000 +MAX_QUEUED_COMMANDS = 32 type ModelFactory = Callable[[], tuple[RunnableModel, Mapping[str, JSONValue]]] -type CommandName = Literal["frame", "step", "play", "pause", "reset", "checkpoint"] +type CommandName = Literal["frame", "step", "play", "pause", "reset", "checkpoint", "stop"] class LiveViewerError(RuntimeError): @@ -64,7 +66,7 @@ def parse_command(encoded: str) -> LiveCommand: ): raise LiveViewerError(f"step count must be an integer in [1, {MAX_STEP_BATCH}]") return LiveCommand("step", steps) - names: set[str] = {"frame", "play", "pause", "reset", "checkpoint"} + names: set[str] = {"frame", "play", "pause", "reset", "checkpoint", "stop"} if not isinstance(name, str) or name not in names: raise LiveViewerError("unknown command type") if set(command) != {"type"}: @@ -92,6 +94,11 @@ def __init__( self._model, self._provenance = self._build() self._completed_steps = 0 self._revision = 0 + self._stop_requested = Event() + + def request_stop(self) -> None: + """Signal the worker without waiting for its current operation.""" + self._stop_requested.set() @property def completed_steps(self) -> int: @@ -112,6 +119,8 @@ def step(self, steps: int = 1) -> None: completed = 0 try: for _ in range(steps): + if self._stop_requested.is_set(): + break self._model.step(self._dt) completed += 1 self._completed_steps += 1 @@ -174,11 +183,30 @@ def __init__(self, session: LiveSession, *, frame_steps: int = 1, fps: float = 3 self._play_wakeup = asyncio.Event() self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="microsimulator-live") self._operation_lock = asyncio.Lock() + self._close_task: asyncio.Task[None] | None = None + self.stopped = asyncio.Event() + + @property + def stopping(self) -> bool: + return self._close_task is not None + + def _require_active(self) -> None: + if self.stopping: + raise LiveViewerError("live session is stopping or stopped") async def _run(self, operation: Callable[..., Any], *arguments: object) -> Any: async with self._operation_lock: + self._require_active() loop = asyncio.get_running_loop() - return await loop.run_in_executor(self._worker, operation, *arguments) + work = loop.run_in_executor(self._worker, operation, *arguments) + try: + return await asyncio.shield(work) + except asyncio.CancelledError: + # Canceling an asyncio waiter does not cancel native/thread work. + # Keep the operation lock until the worker has really finished. + with suppress(Exception): + await asyncio.shield(work) + raise async def _message(self) -> dict[str, JSONValue]: return cast( @@ -199,7 +227,11 @@ async def broadcast_frame(self) -> None: stale.append(socket) continue try: - await socket.send_str(encoded) + await asyncio.wait_for(socket.send_str(encoded), timeout=1.0) + except TimeoutError: + # Retain this socket for shutdown even if it cannot drain a + # frame; Stop must not wait indefinitely on frame delivery. + continue except (ConnectionError, RuntimeError): stale.append(socket) self._sockets.difference_update(stale) @@ -207,20 +239,26 @@ async def broadcast_frame(self) -> None: async def _play(self) -> None: loop = asyncio.get_running_loop() try: - while self.playing and self._sockets: + while self.playing and self._sockets and not self.stopping: started = loop.time() await self._run(self.session.step, self.frame_steps) + if self.stopping: + break await self.broadcast_frame() delay = self.frame_interval - (loop.time() - started) if delay > 0.0: with suppress(TimeoutError): await asyncio.wait_for(self._play_wakeup.wait(), timeout=delay) self._play_wakeup.clear() + except Exception as error: + if not self.stopping: + await self._broadcast({"type": "error", "message": str(error)}) finally: self.playing = False self._play_task = None async def play(self) -> None: + self._require_active() if self.playing: return self.playing = True @@ -233,11 +271,15 @@ async def pause(self, *, broadcast: bool = True) -> None: self._play_wakeup.set() task = self._play_task if task is not None and task is not asyncio.current_task(): - await task - if broadcast: + await asyncio.shield(task) + if broadcast and not self.stopping: await self.broadcast_frame() async def command(self, command: LiveCommand) -> str | None: + if command.name == "stop": + self.request_stop() + return None + self._require_active() if command.name == "frame": await self.broadcast_frame() elif command.name == "step": @@ -258,21 +300,51 @@ async def command(self, command: LiveCommand) -> str | None: return None async def connect(self, socket: web.WebSocketResponse) -> None: + self._require_active() self._sockets.add(socket) await self._send_frame(socket) async def disconnect(self, socket: web.WebSocketResponse) -> None: self._sockets.discard(socket) - if not self._sockets: + if not self._sockets and not self.stopping: await self.pause(broadcast=False) - async def close(self) -> None: + async def _broadcast(self, message: dict[str, JSONValue]) -> None: + for socket in tuple(self._sockets): + if not socket.closed: + with suppress(ConnectionError, RuntimeError, TimeoutError): + # A stalled browser must not hold the shutdown admission + # path open indefinitely while its send buffer is full. + await asyncio.wait_for(socket.send_json(message), timeout=1.0) + + def request_stop(self) -> None: + """Begin idempotent shutdown outside any socket/command task.""" + if self.stopping: + return + self.session.request_stop() + self.playing = False + self._play_wakeup.set() + self._close_task = asyncio.create_task(self._close(), name="microsimulator-live-stop") + + async def _close(self) -> None: + await self._broadcast({"type": "session", "state": "stopping"}) await self.pause(broadcast=False) + # Wait for a manual batch, reset, frame capture, or atomic checkpoint. + # New/queued operations fail the admission check inside this lock. + async with self._operation_lock: + await asyncio.to_thread(self._worker.shutdown, wait=True, cancel_futures=True) + await self._broadcast({"type": "session", "state": "stopped"}) sockets = tuple(self._sockets) self._sockets.clear() for socket in sockets: - await socket.close(code=1001, message=b"server shutdown") - self._worker.shutdown(wait=True, cancel_futures=True) + with suppress(ConnectionError, RuntimeError): + await socket.close(code=1000, message=b"session stopped") + self.stopped.set() + + async def close(self) -> None: + self.request_stop() + assert self._close_task is not None + await asyncio.shield(self._close_task) _CONTROLLER_KEY = web.AppKey("microsimulator.controller", LiveController) @@ -290,11 +362,34 @@ def _authorized(request: web.Request) -> bool: async def _websocket(request: web.Request) -> web.StreamResponse: if not _authorized(request): raise web.HTTPForbidden(text="invalid live-viewer authority") + controller = request.app[_CONTROLLER_KEY] + if controller.stopping: + raise web.HTTPServiceUnavailable(text="live session is stopping or stopped") socket = web.WebSocketResponse(max_msg_size=MAX_COMMAND_BYTES, heartbeat=20.0) await socket.prepare(request) - controller = request.app[_CONTROLLER_KEY] - await controller.connect(socket) + commands: asyncio.Queue[LiveCommand] = asyncio.Queue(maxsize=MAX_QUEUED_COMMANDS) + + async def send_error(error: Exception) -> None: + if not socket.closed: + with suppress(ConnectionError, RuntimeError): + await socket.send_json({"type": "error", "message": str(error)}) + + async def execute_commands() -> None: + while True: + command = await commands.get() + try: + result = await controller.command(command) + if result is not None and not socket.closed: + await socket.send_json({"type": "checkpoint", "path": result}) + except Exception as error: + await send_error(error) + + # Read continuously so Stop on this socket can interrupt its own long batch. + # A bounded per-client queue retains ordinary command order without creating + # an unbounded number of tasks or blocking Stop behind queue backpressure. + consumer = asyncio.create_task(execute_commands(), name="microsimulator-live-commands") try: + await controller.connect(socket) async for message in socket: if message.type is not WSMsgType.TEXT: if message.type is WSMsgType.ERROR: @@ -302,18 +397,21 @@ async def _websocket(request: web.Request) -> web.StreamResponse: await socket.send_json({"type": "error", "message": "text commands required"}) continue try: - result = await controller.command(parse_command(cast(str, message.data))) - if result is not None: - await socket.send_json( - {"type": "checkpoint", "path": result}, - dumps=lambda value: json.dumps(value, separators=(",", ":")), - ) + command = parse_command(cast(str, message.data)) + if command.name == "stop": + controller.request_stop() + else: + if controller.stopping: + raise LiveViewerError("live session is stopping or stopped") + if commands.full(): + raise LiveViewerError("too many queued commands") + commands.put_nowait(command) except Exception as error: - await socket.send_json( - {"type": "error", "message": str(error)}, - dumps=lambda value: json.dumps(value, separators=(",", ":")), - ) + await send_error(error) finally: + consumer.cancel() + with suppress(asyncio.CancelledError): + await consumer await controller.disconnect(socket) return socket @@ -355,7 +453,8 @@ async def cleanup(_: web.Application) -> None: application.router.add_get("/", index_response) application.router.add_get("/api/v1/session", _websocket) application.router.add_static("/assets", assets, show_index=False) - application.on_cleanup.append(cleanup) + # Stop before aiohttp waits for active WebSocket request handlers. + application.on_shutdown.append(cleanup) return application, authority @@ -369,7 +468,7 @@ def serve_live( fps: float = 30.0, open_browser: bool = False, ) -> None: - """Serve one live session on loopback until interrupted.""" + """Serve one live session on loopback until Stop or terminal interruption.""" if host not in {"127.0.0.1", "::1", "localhost"}: raise LiveViewerError("live viewer host must be a loopback address") @@ -398,7 +497,7 @@ async def run() -> None: print(f"MicroSimulator live viewer: {url}", flush=True) if open_browser: webbrowser.open(url) - await asyncio.Event().wait() + await application[_CONTROLLER_KEY].stopped.wait() finally: await runner.cleanup() diff --git a/python/tests/test_viewer_shutdown.py b/python/tests/test_viewer_shutdown.py new file mode 100644 index 0000000..dd0c13c --- /dev/null +++ b/python/tests/test_viewer_shutdown.py @@ -0,0 +1,336 @@ +"""Shutdown regressions use real sockets and a controllably blocked worker.""" + +from __future__ import annotations + +import asyncio +import signal +import socket +import sys +from pathlib import Path +from threading import Event +from threading import enumerate as threads +from typing import Any, cast +from urllib.parse import urlsplit + +import pytest +from aiohttp import ClientSession, WSMsgType, web +from aiohttp.test_utils import TestClient, TestServer +from microsimulator import BackendKind, CellInit, Simulation, load_checkpoint +from microsimulator import checkpoint as checkpoint_module +from microsimulator.checkpoint import JSONValue +from microsimulator.viewer_server import ( + LiveCommand, + LiveController, + LiveSession, + LiveViewerError, + create_live_app, + parse_command, +) + + +def _factory() -> tuple[Simulation, dict[str, JSONValue]]: + simulation = Simulation(BackendKind.CPU) + simulation.add_cell(CellInit()) + return simulation, {} + + +def _dist(path: Path) -> Path: + dist = path / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("shutdown test") + return dist + + +class _BlockedModel: + def __init__(self) -> None: + self.simulation, _ = _factory() + self.entered = Event() + self.release = Event() + + def controller_state(self) -> JSONValue: + return None + + def step(self, dt: float) -> None: + self.entered.set() + assert self.release.wait(10), "test did not release blocked step" + self.simulation.step(dt) + + +async def _entered(event: Event) -> None: + assert await asyncio.to_thread(event.wait, 5), "worker never reached blocking point" + + +@pytest.mark.parametrize("playing", [False, True]) +def test_stop_preempts_same_socket_batch_and_rejects_new_commands( + tmp_path: Path, + playing: bool, +) -> None: + async def exercise() -> None: + model = _BlockedModel() + session = LiveSession(lambda: (model, {}), dt=0.1) + app, token = create_live_app(session, _dist(tmp_path), frame_steps=10_000) + client = TestClient(TestServer(app)) + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"} if playing else {"type": "step", "steps": 10_000}) + await _entered(model.entered) + await ws.send_json({"type": "stop"}) + await ws.send_json({"type": "stop"}) + # Read until admission is closed while the current step is blocked. + while (await ws.receive_json(timeout=5)).get("type") != "session": + pass + for name in ("play", "step", "reset", "checkpoint"): + await ws.send_json({"type": name}) + rejected = await ws.receive_json(timeout=5) + assert rejected["type"] == "error" + assert "stopping" in rejected["message"] + assert session.completed_steps == 0 + model.release.set() + while True: + message = await ws.receive_json(timeout=5) + if message == {"type": "session", "state": "stopped"}: + break + assert session.completed_steps == 1 + assert (await ws.receive(timeout=5)).type == WSMsgType.CLOSE + assert ws.close_code == 1000 + finally: + model.release.set() + await client.close() + assert not any(t.name.startswith("microsimulator-live") for t in threads()) + + asyncio.run(exercise()) + + +def test_stop_waits_for_atomic_checkpoint_replace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + output = tmp_path / "run.json" + session = LiveSession(_factory, dt=0.1, checkpoint_output=output) + session.checkpoint() + original = output.read_bytes() + session.step() + entered, release = Event(), Event() + replace = checkpoint_module.os.replace + + def blocked_replace(source: str | Path, destination: str | Path) -> None: + entered.set() + assert release.wait(10), "test did not release checkpoint replace" + replace(source, destination) + + monkeypatch.setattr(checkpoint_module.os, "replace", blocked_replace) + controller = LiveController(session) + save = asyncio.create_task(controller.command(LiveCommand("checkpoint"))) + try: + await _entered(entered) + await controller.command(LiveCommand("stop")) + assert not controller.stopped.is_set() + assert output.read_bytes() == original + for name in ("play", "step", "reset", "checkpoint"): + with pytest.raises(LiveViewerError, match="stopping"): + await controller.command(parse_command('{"type":"' + name + '"}')) + release.set() + assert await save == str(output.resolve()) + await asyncio.wait_for(controller.close(), 5) + assert abs(load_checkpoint(output).time - 0.1) < 1e-7 + assert list(tmp_path.iterdir()) == [output] + finally: + release.set() + await controller.close() + + asyncio.run(exercise()) + + +def test_cancelled_waiter_does_not_release_worker_early() -> None: + async def exercise() -> None: + model = _BlockedModel() + session = LiveSession(lambda: (model, {}), dt=0.1) + controller = LiveController(session) + operation = asyncio.create_task(controller.command(LiveCommand("step", 10_000))) + try: + await _entered(model.entered) + operation.cancel() + controller.request_stop() + await asyncio.sleep(0) + assert not operation.done() + assert not controller.stopped.is_set() + model.release.set() + with pytest.raises(asyncio.CancelledError): + await operation + await asyncio.wait_for(controller.close(), 5) + assert session.completed_steps == 1 + finally: + model.release.set() + await controller.close() + + asyncio.run(exercise()) + + +def test_disconnect_pauses_without_stopping_and_close_is_idempotent(tmp_path: Path) -> None: + async def exercise() -> None: + session = LiveSession(_factory, dt=0.1) + app, token = create_live_app(session, _dist(tmp_path)) + client = TestClient(TestServer(app)) + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + try: + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await ws.receive_json(timeout=5) + await ws.close() + ws2 = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + assert (await ws2.receive_json(timeout=5))["playing"] is False + await ws2.send_json({"type": "stop"}) + await asyncio.gather(ws2.close(), client.close()) + await client.close() + finally: + await client.close() + + asyncio.run(exercise()) + + +def test_stop_command_is_closed() -> None: + assert parse_command('{"type":"stop"}') == LiveCommand("stop") + with pytest.raises(LiveViewerError, match="unknown fields"): + parse_command('{"type":"stop","force":true}') + + +def test_stop_is_not_blocked_by_a_stalled_frame_send( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + entered = asyncio.Event() + send = web.WebSocketResponse.send_str + frame_count = 0 + + async def stalled_send( + ws: web.WebSocketResponse, + data: str, + compress: int | None = None, + ) -> None: + nonlocal frame_count + if data.startswith('{"type":"frame"'): + frame_count += 1 + if frame_count > 1: + entered.set() + await asyncio.Event().wait() + await send(ws, data, compress=compress) + + monkeypatch.setattr(web.WebSocketResponse, "send_str", stalled_send) + app, token = create_live_app(LiveSession(_factory, dt=0.1), _dist(tmp_path)) + client = TestClient(TestServer(app)) + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + ws = await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await asyncio.wait_for(entered.wait(), 5) + await ws.send_json({"type": "stop"}) + assert await ws.receive_json(timeout=5) == {"type": "session", "state": "stopping"} + assert await ws.receive_json(timeout=5) == {"type": "session", "state": "stopped"} + await ws.close() + finally: + await client.close() + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("termination", ["stop", "interrupt"]) +def test_cli_process_exits_and_reuses_port_for_another_model( + tmp_path: Path, + termination: str, +) -> None: + if termination == "interrupt" and sys.platform == "win32": + pytest.skip("Windows Ctrl+C requires an attached console; use documented manual check") + + async def exercise() -> None: + dist = _dist(tmp_path) + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + for index in range(3): + model = tmp_path / f"model {index}.py" + model.write_text( + "from microsimulator import CellInit\n" + "def build(context):\n" + " simulation = context.simulation()\n" + " cell = CellInit()\n" + f" cell.length = {2 + index}\n" + " simulation.add_cell(cell)\n" + " return simulation\n" + ) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "microsimulator", + "view", + "--model", + str(model), + "--backend", + "cpu", + "--dt", + "0.1", + "--viewer-dist", + str(dist), + "--port", + str(port), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + assert process.stdout is not None + line = (await asyncio.wait_for(process.stdout.readline(), 10)).decode().strip() + if not line.startswith("MicroSimulator live viewer: "): + _, error = await asyncio.wait_for(process.communicate(), 5) + pytest.fail(f"viewer did not start: {line} {error.decode()}") + url = urlsplit(line.split(": ", 1)[1]) + async with ( + ClientSession() as client, + client.ws_connect( + f"http://{url.netloc}/api/v1/session?{url.query}", + headers={"Origin": f"http://{url.netloc}"}, + ) as ws, + ): + frame = cast(dict[str, Any], await ws.receive_json(timeout=5)) + assert frame["scene"]["frame"]["cells"][0]["length"] == 2 + index + if termination == "interrupt": + process.send_signal(signal.SIGINT) + else: + await ws.send_json({"type": "stop"}) + assert await ws.receive_json(timeout=5) == { + "type": "session", + "state": "stopping", + } + assert await ws.receive_json(timeout=5) == { + "type": "session", + "state": "stopped", + } + assert (await ws.receive(timeout=5)).type == WSMsgType.CLOSE + _, error = await asyncio.wait_for(process.communicate(), 10) + assert process.returncode == 0, error.decode() + assert not error, error.decode() + finally: + if process.returncode is None: + process.kill() + await process.wait() + + asyncio.run(exercise()) diff --git a/viewer/README.md b/viewer/README.md index 1eea13e..75074fd 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -33,7 +33,28 @@ uv run microsimulator view \ --open ``` -Without `--open`, open the tokenized loopback URL printed by `microsimulator`. The live transport can play, pause, advance one step, rebuild the original model, and write to the configured checkpoint destination. Camera position, display mapping, grid slice, and selected-cell identity survive frame updates. +Without `--open`, open the tokenized loopback URL printed by `microsimulator`. The live transport can play, pause, advance one step, rebuild the original model, write to the configured checkpoint destination, and stop the session. Camera position, display mapping, grid slice, and selected-cell identity survive frame updates. + +### Stop one model and start another + +Click **Stop session** or press **Ctrl+C** once in the terminal running the server. The current individual step or checkpoint write finishes, the browser displays **Stopped**, and the command returns to the prompt. A large playback batch does not have to finish. Start another `microsimulator view` command using the same port and open the new printed URL. Reset rebuilds the current model; Pause keeps its process available; closing the browser pauses it and allows reconnection. Stop does not automatically save a checkpoint: use Checkpoint first if you need restartable state. + +For example, after stopping the trap model above, launch a different model on the same default port: + +```console +uv run microsimulator view --model examples/tutorials/biophysics.py --backend cpu --seed 42 --dt 0.02 --port 8765 --open +``` + +The command is a single line and also works in PowerShell where the Python/native build is available. To distinguish Windows console behavior from browser behavior, use this manual verification procedure in an attached PowerShell or Command Prompt console: + +1. Record the Windows version, terminal application/version, Python version, and exact launch command. Start the command above and click Stop while paused. Confirm the prompt returns, then start the second model on port 8765. +2. Repeat with Play active and `--frame-steps 10000`. Confirm Stopping transitions to Stopped without finishing the entire batch. +3. Repeat using Ctrl+C once, both paused and playing. Confirm the prompt returns without `taskkill`, then immediately start another model on the same port. +4. Close only the browser tab during Play, then reopen the printed URL. Confirm the session remains available and paused. + +The automated `python/tests/test_viewer_shutdown.py` suite covers same-socket Stop, checkpoint completion, worker cleanup, and repeated real subprocess restarts. Its Stop/port-reuse test is portable to Windows; the SIGINT subprocess case runs on POSIX. Windows console Ctrl+C must be checked with the attached-console procedure above; passing the POSIX case does not establish Windows behavior. + +The `Windows live-session shutdown` GitHub Actions job builds the CPU extension on `windows-2025` and runs the portable server and shutdown tests with dependencies from `uv.lock`. Its uploaded report records Windows, PowerShell, Python, backend availability, and individual test results. The detached CI process cannot substitute for the attached-console Ctrl+C check. ## Capabilities diff --git a/viewer/browser/live-shutdown.mjs b/viewer/browser/live-shutdown.mjs new file mode 100644 index 0000000..13a6ed6 --- /dev/null +++ b/viewer/browser/live-shutdown.mjs @@ -0,0 +1,174 @@ +// Real Python server + browser, including process exit and same-port restart. +// Run from the repository root after building this worktree's Python/viewer. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createInterface } from "node:readline"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-live-shutdown"; +const python = + process.env.MICROSIMULATOR_PYTHON ?? path.resolve(".venv/bin/python"); +const port = process.env.VIEWER_PORT ?? "4327"; +const temporary = await mkdtemp(path.join(tmpdir(), "microsimulator-stop-")); +await mkdir(evidence, { recursive: true }); +await writeFile( + path.join(temporary, "model.py"), + ` +import time +from microsimulator import CellInit +class Model: + def __init__(self, context): + self.simulation = context.simulation() + self.simulation.add_cell(CellInit()) + def step(self, dt): + time.sleep(0.02) + self.simulation.step(dt) + def controller_state(self): + return {"kind": "shutdown-browser-fixture"} +def build(context): + return Model(context) +`, +); + +const browser = await chromium.launch({ headless: true }); +let processUnderTest; +const errors = []; +const results = []; +try { + for (const mode of ["paused", "playing", "reconnect", "interrupt"]) { + if (mode === "interrupt" && process.platform === "win32") continue; + const child = spawn( + python, + [ + "-m", + "microsimulator", + "view", + "--model", + path.join(temporary, "model.py"), + "--backend", + "cpu", + "--dt", + "0.01", + "--frame-steps", + "10000", + "--viewer-dist", + path.resolve("viewer/dist"), + "--port", + port, + "--checkpoint-output", + path.join(temporary, "checkpoint.json"), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + processUnderTest = child; + let stderr = ""; + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + const exit = new Promise((resolve) => + child.once("exit", (code, signal) => resolve({ code, signal })), + ); + const lines = createInterface({ input: child.stdout }); + const url = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("server startup timed out")), + 10000, + ); + lines.on("line", (line) => { + if (line.startsWith("MicroSimulator live viewer: ")) { + clearTimeout(timer); + resolve(line.slice("MicroSimulator live viewer: ".length)); + } + }); + child.once("exit", () => { + clearTimeout(timer); + reject(new Error(stderr)); + }); + child.once("error", reject); + }); + let page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await expect(page.locator("#live-label")).toHaveText("Paused"); + await expect(page.locator("#live-stop")).toBeEnabled(); + if (mode === "reconnect") { + await page.close(); + // The existing viewer deliberately requires a minimum width of 880px. + page = await browser.newPage({ viewport: { width: 880, height: 844 } }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(url); + await expect(page.locator("#live-label")).toHaveText("Paused"); + assert.equal(child.exitCode, null); + await page.locator("#live-checkpoint").click(); + await expect(page.locator("#status")).toContainText("Checkpoint saved"); + assert.ok( + (await readFile(path.join(temporary, "checkpoint.json"))).length > 100, + ); + } + if (mode === "playing") { + await page.locator("#live-play").click(); + await expect(page.locator("#live-label")).toHaveText("Running"); + } + const start = performance.now(); + if (mode === "interrupt") { + child.kill("SIGINT"); + } else { + await page.locator("#live-stop").focus(); + await page.keyboard.press("Enter"); + } + await expect(page.locator("#live-label")).toHaveText("Stopped", { + timeout: 5000, + }); + const toolbar = await page.locator("#live-transport").boundingBox(); + const viewport = await page.locator("#canvas-host").boundingBox(); + assert.ok(toolbar.x >= viewport.x); + assert.ok(toolbar.x + toolbar.width <= viewport.x + viewport.width); + await expect(page.locator("#status")).toContainText("Session stopped"); + for (const id of ["play", "step", "reset", "checkpoint", "stop"]) { + await expect(page.locator(`#live-${id}`)).toBeDisabled(); + } + const stopped = await Promise.race([ + exit, + new Promise((_, reject) => { + const timer = setTimeout( + () => reject(new Error("server did not exit")), + 5000, + ); + timer.unref(); + }), + ]); + assert.deepEqual(stopped, { code: 0, signal: null }); + assert.equal(stderr, ""); + await page.screenshot({ path: `${evidence}/${mode}-stopped.png` }); + results.push({ + mode, + stopMilliseconds: performance.now() - start, + exitCode: stopped.code, + }); + await page.close(); + lines.close(); + processUnderTest = undefined; + } + assert.deepEqual(errors, []); + const result = { + result: "passed", + browser: browser.version(), + platform: process.platform, + port, + results, + }; + await writeFile(`${evidence}/results.json`, JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result, null, 2)); +} finally { + processUnderTest?.kill("SIGKILL"); + await browser.close(); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/viewer/index.html b/viewer/index.html index 0b1a8a3..7a2945d 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -191,6 +191,14 @@

Inspect a colony snapshot

> Checkpoint +
diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 28aa1d8..36d1a0c 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -1,6 +1,12 @@ import "./style.css"; -import { mapCellColors, type ColorMode } from "./color"; +import { mapCellColors, rgbBytes, viridis, type ColorMode } from "./color"; +import { + DatasetScalarRanges, + resolveScalarRange, + type ResolvedScalarRange, +} from "./scalar-range"; +import { ScalarRangeControls } from "./scalar-range-controls"; import { ColonyViewer } from "./colony-viewer"; import { signalSlice, sliceDimension, type SliceAxis } from "./grid"; import { DatasetPresentationState } from "./presentation-state"; @@ -74,6 +80,20 @@ let livePlaying = false; let liveCheckpointEnabled = false; let liveConnection: LiveConnection | null = null; const presentation = new DatasetPresentationState(); +const scalarRanges = new DatasetScalarRanges(); +const speciesRangeRoot = required("species-range"); +const speciesRangeControls = new ScalarRangeControls( + speciesRangeRoot, + scalarRanges, + "species", + updateColors, +); +const signalRangeControls = new ScalarRangeControls( + required("signal-color-range"), + scalarRanges, + "signals", + updateSignal, +); const viewer = new ColonyViewer(canvasHost, viewCubeElement, updateSelection); @@ -131,16 +151,39 @@ function updateColors(): void { } const mode = colorMode.value as ColorMode; speciesField.hidden = mode !== "species"; + speciesRangeRoot.hidden = mode !== "species"; const mapping = mapCellColors(frame, { mode, speciesIndex: selectedInteger(speciesChannel), + range: scalarRanges.get("species", selectedInteger(speciesChannel)), }); viewer.setCellColors(mapping.colors); - const scalar = mapping.minimum !== null && mapping.maximum !== null; - colorLegend.hidden = !scalar; - legendTitle.textContent = mapping.title; - legendMinimum.textContent = scalar ? formatNumber(mapping.minimum ?? 0) : "—"; - legendMaximum.textContent = scalar ? formatNumber(mapping.maximum ?? 0) : "—"; + colorLegend.hidden = mapping.range === null; + if (mapping.range !== null) { + if (mode === "species") + speciesRangeControls.bind(selectedInteger(speciesChannel), mapping.range); + legendTitle.textContent = mapping.title; + legendMinimum.textContent = + mapping.minimum === null ? "—" : formatNumber(mapping.minimum); + legendMaximum.textContent = + mapping.maximum === null ? "—" : formatNumber(mapping.maximum); + updateRangeLegend("legend", mapping.range); + } +} + +function updateRangeLegend(prefix: string, range: ResolvedScalarRange): void { + const mode = range.mode === "fixed" ? "Fixed" : "Automatic"; + const constant = + range.mode === "automatic" && + range.count > 0 && + range.minimum === range.maximum; + required(`${prefix}-mode`).textContent = + `${mode}${range.count === 0 ? " · no values" : constant ? " · constant" : ""}`; + const ramp = required(`${prefix}-ramp`); + ramp.hidden = range.mode === "automatic" && range.count === 0; + ramp.style.background = constant + ? `rgb(${rgbBytes(viridis(0.5)).join(",")})` + : ""; } function updateSignalRange(): void { @@ -155,18 +198,31 @@ function updateSignalRange(): void { } function updateSignal(): void { - if (frame?.signalGrid === null || frame === null || !signalVisible.checked) { + const legend = required("signal-legend"); + if (frame?.signalGrid === null || frame === null) { viewer.setSignalSlice(null); + legend.hidden = true; return; } const axis = signalAxis.value as SliceAxis; + const index = selectedInteger(signalChannel); const value = signalSlice( frame.signalGrid, - selectedInteger(signalChannel), + index, axis, selectedInteger(signalRange), ); - viewer.setSignalSlice(value); + const config = scalarRanges.get("signals", index); + const range = resolveScalarRange(value.values, config); + signalRangeControls.bind(index, range); + viewer.setSignalSlice(signalVisible.checked ? value : null, config); + legend.hidden = false; + required("signal-legend-title").textContent = `Signal ${index}`; + required("signal-legend-min").textContent = + range.minimum === null ? "—" : formatNumber(range.minimum); + required("signal-legend-max").textContent = + range.maximum === null ? "—" : formatNumber(range.maximum); + updateRangeLegend("signal-legend", range); } function detail(label: string, value: string): HTMLDivElement { @@ -227,6 +283,9 @@ function presentScene( ): void { if (newDataset) { presentation.beginDataset(); + scalarRanges.beginDataset(); + speciesRangeControls.beginDataset(); + signalRangeControls.beginDataset(); viewer.beginDataset(); } const display = presentation.forFrame(next); diff --git a/viewer/src/scalar-range-controls.ts b/viewer/src/scalar-range-controls.ts new file mode 100644 index 0000000..3e90aa2 --- /dev/null +++ b/viewer/src/scalar-range-controls.ts @@ -0,0 +1,134 @@ +import { + DatasetScalarRanges, + parseFixedScalarRange, + suggestedFixedRange, + type ResolvedScalarRange, + type ScalarChannelKind, +} from "./scalar-range"; + +/** Reusable keyboard-accessible editor; draft text never changes the active range. */ +export class ScalarRangeControls { + private readonly mode: HTMLSelectElement; + private readonly fixedFields: HTMLElement; + private readonly minimum: HTMLInputElement; + private readonly maximum: HTMLInputElement; + private readonly message: HTMLElement; + private channel: number | null = null; + private resolved: ResolvedScalarRange | null = null; + + public constructor( + root: HTMLElement, + private readonly ranges: DatasetScalarRanges, + private readonly kind: ScalarChannelKind, + private readonly onChange: () => void, + ) { + const label = kind === "species" ? "Species" : "Signal"; + const form = document.createElement("form"); + form.noValidate = true; + form.className = "scalar-range-control"; + form.setAttribute("aria-label", `${label} concentration range`); + const modeLabel = document.createElement("label"); + modeLabel.className = "field"; + const modeText = document.createElement("span"); + modeText.textContent = "Color range"; + this.mode = document.createElement("select"); + this.mode.setAttribute("aria-label", `${label} range mode`); + for (const [value, text] of [ + ["automatic", "Automatic"], + ["fixed", "Fixed"], + ] as const) { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + this.mode.append(option); + } + modeLabel.append(modeText, this.mode); + this.fixedFields = document.createElement("div"); + this.fixedFields.className = "scalar-range-fields"; + const input = (title: string): HTMLInputElement => { + const labelElement = document.createElement("label"); + labelElement.className = "field"; + const text = document.createElement("span"); + text.textContent = title; + const element = document.createElement("input"); + element.type = "text"; + element.inputMode = "decimal"; + element.setAttribute("aria-label", `${label} ${title.toLowerCase()}`); + element.setAttribute("aria-describedby", `${root.id}-message`); + labelElement.append(text, element); + this.fixedFields.append(labelElement); + return element; + }; + this.minimum = input("Minimum"); + this.maximum = input("Maximum"); + const apply = document.createElement("button"); + apply.type = "submit"; + apply.className = "button button-secondary"; + apply.textContent = "Apply range"; + this.message = document.createElement("p"); + this.message.id = `${root.id}-message`; + this.message.className = "scalar-range-message"; + this.message.setAttribute("role", "status"); + this.fixedFields.append(apply, this.message); + form.append(modeLabel, this.fixedFields); + root.append(form); + this.mode.addEventListener("change", () => { + if (this.channel === null || this.resolved === null) return; + const config = + this.mode.value === "automatic" + ? { mode: "automatic" as const } + : (this.ranges.lastFixed(this.kind, this.channel) ?? + suggestedFixedRange(this.resolved)); + this.ranges.set(this.kind, this.channel, config); + this.syncFields(); + this.onChange(); + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (this.channel === null || this.mode.value !== "fixed") return; + try { + const config = parseFixedScalarRange( + this.minimum.value, + this.maximum.value, + ); + this.ranges.set(this.kind, this.channel, config); + this.syncFields(); + this.onChange(); + } catch (error) { + this.message.textContent = `${error instanceof Error ? error.message : String(error)} The previous range remains active.`; + this.message.dataset.error = "true"; + this.minimum.setAttribute("aria-invalid", "true"); + this.maximum.setAttribute("aria-invalid", "true"); + } + }); + } + + public beginDataset(): void { + this.channel = null; + this.resolved = null; + } + + public bind(channel: number, resolved: ResolvedScalarRange): void { + this.resolved = resolved; + if (this.channel !== channel) { + this.channel = channel; + this.syncFields(); + } + } + + private syncFields(): void { + if (this.channel === null || this.resolved === null) return; + const active = this.ranges.get(this.kind, this.channel); + const fixed = + this.ranges.lastFixed(this.kind, this.channel) ?? + suggestedFixedRange(this.resolved); + this.mode.value = active.mode; + this.fixedFields.hidden = active.mode !== "fixed"; + this.minimum.value = String(fixed.minimum); + this.maximum.value = String(fixed.maximum); + this.minimum.removeAttribute("aria-invalid"); + this.maximum.removeAttribute("aria-invalid"); + this.message.textContent = "Apply or press Enter to use edited bounds."; + delete this.message.dataset.error; + } +} diff --git a/viewer/src/style.css b/viewer/src/style.css index 0466d88..edb350d 100644 --- a/viewer/src/style.css +++ b/viewer/src/style.css @@ -714,3 +714,45 @@ dd { transition-duration: 0ms; } } + +.scalar-range-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.scalar-range-fields input { + width: 100%; + min-width: 0; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 7px 8px; + background: var(--bg); + color: var(--text); + font-size: 12px; +} + +.scalar-range-fields input[aria-invalid="true"] { + border-color: var(--danger); +} + +.scalar-range-fields > button, +.scalar-range-message { + grid-column: 1 / -1; +} + +.scalar-range-message, +.legend-caption { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.5; +} + +.scalar-range-message[data-error="true"] { + color: var(--danger); +} + +.legend-caption { + margin-block: 6px; +} diff --git a/viewer/tests/color.test.ts b/viewer/tests/color.test.ts index 8da7817..60af627 100644 --- a/viewer/tests/color.test.ts +++ b/viewer/tests/color.test.ts @@ -63,6 +63,47 @@ describe("cell color mappings", () => { expect(mapping.colors[2]).toEqual(viridis(0.5)); }); + it("uses the selected fixed range across frames without changing cell values", () => { + const config = { + mode: "species" as const, + speciesIndex: 0, + range: { mode: "fixed" as const, minimum: 0, maximum: 10 }, + }; + const initial = mapCellColors(frame, config); + const later = { + ...frame, + cells: [cell(0, 0, 0, [2, 8]), cell(1, 0, 0, [40, 6])], + }; + const mapping = mapCellColors(later, config); + expect(initial.colors[0]).toEqual(mapping.colors[0]); + expect(mapping.colors[1]).toEqual(viridis(1)); + expect(mapping.range).toEqual({ + mode: "fixed", + minimum: 0, + maximum: 10, + count: 2, + }); + expect(later.cells[1]?.species[0]).toBe(40); + }); + + it("reports a configured fixed scale but zero values for an empty frame", () => { + const mapping = mapCellColors( + { ...frame, cells: [] }, + { + mode: "species", + speciesIndex: 0, + range: { mode: "fixed", minimum: -2, maximum: 2 }, + }, + ); + expect(mapping.colors).toEqual([]); + expect(mapping.range).toEqual({ + mode: "fixed", + minimum: -2, + maximum: 2, + count: 0, + }); + }); + it("rejects an unavailable species channel", () => { expect(() => mapCellColors(frame, { mode: "species", speciesIndex: 2 }), From 283c0ee150a734a779c5ad5c5d59c3b25dd09c27 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:48:26 -0600 Subject: [PATCH 10/38] Pause disconnected sessions before admitting reconnects --- python/src/microsimulator/viewer_server.py | 27 +++++++-- python/tests/test_viewer_shutdown.py | 68 +++++++++++++++++++++- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/python/src/microsimulator/viewer_server.py b/python/src/microsimulator/viewer_server.py index 01decb8..2bd216b 100644 --- a/python/src/microsimulator/viewer_server.py +++ b/python/src/microsimulator/viewer_server.py @@ -218,11 +218,14 @@ async def _send_frame(self, socket: web.WebSocketResponse) -> None: await socket.send_str(json.dumps(await self._message(), separators=(",", ":"))) async def broadcast_frame(self) -> None: - if not self._sockets: + sockets = tuple(self._sockets) + if not sockets: return encoded = json.dumps(await self._message(), separators=(",", ":")) stale: list[web.WebSocketResponse] = [] - for socket in tuple(self._sockets): + # A frame captured for earlier clients must not arrive ahead of a newly + # connected client's initial frame (or carry its stale playing flag). + for socket in sockets: if socket.closed: stale.append(socket) continue @@ -301,6 +304,15 @@ async def command(self, command: LiveCommand) -> str | None: async def connect(self, socket: web.WebSocketResponse) -> None: self._require_active() + stale = {client for client in self._sockets if client.closed} + if stale: + self._sockets.difference_update(stale) + if not self._sockets: + # The peer may receive its close handshake before the old + # request handler reaches finally. Honor last-client pause + # before admitting a replacement connection in that window. + await self.pause(broadcast=False) + self._require_active() self._sockets.add(socket) await self._send_frame(socket) @@ -410,9 +422,14 @@ async def execute_commands() -> None: await send_error(error) finally: consumer.cancel() - with suppress(asyncio.CancelledError): - await consumer - await controller.disconnect(socket) + try: + # Drop client authority and pause before waiting for canceled work + # to drain. Otherwise a reconnect during that wait keeps playback + # alive because the old socket still appears to be connected. + await controller.disconnect(socket) + finally: + with suppress(asyncio.CancelledError): + await consumer return socket diff --git a/python/tests/test_viewer_shutdown.py b/python/tests/test_viewer_shutdown.py index dd0c13c..a158398 100644 --- a/python/tests/test_viewer_shutdown.py +++ b/python/tests/test_viewer_shutdown.py @@ -13,7 +13,7 @@ from urllib.parse import urlsplit import pytest -from aiohttp import ClientSession, WSMsgType, web +from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType, web from aiohttp.test_utils import TestClient, TestServer from microsimulator import BackendKind, CellInit, Simulation, load_checkpoint from microsimulator import checkpoint as checkpoint_module @@ -173,7 +173,11 @@ async def exercise() -> None: asyncio.run(exercise()) -def test_disconnect_pauses_without_stopping_and_close_is_idempotent(tmp_path: Path) -> None: +@pytest.mark.parametrize("_attempt", range(10)) +def test_disconnect_pauses_without_stopping_and_close_is_idempotent( + tmp_path: Path, + _attempt: int, +) -> None: async def exercise() -> None: session = LiveSession(_factory, dt=0.1) app, token = create_live_app(session, _dist(tmp_path)) @@ -209,6 +213,66 @@ def test_stop_command_is_closed() -> None: parse_command('{"type":"stop","force":true}') +def test_reconnect_observes_pause_before_disconnected_command_drains( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def exercise() -> None: + entered, release = Event(), Event() + reconnect_started = asyncio.Event() + frame_message = LiveSession.frame_message + connect = LiveController.connect + frames, connections = 0, 0 + + def blocked_frame(session: LiveSession, *, playing: bool) -> dict[str, JSONValue]: + nonlocal frames + frames += 1 + if frames == 2: + entered.set() + assert release.wait(10), "test did not release frame capture" + return frame_message(session, playing=playing) + + async def observe_connect(controller: LiveController, ws: web.WebSocketResponse) -> None: + nonlocal connections + connections += 1 + if connections == 2: + reconnect_started.set() + await connect(controller, ws) + + monkeypatch.setattr(LiveSession, "frame_message", blocked_frame) + monkeypatch.setattr(LiveController, "connect", observe_connect) + app, token = create_live_app(LiveSession(_factory, dt=0.1), _dist(tmp_path)) + client = TestClient(TestServer(app)) + try: + await client.start_server() + origin = str(client.make_url("/")).rstrip("/") + + async def open_socket() -> ClientWebSocketResponse: + return await client.ws_connect( + f"/api/v1/session?token={token}", + headers={"Origin": origin}, + ) + + ws = await open_socket() + await ws.receive_json(timeout=5) + await ws.send_json({"type": "play"}) + await _entered(entered) + # close() finishes the network handshake while the server's canceled + # frame capture is still holding its worker/serialization lock. + await ws.close() + reconnect = asyncio.create_task(open_socket()) + await asyncio.wait_for(reconnect_started.wait(), 5) + release.set() + ws2 = await reconnect + assert (await ws2.receive_json(timeout=5))["playing"] is False + await ws2.close() + finally: + release.set() + await client.close() + + asyncio.run(exercise()) + + def test_stop_is_not_blocked_by_a_stalled_frame_send( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 4ccef4f3a95f02b810fa18eb76b75e0e9ff7a80e Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:48:31 -0600 Subject: [PATCH 11/38] Specify conservative cell occupancy transport with executable reference cases --- .../architecture/0025-cell-occupied-volume.md | 104 ++++++ docs/architecture/README.md | 1 + docs/microfluidics.md | 4 + .../src/microsimulator/occupancy_reference.py | 314 ++++++++++++++++++ python/tests/test_occupancy_reference.py | 178 ++++++++++ 5 files changed, 601 insertions(+) create mode 100644 docs/architecture/0025-cell-occupied-volume.md create mode 100644 python/src/microsimulator/occupancy_reference.py create mode 100644 python/tests/test_occupancy_reference.py diff --git a/docs/architecture/0025-cell-occupied-volume.md b/docs/architecture/0025-cell-occupied-volume.md new file mode 100644 index 0000000..e55fe66 --- /dev/null +++ b/docs/architecture/0025-cell-occupied-volume.md @@ -0,0 +1,104 @@ +# ADR 0025: conservative extracellular storage with coarse geometric porosity + +- Status: selected numerical design; executable CPU reference only +- Date: 2026-09-24 +- Scope: issue #13. Native implementation and enabling this model in simulations are separate work. + +## Decision and current behavior + +Select **coarse geometric porosity** as an opt-in transport model. Estimate the union of cell capsules inside each non-wall voxel, store solute amount per voxel, and use a declared porosity closure for face conductance. This approximates cell exclusion without claiming to resolve sub-voxel fluid passages, membrane boundary layers, displacement flow, or hydrodynamic forces. It is preferable here to treating smoothed biochemical biomass density as an exact solid fraction. + +Current production transport continues to use full non-wall voxel volume: `SignalGridSpec.voxel_volume()` is `hx*hy*hz`, and `cpu_coupled.cpp` divides scattered cell amount rates by that volume. `colony_volume_fraction()` deposits conserved biochemical biomass for empirical resistance; it may exceed one and is not this occupancy representation. No production defaults or checkpoint formats change in this contribution. + +The reference is `python/src/microsimulator/occupancy_reference.py`. It uses float64 midpoint quadrature and a dense backward-Euler solve, intentionally unsuitable for large simulations. Its interfaces carry explicit amounts, volumes, faces, and ledgers so later backends can be compared without inheriting their implementation. + +## State, geometry, and units + +For voxel i, geometric volume `V_i = hx*hy*hz`, accessible fraction `epsilon_i`, accessible storage `W_i = epsilon_i V_i`, and extracellular concentration `c_i`, define the authoritative solute amount `N_i = W_i c_i`. Concentration is amount per accessible fluid volume, including in partially occupied voxels. Length has units L, time T, concentration A/L³, and amount A. + +Occupancy depends only on the current cell centers, normalized directions, nonnegative centerline lengths, positive radii, lattice centers/spacing, and the existing binary transport-wall mask. It does not depend on species, growth-rate attributes, cell type, stationary attachment, or the biomass-resistance averaging radius. All cells, including mobile ones, exclude storage. Mechanical wall primitives do not define a second transport mask: only the declared voxel obstacle mask clips storage. + +Each voxel uses m³ midpoint samples on its physical box centered at `origin + index*spacing`. A sample is occupied when its distance to any capsule centerline segment is at most the corresponding radius. Count the union, never summed capsule fractions, so overlaps cannot make epsilon negative. Outside-domain capsule pieces are ignored. A wall voxel has epsilon zero regardless of cells. Default reference m is 8; m is a reproducible numerical parameter requiring convergence checks. Even a degenerate lattice axis retains its physical voxel thickness for occupancy and storage; it does not turn capsules into disks. + +Biochemical biomass remains `B = pi*r²*(length + 2*r)`. Geometric capsule volume is `V_geom = pi*r²*length + 4*pi*r³/3`. Native division preserves B but, for equal radii, reduces total capsule volume by `2*pi*r³/3`. This closure deliberately exposes the resulting extra fluid storage. It represents coarse division geometry, not physical septum formation or calibrated cell volume. Never transfer extracellular solute into daughters merely because their geometric occupancy changed. + +## Conservative transport balance + +For an internal oriented face i→j, use harmonic closure `a_f = 2 epsilon_i epsilon_j / (epsilon_i + epsilon_j)` when both voxels are accessible, otherwise zero. This is a coarse permeability/aperture approximation, not geometric face intersection. Let `K_f = D a_f A_f / d_f` and `Q_f = a_f A_f u_f`. D has units L²/T, K and Q have units L³/T, and u is intrinsic accessible-fluid velocity in L/T. Positive Q flows from i to j. The outward amount rate is + +```text +F_ij = K_f (c_i - c_j) + max(Q_f, 0) c_i + min(Q_f, 0) c_j +``` + +The identical face value enters the neighbor with the opposite sign. With amount source S_i, accessible-fluid reaction source b_i, and first-order loss lambda_i: + +```text +dN_i/dt = -sum_faces F_ij + S_i + W_i b_i - lambda_i N_i +``` + +After the geometric remap described below, freeze W and face coefficients over one transport step. The first native implementation should offer backward Euler: + +```text +(W + dt L) c_new = N_remapped + dt (S + W b + reservoir_inflow) +N_new = W c_new +``` + +L has diffusion conductances, upwind outgoing fluxes, and `lambda_i W_i` on its diagonal, with negative incoming neighbor coefficients. Zero-storage rows are isolated identity rows with zero right-hand side. The reference reports before/after total amount plus separately integrated source, reaction, and boundary amounts; internal faces cancel. Do not infer conservation from changes in concentration or from an unweighted sum of concentrations. + +No-flux boundaries have K=Q=0. Periodic pairs contribute one shared internal face. Fixed reservoirs retain the existing **exterior lattice-center** convention: distance d is one spacing, not a half spacing. For this closure, use exterior epsilon=1 and the same harmonic rule. Reservoir exchange is `K(c_res-c_i) - Q*c_upwind` with Q positive outward, and is included in the boundary ledger. In a singleton axis production transport has no face operator, matching the existing degenerate-axis rule. Affine b is concentration/time per accessible fluid; a physical source specified per whole voxel instead becomes an explicit amount rate, never an implicit second volume conversion. + +## Velocity convention and flow coupling + +The current face field is a velocity used directly by transport and also sampled by cell drift. With the existing binary wall mask and epsilon=1 on fluid sites, intrinsic velocity and whole-open-face volumetric velocity coincide. Partial porosity introduces a distinction that cannot be inferred from old arrays. + +In the new opt-in mode, retain **intrinsic velocity** in `SignalGridVelocityField` and derive Q by multiplying `a_f*A_f` once. If a flow solver produces integrated flux Q, convert to intrinsic u by dividing by `a_f*A_f` once on open faces and require Q=0 on closed faces. Never multiply an already aperture-weighted flux by epsilon again. Constant-advection inputs use the same explicit convention. + +Existing flow solutions generally satisfy continuity for their current binary fluid geometry, not for these new weighted faces. They must not be advertised as occupancy-consistent flow without a weighted projection/re-solve. For fixed occupancy, validate `sum Q_out = 0` in interior voxels. Moving occupancy would require `dW/dt + sum Q_out = 0` for incompressible displaced fluid; this initial model instead uses the explicit conservative remap below. That remap is a solute bookkeeping closure and does not solve fluid displacement. Prescribed velocity experiments must declare this limitation; coupling a resolved displacement-flow model is subsequent work. Transport remains amount-conservative even for a prescribed divergent field, but uniform concentration need not remain uniform. + +## Occupancy changes, closed storage, and transaction order + +Fractions smaller than `epsilon_cutoff = 1e-8` are treated as zero, including for face closure. The cutoff is part of the model configuration and checkpoint state, not a hidden denominator floor. Keep N fixed in each voxel whose new W remains positive, so concentration changes to N/W. Newly accessible voxels start with their existing amount, normally zero; removal does not invent extracellular solute. + +For voxels changing to zero storage, redistribute their entire amount over newly accessible recipient voxels in the same face-connected component of the **union of old and new accessible voxels**, weighted by recipients' new W. Connectivity uses regular voxel neighbors including declared periodic pairs and excludes persistent wall/closed voxels. Use sorted voxel order for deterministic accumulation. Existing recipient amounts are retained. If a component closes completely while holding any positive amount, reject the entire geometry/transport transaction. If it contains exactly zero amount, closure is valid. No epsilon floor, disappearing amount, cross-wall transfer, or silent clipping is allowed. A near-zero positive W can still create high concentrations; finite/positivity and solver checks may reject the step rather than alter its mass balance. + +This remap is intentionally nonlocal within its transition component and can instantaneously mix expelled solute. It does not reconstruct a membrane trajectory or predict a swept-volume velocity. Its domain, cutoff, and redistribution rule must therefore be stated in scientific use. A donor is emptied once even when several cells overlap it. Reject invalid input before changing simulation state. + +The proposed opt-in controller/native stage order is: + +1. Snapshot complete state and authoritative N at the last committed geometry. Apply validated regulation, divisions, removals, and their geometry callbacks. Recompute occupancy and conservatively remap if geometry changed. +2. Advance biological growth and intracellular dilution using B. Recompute occupancy for post-growth geometry and remap N again. Construct one set of accessible exchange weights, sample this remapped pre-transport concentration, evaluate biology, then solve transport/reactions and scatter cell amount rates using the same weights. This explicitly changes the sampling point from the legacy stage; it needs a separately named opt-in contract and native split-stage work. +3. Apply configured flow drift and mechanical relaxation, then recompute/remap once at final geometry. Any direct geometry edit or removal outside the controller must invoke the same barrier before the next transport/export/checkpoint operation. No geometry change may leave old W paired with new cells. +4. Commit geometry, biomass/species, N, W, time, and all ledgers together only after every stage validates. A failure restores the complete transaction, including controller/RNG state. Zero-time topology edits still require remapping. Unchanged geometry reuses occupancy without resampling. + +This ordering follows the existing regulation/topology → biology → drift/mechanics outline while making its new geometry barriers explicit. It is not implemented by the reference module. Production work must add atomic staging; installing only a new denominator inside `cpu_coupled.cpp` would be incorrect. + +## Cell sampling and scattering + +Choose a declared physical support that reaches accessible extracellular sites, restricted to the connected fluid component containing a deterministic nearest-accessible anchor. Resolve equal-distance anchors by flattened index and explicitly validate the maximum search radius; no cross-wall search. For nonnegative geometric kernel values phi_i in that support, use `w_i = phi_i W_i / sum(phi_j W_j)`. Sampling is `sum(w_i c_i)`; a cellular extracellular amount rate J scatters `w_i J`. The concentration-rate conversion is `(w_i J)/W_i` exactly once, for accessible sites only. The same weights give a partition of unity and the sampling/scattering adjoint relation. The reference `exchange_weights` receives an already connected support; topology construction remains native implementation work. + +With epsilon=1 and equal voxel volumes this reduces to the current normalized trilinear weights and J/V conversion. With resolved excluded cell centers, the old eight center-neighbor stencil may have no accessible sites; expanding or surface-based physical support is required, with a declared radius and refinement study. Reject zero accessible support. Uptake must be limited by an explicitly conservative coupled solve or reject an unaffordable step; never clamp negative extracellular concentrations. Opposite intracellular amount uses B, not geometric occupancy. Washout of a cell exports its intracellular amount through a separate biological ledger; it does not remove a voxel's extracellular solute. + +## Executable reference cases and tolerances + +Run `uv run python -m pytest python/tests/test_occupancy_reference.py -v`. The cases are deterministic CPU tests: + +| Case | Amount evidence | +| --- | --- | +| Empty grid | Float64 reference equals existing native CPU backward Euler for `[0,1,0]`, within rtol 2e-6/atol 2e-7; total amount remains 1. Empty geometry gives epsilon=1. | +| Partially occupied closed domain and unequal storage | W=[0.5,1.5], initial c=[8,0], total N=4; diffusion tends to equal c=2 with unequal amounts [1,3]. Every-step ledger residual is below 1e-12. | +| Changing occupancy, closure, reopening | N=[2,3,1], W changes [1,1,1]→[0,0.25,0.75]; remap gives [0,3.5,2.5], total 6. Reopening keeps new-site amount zero. Entire-component closure rejects without mutation; a persistent wall prevents redistribution to an unrelated recipient. | +| Division/removal, overlap, wall-adjacent geometry | One native-shaped parent becomes two daughters with unchanged B and smaller geometric volume. Union quadrature does not double-count duplicate cells, wall voxels remain inaccessible, and remapping through division/removal preserves total N. | +| Boundaries, reactions, exchange and advection | Unequal storage, an internal advective face, inflow/outflow reservoirs, decay and cellular source all contribute to the signed ledger; residual below 1e-12. A face test verifies aperture enters Q once. | +| Refinement | Backward-Euler timestep error approximately halves for 10/20/40 steps; the empty-limit centered operator error approximately quarters for 10/20/40 voxels. Sphere quadrature at m=8/16/32 improves the finest volume error to below 2%. | + +The 1e-12 reference balance tolerance applies to these order-one float64 cases. General checks scale by `max(1, |N_before|, |N_after|, sum(abs(external terms)))`; scientific units must be normalized explicitly. Initial native float32 gates: per-step relative ledger residual <=5e-6, 1000-step closed-case drift <=5e-5, and concentrations versus float64 reference within rtol 2e-4/atol 2e-6 in normalized test units. These are acceptance targets to measure, not verified GPU results. + +Subsequent native validation must run h, h/2, h/4 at fixed physical cell/device dimensions and exchange support, with m, 2m, 4m independently; also dt, dt/2, dt/4. Track occupied volume, total amount, concentration L1/Linf errors, boundary/reaction/cell ledgers, cutoff crossings, and solver residuals. Require convergence of the scientific observable, not only conservation. The midpoint geometry estimate can oscillate across resolutions; never assert a universal smooth-interface order from one placement. Test rotated/translated rods, overlapping capsules, nearly blocked passages, all-zero storage, wall contacts, division/removal, and restart at a geometry barrier. Vary epsilon_cutoff by factors of ten. Native backends must implement their own kernels and pass the same cases without CPU fallback. + +## Compatibility and implementation work + +Old checkpoints and all ordinary runs retain occupancy-disabled full-voxel semantics. Do not reinterpret old concentration arrays as fluid-volume concentrations. Enabling occupancy on an existing state is an explicit conversion: preserve legacy amount `N=V*c`, compute W, then apply closure remapping and derive c. Such a conversion can reject a sealed component and must be recorded in provenance. + +A future checkpoint version must record model kind/version, lattice and wall geometry, quadrature resolution, cutoff, exchange-support/anchor rule, remap rule, velocity convention, authoritative per-species N and committed W/geometry revision, plus any required solver history. N and W participate in integrity checks. On restore, authenticate before migration, validate N>=0 and zero amount at W=0, verify geometry/occupancy consistency, and resume at a committed barrier without repeating remapping. Record precision/backend provenance; recomputing occupancy with another quadrature algorithm may change results and requires an explicit conversion. The current reference adds no fields to checkpoints. + +Required follow-up contributions are (1) native state/configuration and checkpoint conversion, (2) conservative geometry rasterization/transition connectivity, (3) CPU weighted-storage operator and atomic coupled staging, (4) independent Metal and CUDA kernels/reductions/solvers, (5) accessible cell exchange and uptake budgets, (6) weighted-flow interface/projection with explicit drift semantics, and (7) end-to-end geometry/removal/restart and refinement validation. Existing flow resistance may coexist as a calibrated closure; it must not be relabeled as geometric exclusion or counted a second time in transport porosity. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a823b8c..643f936 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -24,6 +24,7 @@ The [numerical contract](numerical-contract.md) is the best starting point for w - [Persistent fixed rod cells](0009-fixed-cells.md) - [Biomass, growth, division, and uptake](0024-biomass-accounting.md) - [Typed species rate plans](0003-species-rates.md) +- [Cell-occupied extracellular volume: design and CPU reference](0025-cell-occupied-volume.md) - [Grid signaling and cell coupling](0006-grid-signaling.md) - [Crank-Nicolson signal transport](0008-crank-nicolson-signals.md) - [Neighbor diffusion](0010-neighbor-diffusion.md) diff --git a/docs/microfluidics.md b/docs/microfluidics.md index 833dc95..286f1b8 100644 --- a/docs/microfluidics.md +++ b/docs/microfluidics.md @@ -31,6 +31,10 @@ Attached biomass can change flow resistance through a conservatively smoothed de Free-cell motion uses the local velocity and a finite-aspect Jeffery orientation approximation, followed by contact relaxation. This kinematic coupling approximates rods as equivalent spheroids for rotation. Cell-scale hydrodynamic forces, lubrication, and predictive adhesion or detachment are outside its scope. The [flow-drift design](architecture/0021-flow-drift.md) specifies the approximation and integration limits. +## Cell-occupied extracellular volume + +Current native transport stores concentration per full non-wall voxel; cells do not yet exclude extracellular storage. The smoothed biochemical biomass density used for flow resistance is neither bounded geometric occupancy nor a resolved fluid fraction. [ADR 0025](architecture/0025-cell-occupied-volume.md) selects an opt-in coarse geometric-porosity model and provides executable CPU reference cases, including conservative amount remapping as geometry changes. It is a numerical design, not an enabled production feature. Its harmonic face closure and component-level redistribution do not resolve fluid passages around individual cells, membrane transport layers, or displacement flow; geometric, spatial, and timestep refinement remain required. + ## Interpreting results The [analytic flow benchmarks](tutorials/flow-solvers.md#numerical-evidence) test profile convergence, flux routing, and agreement between the solvers in a shared thin-gap regime. The [controlled nutrient study](tutorials/nutrient-validation.md) measures spatial growth, nutrient balance, and sensitivity to grid spacing, timestep, and flow-refresh interval. It isolates attached-population growth and transport; the interactive tutorials exercise division, mechanics, and washout separately. diff --git a/python/src/microsimulator/occupancy_reference.py b/python/src/microsimulator/occupancy_reference.py new file mode 100644 index 0000000..7b0544d --- /dev/null +++ b/python/src/microsimulator/occupancy_reference.py @@ -0,0 +1,314 @@ +"""Float64 design reference for ADR 0025; not a native transport implementation. + +Amounts are authoritative. Coarse geometric porosity is distinct from the +biochemical biomass density used by the flow-resistance closure. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast + +import numpy as np +from numpy.typing import NDArray + +Array = NDArray[np.float64] +EPSILON_CUTOFF = 1.0e-8 + + +@dataclass(frozen=True) +class Capsule: + center: tuple[float, float, float] + direction: tuple[float, float, float] + length: float + radius: float + + def __post_init__(self) -> None: + values = (*self.center, *self.direction, self.length, self.radius) + if not all(math.isfinite(value) for value in values): + raise ValueError("capsule geometry must be finite") + if len(self.center) != 3 or len(self.direction) != 3: + raise ValueError("capsule vectors must have three coordinates") + if self.length < 0 or self.radius <= 0 or math.hypot(*self.direction) == 0: + raise ValueError("capsule requires nonnegative length, positive radius and direction") + + +def geometric_porosity( + centers: Array, + spacing: tuple[float, float, float], + cells: Sequence[Capsule], + *, + subdivisions: int = 8, + walls: Sequence[bool] | None = None, +) -> Array: + """Midpoint quadrature of the union of capsules, clipped to fluid voxels. + + centers use native lattice-center convention. Walls use the existing binary + voxel mask, not a second interpretation of mechanical constraint surfaces. + Overlaps count once. The result is a coarse storage fraction, not a resolved + aperture or a sub-voxel connectivity claim. + """ + points = np.asarray(centers, dtype=np.float64) + if points.ndim != 2 or points.shape[1] != 3 or not np.isfinite(points).all(): + raise ValueError("centers must be finite N by 3 coordinates") + if len(spacing) != 3 or any(not math.isfinite(h) or h <= 0 for h in spacing): + raise ValueError("spacing must contain three positive finite lengths") + if ( + isinstance(subdivisions, bool) + or not isinstance(cast(object, subdivisions), int) + or subdivisions < 1 + ): + raise ValueError("subdivisions must be a positive integer") + solid = np.zeros(len(points), dtype=np.bool_) if walls is None else np.asarray(walls) + if solid.shape != (len(points),) or solid.dtype != np.bool_: + raise ValueError("walls must contain one Boolean per voxel") + samples = (np.arange(subdivisions, dtype=np.float64) + 0.5) / subdivisions - 0.5 + offsets = np.stack(np.meshgrid(samples, samples, samples, indexing="ij"), axis=-1) + offsets = offsets.reshape(-1, 3) * np.asarray(spacing) + result = np.zeros(len(points)) + for index, center in enumerate(points): + if solid[index]: + continue + coordinates = center + offsets + occupied = np.zeros(len(coordinates), dtype=np.bool_) + for cell in cells: + direction = np.asarray(cell.direction, dtype=np.float64) + direction = direction / np.linalg.norm(direction) + relative = coordinates - np.asarray(cell.center) + axial = np.clip(relative @ direction, -cell.length / 2, cell.length / 2) + distance = relative - axial[:, None] * direction + squared = cast(Array, np.sum(distance * distance, axis=1)) + within = squared <= cell.radius * cell.radius + occupied = np.logical_or(occupied, within) + result[index] = 1.0 - np.mean(occupied) + result[result < EPSILON_CUTOFF] = 0 + return result + + +def _vector(values: Sequence[float] | Array, name: str, count: int | None = None) -> Array: + result = np.asarray(values, dtype=np.float64) + if result.ndim != 1 or not np.isfinite(result).all(): + raise ValueError(f"{name} must be a finite vector") + if count is not None and result.shape != (count,): + raise ValueError(f"{name} size mismatch") + return result + + +def accessible_volumes(porosity: Sequence[float] | Array, voxel_volume: float) -> Array: + epsilon = _vector(porosity, "porosity") + if np.any(epsilon < 0) or np.any(epsilon > 1): + raise ValueError("porosity must lie in [0, 1]") + if not math.isfinite(voxel_volume) or voxel_volume <= 0: + raise ValueError("voxel volume must be finite and positive") + return np.where(epsilon < EPSILON_CUTOFF, 0.0, epsilon) * voxel_volume + + +def concentration(amount: Sequence[float] | Array, volume: Sequence[float] | Array) -> Array: + n = _vector(amount, "amount") + w = _vector(volume, "accessible volume", len(n)) + if np.any(n < 0) or np.any(w < 0) or np.any((w == 0) & (n != 0)): + raise ValueError("nonnegative amounts require accessible storage") + return np.divide(n, w, out=np.zeros_like(n), where=w > 0) + + +def remap_amounts( + amount: Sequence[float] | Array, + old_volume: Sequence[float] | Array, + new_volume: Sequence[float] | Array, + neighbors: Sequence[tuple[int, int]], +) -> Array: + """Keep surviving voxel amounts; expel closing storage conservatively. + + Recipients are all newly accessible voxels in the old-or-new accessible + face-connected component, weighted by new accessible volume. A closing + component with nonzero amount fails atomically. Inputs are never mutated. + """ + n = _vector(amount, "amount") + old = _vector(old_volume, "old volume", len(n)) + new = _vector(new_volume, "new volume", len(n)) + concentration(n, old) + if np.any(new < 0): + raise ValueError("new volume must be nonnegative") + result = n.copy() + adjacency: list[list[int]] = [[] for _ in n] + for first, second in neighbors: + if first == second or not 0 <= first < len(n) or not 0 <= second < len(n): + raise ValueError("invalid neighbor edge") + adjacency[first].append(second) + adjacency[second].append(first) + active = (old > 0) | (new > 0) + visited: set[int] = set() + for start in range(len(n)): + if not active[start] or start in visited: + continue + pending = [start] + component: list[int] = [] + while pending: + index = pending.pop() + if index in visited or not active[index]: + continue + visited.add(index) + component.append(index) + pending.extend(adjacency[index]) + component.sort() + donors = [index for index in component if new[index] == 0] + recipients = [index for index in component if new[index] > 0] + expelled = math.fsum(float(n[index]) for index in donors) + if expelled > 0 and not recipients: + raise ValueError( + f"closing component at voxel {start} has solute but no accessible recipient" + ) + result[donors] = 0 + if expelled: + capacity = math.fsum(float(new[index]) for index in recipients) + for index in recipients: + result[index] += expelled * new[index] / capacity + concentration(result, new) + return result + + +@dataclass(frozen=True) +class Face: + """Internal oriented face: diffusive conductance L^3/T, fluid flux L^3/T.""" + + first: int + second: int + conductance: float + volume_flux: float = 0.0 + + +def porosity_face( + first: int, + second: int, + epsilon_first: float, + epsilon_second: float, + *, + diffusion: float, + area: float, + distance: float, + intrinsic_velocity: float = 0.0, +) -> Face: + """Harmonic porosity closure; aperture is applied exactly once to flux.""" + values = (epsilon_first, epsilon_second, diffusion, area, distance, intrinsic_velocity) + if not all(math.isfinite(value) for value in values): + raise ValueError("face data must be finite") + if not 0 <= epsilon_first <= 1 or not 0 <= epsilon_second <= 1: + raise ValueError("face porosities must lie in [0, 1]") + if diffusion < 0 or area <= 0 or distance <= 0: + raise ValueError("invalid face geometry or diffusion") + aperture = ( + 0.0 + if min(epsilon_first, epsilon_second) < EPSILON_CUTOFF + else 2 * epsilon_first * epsilon_second / (epsilon_first + epsilon_second) + ) + return Face( + first, second, diffusion * aperture * area / distance, aperture * area * intrinsic_velocity + ) + + +@dataclass(frozen=True) +class ReservoirFace: + """Exterior reservoir: positive flux leaves domain; concentration is amount/L^3.""" + + site: int + concentration: float + conductance: float = 0.0 + volume_flux: float = 0.0 + + +@dataclass(frozen=True) +class Balance: + before: float + after: float + source: float + reaction: float + boundary: float + + @property + def residual(self) -> float: + return self.after - self.before - self.source - self.reaction - self.boundary + + +def backward_euler( + amount: Sequence[float] | Array, + volume: Sequence[float] | Array, + faces: Sequence[Face], + dt: float, + *, + source: Sequence[float] | Array | None = None, + loss: Sequence[float] | Array | None = None, + reservoirs: Sequence[ReservoirFace] = (), +) -> tuple[Array, Balance]: + """Dense float64 finite-volume reference with an explicit amount ledger. + + source is amount/time, loss is 1/time. Interior faces are equal/opposite; + first-order advection and reservoir/loss terms are implicit. Not scalable. + """ + n = _vector(amount, "amount") + w = _vector(volume, "volume", len(n)) + concentration(n, w) + if not math.isfinite(dt) or dt < 0: + raise ValueError("dt must be finite and nonnegative") + s = np.zeros_like(n) if source is None else _vector(source, "source", len(n)) + k = np.zeros_like(n) if loss is None else _vector(loss, "loss", len(n)) + if np.any(k < 0) or np.any((w == 0) & (s != 0)): + raise ValueError("loss must be nonnegative; sources require accessible storage") + operator = np.diag(k * w) + rhs = n + dt * s + for face in faces: + i, j, g, q = face.first, face.second, face.conductance, face.volume_flux + if i == j or not 0 <= i < len(n) or not 0 <= j < len(n): + raise ValueError("invalid transport face indices") + if not math.isfinite(g) or g < 0 or not math.isfinite(q): + raise ValueError("invalid transport coefficients") + if (w[i] == 0 or w[j] == 0) and (g != 0 or q != 0): + raise ValueError("closed storage cannot have an open face") + operator[i, i] += g + max(q, 0) + operator[j, j] += g + max(-q, 0) + operator[i, j] -= g + max(-q, 0) + operator[j, i] -= g + max(q, 0) + for face in reservoirs: + i, c, g, q = face.site, face.concentration, face.conductance, face.volume_flux + if not 0 <= i < len(n) or w[i] == 0: + raise ValueError("reservoir must connect accessible storage") + if not all(math.isfinite(value) for value in (c, g, q)) or c < 0 or g < 0: + raise ValueError("invalid reservoir coefficients") + operator[i, i] += g + max(q, 0) + rhs[i] += dt * (g + max(-q, 0)) * c + matrix = np.diag(w) + dt * operator + for i in range(len(n)): + if w[i] == 0: + matrix[i, i] = 1.0 + c = np.linalg.solve(matrix, rhs) + updated = c * w + if not np.isfinite(updated).all() or np.any(updated < 0): + raise ValueError("step produced invalid amount; no clipping is permitted") + boundary = dt * math.fsum( + f.conductance * (f.concentration - c[f.site]) + - f.volume_flux * (c[f.site] if f.volume_flux >= 0 else f.concentration) + for f in reservoirs + ) + return updated, Balance( + float(n.sum()), + float(updated.sum()), + float(dt * s.sum()), + float(-dt * np.dot(k, updated)), + float(boundary), + ) + + +def exchange_weights( + kernel: Sequence[float] | Array, + volume: Sequence[float] | Array, +) -> Array: + """Accessible-volume weighted partition of unity in a declared connected support.""" + base = _vector(kernel, "kernel") + accessible = _vector(volume, "volume", len(base)) + if np.any(base < 0) or np.any(accessible < 0): + raise ValueError("exchange kernel and volume must be nonnegative") + weights = base * accessible + if np.any(weights < 0) or not np.isfinite(weights).all() or weights.sum() <= 0: + raise ValueError("cell has no valid accessible exchange support") + return weights / weights.sum() diff --git a/python/tests/test_occupancy_reference.py b/python/tests/test_occupancy_reference.py new file mode 100644 index 0000000..880c2ad --- /dev/null +++ b/python/tests/test_occupancy_reference.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import math + +import numpy as np +import pytest +from microsimulator.occupancy_reference import ( + Capsule, + Face, + ReservoirFace, + accessible_volumes, + backward_euler, + concentration, + exchange_weights, + geometric_porosity, + porosity_face, + remap_amounts, +) + + +def test_empty_grid_matches_existing_native_backward_euler() -> None: + from microsimulator import GridShape, SignalGridSpec, SignalIntegrationKind, Simulation, Vec3 + + shape = GridShape() + shape.x, shape.y, shape.z = 3, 1, 1 + spec = SignalGridSpec() + spec.shape, spec.signal_count = shape, 1 + spec.diffusion, spec.advection = [1.0], [Vec3()] + spec.integration = SignalIntegrationKind.BACKWARD_EULER + simulation = Simulation() + simulation.configure_signal_grid(spec, [0.0, 1.0, 0.0]) + simulation.step(0.1) + result, balance = backward_euler([0, 1, 0], [1, 1, 1], [Face(0, 1, 1), Face(1, 2, 1)], 0.1) + np.testing.assert_allclose(result, simulation.signal_levels, rtol=2e-6, atol=2e-7) + assert abs(balance.residual) < 1e-12 + centers = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) + np.testing.assert_array_equal(geometric_porosity(centers, (1, 1, 1), []), [1, 1]) + + +def test_partial_closed_grid_uses_amount_and_unequal_storage() -> None: + volume = accessible_volumes([0.25, 0.75], 2.0) + amount = volume * np.array([8.0, 0.0]) + face = porosity_face(0, 1, 0.25, 0.75, diffusion=1, area=1, distance=1) + initial = amount.sum() + for _ in range(100): + amount, ledger = backward_euler(amount, volume, [face], 1.0) + assert abs(ledger.residual) < 1e-12 + np.testing.assert_allclose(concentration(amount, volume), [2, 2], atol=1e-12) + assert abs(amount.sum() - initial) < 1e-12 + assert not math.isclose(float(amount[0]), float(amount[1])) + + +def test_changing_occupancy_and_full_closure_conserve_without_division_by_zero() -> None: + amount = np.array([2.0, 3.0, 1.0]) + old = np.ones(3) + new = accessible_volumes([0, 0.25, 0.75], 1) + result = remap_amounts(amount, old, new, [(0, 1), (1, 2)]) + np.testing.assert_allclose(result, [0, 3.5, 2.5], atol=1e-15) + assert result.sum() == amount.sum() + assert np.isfinite(concentration(result, new)).all() + reopened = remap_amounts(result, new, [1, 1, 1], [(0, 1), (1, 2)]) + assert reopened[0] == 0 # no invented solute in newly exposed storage + assert reopened.sum() == amount.sum() + with pytest.raises(ValueError, match="no accessible recipient"): + remap_amounts(amount, old, [0, 0, 0], [(0, 1), (1, 2)]) + np.testing.assert_array_equal(amount, [2, 3, 1]) # rejection is atomic + # A persistent wall separates the only potential recipient. + with pytest.raises(ValueError, match="no accessible recipient"): + remap_amounts([1, 0, 0], [1, 0, 1], [0, 0, 1], [(0, 1), (1, 2)]) + np.testing.assert_array_equal(accessible_volumes([1e-12, 0], 1), [0, 0]) + np.testing.assert_array_equal(remap_amounts([0], [1], [0], []), [0]) + + +def test_geometry_union_wall_clipping_division_and_removal() -> None: + centers = np.array( + [(x, y, z) for x in range(-3, 4) for y in range(-1, 2) for z in range(-1, 2)], + dtype=np.float64, + ) + parent = Capsule((0, 0, 0), (1, 0, 0), 4, 0.4) + daughters = [ + Capsule((-1.2, 0, 0), (1, 0, 0), 1.6, 0.4), + Capsule((1.2, 0, 0), (1, 0, 0), 1.6, 0.4), + ] + old = geometric_porosity(centers, (1, 1, 1), [parent], subdivisions=16) + np.testing.assert_array_equal( + old, geometric_porosity(centers, (1, 1, 1), [parent, parent], subdivisions=16) + ) + divided = geometric_porosity(centers, (1, 1, 1), daughters, subdivisions=16) + assert divided.sum() > old.sum() # native division reduces geometric solid volume + b_parent = math.pi * 0.4**2 * (4 + 0.8) + b_daughters = 2 * math.pi * 0.4**2 * (1.6 + 0.8) + assert math.isclose(b_parent, b_daughters) + assert math.isclose( + (math.pi * 0.4**2 * 4 + 4 / 3 * math.pi * 0.4**3) + - 2 * (math.pi * 0.4**2 * 1.6 + 4 / 3 * math.pi * 0.4**3), + 2 / 3 * math.pi * 0.4**3, + ) + # No voxel closes in this geometry transition; amount remains voxel-local. + amount = old * 2 + updated = remap_amounts(amount, old, divided, []) + removed = remap_amounts(updated, divided, np.ones_like(old), []) + assert abs(removed.sum() - amount.sum()) < 1e-12 + wall_mask = [bool(index == 31) for index in range(len(centers))] + with_wall = geometric_porosity(centers, (1, 1, 1), [parent], walls=wall_mask) + assert with_wall[31] == 0 + assert np.all((with_wall >= 0) & (with_wall <= 1)) + + +def test_boundary_reaction_and_cell_exchange_have_explicit_amount_ledgers() -> None: + volume = np.array([0.25, 0.75]) + weights = exchange_weights([0.5, 0.5], volume) + c = np.array([2.0, 4.0]) + source = weights * 3.0 + assert float(weights @ c) == 3.5 + assert float(source.sum()) == 3.0 + updated, ledger = backward_euler( + c * volume, + volume, + [Face(0, 1, 0.1, 0.05)], + 0.2, + source=source, + loss=[0.3, 0.7], + reservoirs=[ReservoirFace(0, 5, 0.2, -0.1), ReservoirFace(1, 0, 0, 0.1)], + ) + assert np.all(updated >= 0) + assert ledger.boundary > 0 and ledger.reaction < 0 and ledger.source > 0 + assert abs(ledger.residual) < 1e-12 + face = porosity_face(0, 1, 0.25, 0.75, diffusion=2, area=3, distance=4, intrinsic_velocity=5) + assert face.volume_flux == 0.375 * 3 * 5 # porosity appears exactly once + with pytest.raises(ValueError, match="accessible exchange"): + exchange_weights([1, 0], [0, 1]) + + +def test_diffusion_timestep_refinement_and_geometric_quadrature_refinement() -> None: + # Two-cell antisymmetric diffusion mode has eigenvalue -2 for epsilon=1. + errors: list[float] = [] + for steps in (10, 20, 40): + amount = np.array([1.5, 0.5]) + for _ in range(steps): + amount, _ = backward_euler(amount, [1, 1], [Face(0, 1, 1)], 1 / steps) + errors.append(abs(float(amount[0]) - (1 + 0.5 * math.exp(-2)))) + assert errors[2] < 0.55 * errors[1] < 0.31 * errors[0] + sphere = Capsule((0, 0, 0), (1, 0, 0), 0, 0.5) + exact = 4 / 3 * math.pi * 0.5**3 + geometric_errors: list[float] = [] + for resolution in (8, 16, 32): + epsilon = geometric_porosity(np.zeros((1, 3)), (2, 2, 2), [sphere], subdivisions=resolution) + geometric_errors.append(abs(float((1 - epsilon[0]) * 8) - exact)) + assert geometric_errors[-1] < geometric_errors[0] + assert geometric_errors[-1] < 0.02 * exact + + +def test_invalid_reference_inputs_fail_without_silent_clipping() -> None: + with pytest.raises(ValueError): + accessible_volumes([1.1], 1) + with pytest.raises(ValueError): + concentration([1], [0]) + with pytest.raises(ValueError): + backward_euler([0], [1], [], 1, source=[-1]) + with pytest.raises(ValueError): + backward_euler([0, 1], [0, 1], [Face(0, 1, 1)], 0.1) + + +def test_empty_limit_spatial_operator_is_second_order() -> None: + errors: list[float] = [] + for count in (10, 20, 40): + h = 1 / count + centers = (np.arange(count, dtype=np.float64) + 0.5) * h + values = 2 + np.cos(math.pi * centers) + rate = np.zeros(count) + for i in range(count - 1): + face = porosity_face(i, i + 1, 1, 1, diffusion=1, area=1, distance=h) + amount_flux = face.conductance * (values[i + 1] - values[i]) + rate[i] += amount_flux / h + rate[i + 1] -= amount_flux / h + exact = -(math.pi**2) * np.cos(math.pi * centers) + errors.append(float(np.max(np.abs(rate - exact)))) + assert errors[2] < 0.26 * errors[1] < 0.07 * errors[0] From 1351bccaaaa33b1058723eb32f4d802cea9564c2 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:54:41 -0600 Subject: [PATCH 12/38] Add linear RGB composite species coloring and channel controls --- viewer/README.md | 12 + viewer/browser/composite-species.mjs | 344 +++++++++++++++++++++++++++ viewer/index.html | 2 + viewer/src/color.ts | 24 +- viewer/src/composite-color.ts | 93 ++++++++ viewer/src/composite-controls.ts | 190 +++++++++++++++ viewer/src/composite-state.ts | 66 +++++ viewer/src/main.ts | 20 ++ viewer/src/scalar-range-controls.ts | 9 +- viewer/src/style.css | 68 ++++++ viewer/tests/composite.test.ts | 222 +++++++++++++++++ 11 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 viewer/browser/composite-species.mjs create mode 100644 viewer/src/composite-color.ts create mode 100644 viewer/src/composite-controls.ts create mode 100644 viewer/src/composite-state.ts create mode 100644 viewer/tests/composite.test.ts diff --git a/viewer/README.md b/viewer/README.md index 3ae4a00..db03ffa 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -86,3 +86,15 @@ Settings belong to the numerical species or signal channel within the current da ## Channel labels Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2 and v3, while writers emit v3. See the [authoring guide](../docs/models/channel-labels.md) and [scene v3 schema](../docs/formats/scene-v3.md). + +## Composite species colors + +Choose Species composite to display several intracellular channels together. The first two available channels initially use red and green and are enabled; additional channels start disabled. Enable or disable each channel with its checkbox. Display settings exposes its tint (a six-digit sRGB hexadecimal color), the same Automatic/Fixed range editor used by single-species coloring, and controls for reordering the list. Tint or range changes apply when submitted, and invalid edits preserve the active value. + +For each cell, every enabled channel is independently normalized with its selected range. Tints are decoded from sRGB into linear RGB; normalized intensity multiplies each linear tint, the contributions are added, and each summed component is clipped to one. The result is encoded back to sRGB for the existing renderer interface, which converts its instance colors to linear RGB. Channel-list order has no effect on the result. Full red and full green therefore produce yellow. The legend lists enabled channel names, tints, and active bounds. Three.js uses a small approximation in its sRGB encoding function; conversion tests bound the resulting error below 0.00001, well below an 8-bit color step. + +When all channels are disabled or unavailable, cells use neutral gray and the legend says no channels are active. With active channels and fixed zero-based bounds, zero intensity is black. Automatic constant data uses the same midpoint convention as single-species coloring, including a constant zero field; choose fixed zero-based bounds when zero should mean no displayed contribution. These colors are a presentation mapping, not calibrated fluorescence measurements. Lighting, tone mapping, and selection highlighting can further affect the final pixel appearance. + +Tints, visibility, list order, and ranges remain associated with numerical channel identity when labels change or data temporarily disappears. Single-species and composite views share each species channel's range. Same-model reset and frame seeking retain settings; opening another dataset restores defaults. The cell inspector, picking, selection highlights, and lineage values continue using the original cell state. + +`browser/composite-species.mjs` exercises red-only, green-only, co-expressing, and zero-expression cells in a moving colony, verifies the rendered instance colors, and checks controls, ordering, picking, highlighting, lineage, and dataset transitions. It uses a Vite server on port 4319 (or `VIEWER_URL`) and the same Playwright/evidence environment variables as the other browser checks. diff --git a/viewer/browser/composite-species.mjs b/viewer/browser/composite-species.mjs new file mode 100644 index 0000000..321b3b1 --- /dev/null +++ b/viewer/browser/composite-species.mjs @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4319"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-composite-species"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 2, + channel_metadata: { + species: ["Red reporter", "Green reporter"], + signals: [], + }, + cells: [ + [1, 0], + [0, 1], + [1, 1], + [0, 0], + ].map((species, slot) => ({ + id: String(slot + 1), + parent_id: slot === 2 ? "1" : null, + slot, + position: [slot % 2 === 0 ? -2 : 2, slot < 2 ? -2 : 2, 0.6], + direction: [1, 0, 0], + length: 1.5, + radius: 0.45, + growth_rate: 0.1, + cell_type: slot, + fixed: false, + species, + })), + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: null, +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 3, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); +} +const row = (index) => + page.locator(`.composite-channel[data-channel="${index}"]`); +async function fixed(index, minimum, maximum) { + const group = row(index); + if (!(await group.locator("details").evaluate((details) => details.open))) + await group.locator("summary").click(); + await group + .getByRole("combobox", { name: "Species range mode", exact: true }) + .selectOption("fixed"); + await group + .getByRole("textbox", { name: "Species minimum", exact: true }) + .fill(String(minimum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill(String(maximum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); +} +async function colors() { + return page.evaluate(() => + Array.from( + globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [], + ), + ); +} +function close(actual, expected, tolerance = 1e-5) { + assert.equal(actual.length, expected.length); + actual.forEach((value, index) => + assert.ok( + Math.abs(value - expected[index]) < tolerance, + `component ${index}: ${value} != ${expected[index]}`, + ), + ); +} +async function clickCell(index) { + const position = await page.evaluate((index) => { + const v = globalThis.__testViewer; + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .fromArray(v.cells[index].position) + .project(v.camera); + const bounds = v.renderer.domElement.getBoundingClientRect(); + return { + x: bounds.x + ((point.x + 1) * bounds.width) / 2, + y: bounds.y + ((1 - point.y) * bounds.height) / 2, + }; + }, index); + await page.mouse.click(position.x, position.y); +} +try { + await page.goto(`${url}/?token=composite-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#color-mode").selectOption("composite"); + await fixed(0, 0, 1); + await fixed(1, 0, 1); + const redGreenYellowBlack = [1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0]; + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#composite-legend")).toContainText( + "Red reporter · #ff0000 · Fixed 0 to 1", + ); + await expect(page.locator("#composite-legend")).toContainText( + "Green reporter · #00ff00 · Fixed 0 to 1", + ); + await row(0).locator("summary").click(); + await row(1).locator("summary").click(); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position + .sub(v.controls.target) + .multiplyScalar(1.25) + .add(v.controls.target); + v.controls.update(); + }); + await page.screenshot({ path: `${evidence}/expression-patterns.png` }); + await clickCell(2); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect( + page + .locator("#cell-details > div") + .filter({ has: page.locator("dt", { hasText: "Parent" }) }) + .locator("dd"), + ).toHaveText("1"); + await expect(page.locator("#species-values code")).toHaveText(["1", "1"]); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + await page.screenshot({ path: `${evidence}/selected-patterns.png` }); + + for (let time = 1; time <= 3; time += 1) { + await send({ + ...base, + time, + cells: base.cells.map((cell) => ({ + ...cell, + position: [ + cell.position[0] + time / 3, + cell.position[1] - time / 4, + cell.position[2], + ], + })), + }); + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + } + await page.screenshot({ path: `${evidence}/moving-patterns.png` }); + await row(1) + .getByRole("checkbox", { name: "Enable Green reporter" }) + .uncheck(); + close(await colors(), [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); + await row(0).getByRole("checkbox", { name: "Enable Red reporter" }).uncheck(); + await expect(page.locator("#composite-legend")).toContainText( + "No channels active", + ); + const neutral = await page.evaluate(async () => { + const { COMPOSITE_NEUTRAL } = await import("/src/composite-color.ts"); + const { Color, SRGBColorSpace } = + await import("/node_modules/.vite/deps/three.js"); + const value = new Color().setRGB(...COMPOSITE_NEUTRAL, SRGBColorSpace); + return [value.r, value.g, value.b]; + }); + close(await colors(), [...neutral, ...neutral, ...neutral, ...neutral]); + await row(0).getByRole("checkbox").check(); + await row(1).getByRole("checkbox").check(); + close(await colors(), redGreenYellowBlack); + await row(1).locator("summary").click(); + const beforeOrder = await colors(); + await row(1).getByRole("button", { name: "Move Green reporter up" }).click(); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + assert.deepEqual(await colors(), beforeOrder); + await expect( + row(1).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + + await fixed(0, 0, 2); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("0"); + await expect( + page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("2"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill("3"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); + await page.locator("#color-mode").selectOption("composite"); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("3"); + await fixed(0, 0, 1); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#808080"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 4, + cells: [{ ...base.cells[0], species: [0.5, 0] }, ...base.cells.slice(1)], + }); + const decode = (value) => ((value + 0.055) / 1.055) ** 2.4; + close((await colors()).slice(0, 3), Array(3).fill(0.5 * decode(128 / 255))); + const validTintColors = await colors(); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("invalid"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await expect( + row(0).locator(".composite-tint-form [role=status]"), + ).toContainText("six-digit"); + assert.deepEqual(await colors(), validTintColors); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#ff0000"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 5, + cells: [{ ...base.cells[0], species: [-1, 2] }, ...base.cells.slice(1)], + }); + close((await colors()).slice(0, 3), [0, 1, 0]); + await clickCell(0); + await expect(page.locator("#species-values code")).toHaveText(["-1", "2"]); + + await row(1).getByRole("checkbox").uncheck(); + await send({ + ...base, + time: 6, + species_count: 0, + channel_metadata: { species: [], signals: [] }, + cells: [], + }); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await send(base); // Backward seek / live reset contract. + await expect(page.locator("#color-mode")).toHaveValue("composite"); + await expect(row(1).getByRole("checkbox")).not.toBeChecked(); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + + await page.goto(url); + const open = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "named.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await open(base); + await page.locator("#color-mode").selectOption("composite"); + await row(0).getByRole("checkbox").uncheck(); + await fixed(1, 0, 20); + await open({ ...base, time: 99 }); + await page.locator("#color-mode").selectOption("composite"); + await expect(row(0).getByRole("checkbox")).toBeChecked(); + await row(1).locator("summary").click(); + await expect( + row(1).getByRole("combobox", { name: "Species range mode", exact: true }), + ).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + evidence, + coverage: + "linear RGB/colorspace, coexpression/motion, disable/neutral, reorder, shared ranges, tint validation, clipping, picking/highlight/lineage, absent channels/reset/new dataset", + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index e5f2322..e0bfce9 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -58,6 +58,7 @@

Cells

@@ -67,6 +68,7 @@

Cells

+ +