Running simulations and experiments on Petri nets
Petrinaut ships with a headless simulation engine, @hashintel/petrinaut-core, that powers in-editor simulator but is suitable for using in its own right in other tools.
createSimulation initializes a simulation against an immutable SDCPN snapshot and resolves a live Simulation handle. The handle exposes readable stores for status and frames, an event stream, and run / pause / reset controls. Frame computation happens off the main thread in a Worker; frames stream back to the consumer as they're computed.
import { createSimulation } from "@hashintel/petrinaut-core";
const simulation = await createSimulation({
sdcpn,
initialMarking,
parameterValues,
seed: 42,
dt: 0.01,
maxTime: null,
createWorker: () =>
new Worker(new URL("./simulation.worker.js", import.meta.url)),
});
Key things to know:
Initializing → Ready → Running ⇄ Paused → Complete or Error. The full state diagram lives in the simulation README.seed, dt, and configuration, a simulation will produce byte-identical frames every time — important for reproducible experiments and bug reports.backpressure: { maxFramesAhead, batchSize } and simulation.ack(frameNumber) let you tune how far the engine gets ahead of playback.For interactive debugging, a single run is usually what you want. Set the initial marking — token counts for untyped places, individual records for colored places — override any scenario parameters you want to vary, and press Play (or call simulation.run() in headless usage).
Each frame proceeds in two phases:
dt). Every place with a differential equation has its token attributes updated.A run stops at the configured maxTime, or when the engine reports deadlock: no transition fired this step and no transition is structurally enabled. Deadlock means there is no possible next step, not just that nothing happened to fire this frame; rate-throttled transitions waiting for their next stochastic firing don't count.
For statistical questions — "what's the distribution of throughput?" — a single run isn't enough. createMonteCarloSimulator orchestrates many independent runs against the same SDCPN with different seeds, parameter sweeps, or initial markings:
import { createMonteCarloSimulator } from "@hashintel/petrinaut-core";
const simulator = createMonteCarloSimulator({
sdcpn,
runs: [
/* MonteCarloRunConfig per run */
],
metrics,
});
await simulator.runUntilComplete();
Implementation notes that matter when you're scaling these up:
ArrayBuffers — currentFrame and nextFrame — that the engine swaps between each step. The simulator does not retain per-frame history; only the latest frame is observable. That makes it practical to run thousands of long simulations on a single machine without exhausting heap.A metric is a user-authored TypeScript snippet evaluated against the simulation's state object each frame.
return (
state.places.Infected.count /
(state.places.Susceptible.count +
state.places.Infected.count +
state.places.Recovered.count)
);
Metrics drive both the in-editor timeline charts (one value per frame, plotted over time) and Monte Carlo aggregates.
Metrics are compiled with compileMetric and are subject to the same diagnostics surface as the rest of the net's code, so authoring errors show up in the editor alongside lambda and kernel diagnostics.
Two visualization surfaces are wired up automatically:
visualizerCode attached. They re-render live as the underlying tokens change, so a place can display anything from a single number to a domain-specific scene (orbits, queues, grids, gauges). See Custom place visualizers in Basic Features for the authoring model.Previous