Core concepts¶
Simulation
├── Clock current time (float), optional calendar anchoring
├── Event queue binary heap ordered by (time, priority, scheduling order)
├── Events things that happen at an instant (callbacks or Event subclasses)
├── Processes generator functions that wait for time, resources, items, signals
├── Entities customers, jobs, patients, aircraft ... with state and history
├── Resources pools of capacity (servers, machines, gates) with waiting lines
├── Queues buffers with disciplines and optional capacity
├── Randomness seeded, named random streams + distributions
├── Metrics tallies, time-weighted gauges, counters, histograms
└── Event log optional structured trace (JSON / CSV / Parquet)
Simulation and time¶
from datetime import datetime, timedelta
from simulsi import Simulation
sim = Simulation(seed=1) # time is a float in abstract units
cal = Simulation(seed=1, epoch=datetime(2026, 3, 1, 6, 0), time_unit=timedelta(minutes=1))
cal.schedule(time=datetime(2026, 3, 1, 7, 30), event_type="shift_change")
cal.schedule(delay=timedelta(hours=2), event_type="inspection")
cal.run(until=timedelta(hours=3))
print(cal.now, cal.now_datetime) # 180.0 2026-03-01 09:00:00
run(until=T) executes every event with timestamp <= T and leaves the
clock exactly at T, even if nothing happened near the end; run() without
until stops when no events remain; sim.stop() ends a run after the
current event. Runs can be resumed by calling run again with a later until.
Events¶
from simulsi import Event, Priority, Simulation
sim = Simulation(seed=1)
log = []
sim.schedule(time=5, event_type="ping", callback=lambda s, e: log.append(("ping", s.now)))
class Arrival(Event):
def __init__(self, n):
super().__init__("arrival", {"n": n})
def execute(self, sim):
log.append(("arrival", sim.now, self.payload["n"]))
if self.payload["n"] < 2:
sim.schedule(Arrival(self.payload["n"] + 1), delay=3)
first = sim.schedule(Arrival(0), time=1)
urgent = sim.schedule(time=5, event_type="urgent", priority=Priority.URGENT,
callback=lambda s, e: log.append(("urgent", s.now)))
late = sim.schedule(time=50, event_type="cancel-me")
sim.cancel(late)
sim.run()
print(log) # urgent runs before ping at t=5
Every event has event_id, timestamp, priority, event_type, payload,
callback and status (created, scheduled, cancelled, executed).
reschedule(event, time=...) moves a pending (or cancelled) event. Ordering is
fully deterministic: time, then priority (lower first), then scheduling order.
Processes¶
A process is a generator. What it yields decides what it waits for:
| yield | waits for | resumes with |
|---|---|---|
a number / timedelta |
that much time | None |
sim.timeout(d, value) |
d time units |
value |
sim.request(resource) |
a unit of the resource | the Request |
queue.get() / queue.put(x) |
an item / free space | the item / None |
sim.signal() |
someone calling signal.succeed(v) |
v |
sim.all_of(a, b) / sim.any_of(a, b) |
all / any of them | {waitable: value} |
another Process |
its completion | its return value |
from simulsi import Interrupt, Simulation
sim = Simulation(seed=1)
machine = sim.resource("machine", capacity=1)
def job(sim):
try:
yield from sim.use(machine, 10) # acquire, hold 10, release
except Interrupt as stop:
print(f"t={sim.now}: interrupted ({stop.cause}); unit released automatically")
p = sim.process(job(sim), name="job-1")
sim.call_at(lambda: p.interrupt("power cut"), time=4)
sim.run()
If a process raises and nobody waits on it, the exception propagates out of
sim.run() - model bugs are never swallowed.
Entities¶
from simulsi import Simulation
sim = Simulation(seed=1)
c = sim.entity("customer", priority=2, service_type="premium") # id "customer-1"
c.set_state("waiting")
sim.run(until=3)
c.set_state("served", by="teller-2")
sim.run(until=5)
sim.dispose(c) # records time in system
print(c.id, c["priority"], c.time_in_states())
print(sim.run().metrics["entity.customer.time_in_system.mean"])
Entities have an id (reproducible per simulation), created_at, state,
free-form attributes and a history of state changes. Pass entity= to
sim.process or sim.request so logs and flow graphs know who did what.
Resources¶
from simulsi import Simulation
sim = Simulation(seed=1)
doctors = sim.resource("doctor", capacity=2, discipline="priority")
def patient(sim, acuity, arrive):
yield arrive
req = yield sim.request(doctors, priority=acuity, patience=30)
if not req.granted: # reneged after 30 time units
return
yield 20
sim.release(req)
for i, acuity in enumerate([3, 3, 1, 2, 3]):
sim.process(patient(sim, acuity, i))
m = sim.run(until=200).metrics
print(m["resource.doctor.utilization"], m["resource.doctor.wait.mean"])
- Disciplines:
"fifo"(default),"lifo","priority"(lower value first, FIFO among equals) or any key function. - Reneging:
patience=withdraws an ungranted request after a while. - Capacity changes and downtime:
set_capacity(n),fail(units, interrupt=False),repair(units). Units in use finish normally unlessinterrupt=True. - Statistics (prefix
resource.<name>.):utilization(busy / capacity, time-weighted),availability,mean_busy,mean_queue_length,max_queue_length,wait.mean/max/p95/total,requests,grants,releases,reneges,failures,preemptions,throughput,busy_time,capacity_time. - Invariants (property-tested):
0 <= in_use <= capacity;requests = grants + waiting + reneged;grants = releases + preemptions + in_use.
Preemption¶
sim.resource(name, capacity, discipline="priority", preemptive=True) lets
a request that finds no free unit evict the least important user, but only if
the request is strictly more important (lower value). The evicted process gets
an Interrupt whose cause is a Preempted(resource, by, usage_since). Its
unit is already released (calling release on it is a no-op), so it usually
re-requests the work that remains:
from simulsi import Interrupt, Preempted, Simulation
sim = Simulation(seed=1)
or_room = sim.resource("theatre", 1, discipline="priority", preemptive=True)
def operation(sim, name, priority, arrive, work):
yield arrive
while work > 0:
req = yield sim.request(or_room, priority=priority)
start = sim.now
try:
yield work
work = 0
sim.release(req)
except Interrupt as stop:
assert isinstance(stop.cause, Preempted)
work -= sim.now - start
print(f"t={sim.now}: {name} bumped, {work} left")
print(f"t={sim.now}: {name} done")
sim.process(operation(sim, "elective", 5, 0, 6))
sim.process(operation(sim, "emergency", 1, 2, 3))
sim.run()
Queues¶
Queue is an ordered buffer between process steps, with optional capacity
(blocking put) and blocking get:
from simulsi import Simulation
sim = Simulation(seed=1)
orders = sim.queue("orders", discipline="priority", capacity=10)
def producer(sim):
for i in range(5):
yield orders.put(f"order-{i}", priority=5 - i)
yield 1
def consumer(sim):
yield 10
while True:
item = yield orders.get()
print(sim.now, item)
yield 2
sim.process(producer(sim))
sim.process(consumer(sim))
print(sim.run(until=30).metrics["queue.orders.wait.mean"])
Metrics (prefix queue.<name>.): mean_length, max_length, wait.mean/max,
puts, gets, removed, throughput, length_final. Items never vanish:
puts == gets + removed + length.
orders.get(filter=lambda o: o.startswith("rush")) takes the first matching
item in discipline order (waiting until one arrives); a filtered get that is
waiting does not hold up gets for other items. try_get(filter) is the
non-blocking form.
Containers¶
A Container holds a level (fuel, stock, cash, parts in a bin) rather than
distinct items. get(amount) waits until enough is there; put(amount) waits
until there is room. Waiters are served strictly in arrival order.
from simulsi import Simulation
sim = Simulation(seed=2)
stock = sim.container("stock", capacity=200, init=120)
def demand(sim):
rng = sim.stream("demand")
while True:
yield 1.0
yield stock.get(rng.integers(5, 15))
def replenish(sim):
while True:
yield sim.wait_until(lambda: stock.level < 40) # reorder point
yield 3.0 # lead time
yield stock.put(150)
sim.process(demand(sim))
sim.process(replenish(sim))
m = sim.run(until=500).metrics
print(m["container.stock.level.mean"], m["container.stock.stockouts"])
Metrics (prefix container.<name>.): level.mean/min/max, level_final,
put_amount, get_amount, stockouts (gets that had to wait),
get_wait.mean, put_wait.mean.
sim.wait_until(predicate) works with any model state: the predicate is
checked after every event until it is true, and the yield returns the time
it became true. Keep predicates cheap.
When the predicate only depends on some resources, containers or queues,
list them: sim.wait_until(lambda: stock.level < 40, on=[stock]). The
predicate is then re-checked only when one of them changes, which makes
many pending conditions cheap (about 8x faster in a model with hundreds of
waiting processes).
Continuous levels¶
A Level is a quantity that changes at a rate between events: a fuel
tank, a battery, a reservoir. Processes change the rate or add to the
value, and wait for thresholds; crossing times are computed exactly, not
by stepping.
from simulsi import Level, Simulation
sim = Simulation()
battery = Level(sim, "battery", init=80, rate=-2.0, low=0, high=100) # drains 2 per minute
def charger(sim):
while True:
yield battery.when(20, "down")
battery.set_rate(10.0) # plug in
yield battery.when(100, "up")
battery.set_rate(-2.0) # back to work
sim.process(charger(sim))
m = sim.run(until=600).metrics
print(m["level.battery.mean"], m["level.battery.min"])
when(threshold, "up" | "down" | "any")triggers with the time the level gets there, or at once if it already is.- The level stays within
[low, high]: at a bound it stops (ratereads 0) until the rate turns. add(amount)jumps (a delivery) and returns what fitted.- Metrics
level.<name>.mean(exact time average),final,min,max.
Movement on networks¶
simulsi.spatial gives travel times on a graph of places: roads between
depots, aisles in a warehouse, routes between airports.
from simulsi import Simulation
from simulsi.spatial import Network, grid_network
roads = Network([("depot", "A", 12), ("A", "B", 7), ("depot", "B", 25)], name="roads")
print(roads.travel_time("depot", "B"), roads.path("depot", "B")) # 19.0 ['depot', 'A', 'B']
sim = Simulation()
def van(sim):
here = "depot"
for stop in ["A", "B", "depot"]:
yield from roads.trip(sim, here, stop)
here = stop
sim.process(van(sim))
print(sim.run().metrics["network.roads.trip_time.mean"])
Network(links, directed=False),add_link,remove_link(a closed road),travel_time,path,nearest(origin, candidates)andmatrix(). Shortest paths use Dijkstra and are cached.grid_network(width, height, diagonal=False, blocked=[...])builds a grid of(x, y)cells with walls.Network.from_coordinates(points, speed=..., metric="haversine")links places by straight-line or great-circle distance.yield from net.trip(sim, a, b, on_leg=...)moves edge by edge, so the clock advances by the travel time;on_leg(from, to, time)can log or animate positions.
Schedules and time-varying arrivals¶
from simulsi import Simulation
from simulsi.processes import PiecewiseRate, arrivals, capacity_schedule
sim = Simulation(seed=4)
agents = sim.resource("agents", 2)
# shifts in minutes: 2 agents at night, 6 from 08:00, 4 from 17:00, every day
capacity_schedule(sim, agents, [(0, 2), (480, 6), (1020, 4)], period=1440)
# calls per minute by time of day
calls = PiecewiseRate([(0, 0.05), (480, 0.6), (720, 0.9), (1020, 0.3)], period=1440)
def call(sim, i):
yield from sim.use(agents, sim.stream("talk").exponential(6.0))
arrivals(sim, calls, call, stream="calls")
m = sim.run(until=7 * 1440).metrics
print(m["resource.agents.utilization"], m["resource.agents.wait.mean"])
capacity_schedule(sim, resource, [(time, capacity), ...], period=None)changes capacity at the given offsets. Lowering capacity does not interrupt work in progress; the new limit applies to later grants.arrivals(sim, rate, spawn, stream=..., limit=None, until=None)callsspawn(sim, i)at every arrival and starts it as a process if it returns a generator.ratecan be a number (Poisson process), aPiecewiseRate(exact, one random draw per arrival) or any functionrate(t)together withmax_rate=(generated by thinning).
Batching, split/join and routing¶
from simulsi import Simulation
from simulsi.processes import Router, batch, join, shortest_queue, split
sim = Simulation(seed=5)
parts = sim.queue("parts")
lines = [sim.resource("line-a", 1), sim.resource("line-b", 1)]
inspection = Router(sim, "inspection", {"pass": 0.9, "rework": 0.1})
def supplier(sim):
for i in range(12):
yield 0.5
yield parts.put(i)
def finish(sim, line):
yield from sim.use(line, 2.0)
return inspection.choose()
def assembler(sim):
while True:
kit = yield from batch(parts, 4, timeout=5.0) # up to 4 parts
if not kit:
return
results = yield from join(split(sim, [finish(sim, shortest_queue(lines))
for _ in range(2)]))
print(sim.now, kit, results)
sim.process(supplier(sim))
sim.process(assembler(sim))
m = sim.run().metrics
print(m["route.inspection.pass"], m["route.inspection.rework"])
batch(queue, size, timeout=None)collects items (yield from); with a timeout it returns whatever has arrived by then.split(sim, generators)starts parallel processes andjoin(processes)waits for all of them and returns their return values in order (a failure in any of them is raised in the joiner).Router(sim, name, {route: probability})draws from its own stream (route:<name>) and countsroute.<name>.<route>.shortest_queue(resources)picks the resource with the fewest waiting.
Randomness¶
from simulsi.randomness import (Categorical, Empirical, Exponential, LogNormal, Normal,
RandomStream, Triangular, from_spec)
rng = RandomStream(seed=42)
service = LogNormal.from_moments(mean=5.0, std=2.0)
print(service.sample(rng), service.mean, service.variance)
print(Categorical({"economy": 0.8, "business": 0.2}).sample(rng))
print(Empirical([3.1, 4.7, 5.2, 9.9]).sample_n(rng, 3))
print(from_spec({"distribution": "triangular", "low": 1, "mode": 2, "high": 6}))
Available: Constant, Uniform, Normal, Exponential (rate or mean),
Poisson, Binomial, Gamma, LogNormal (+ from_moments), Triangular,
Empirical (optionally weighted), Categorical, and Custom(fn).
Parameters are validated eagerly (e.g. probabilities must be in [0, 1] and
sum to 1). RandomStream also has uniform, normal, exponential,
poisson, binomial, gamma, lognormal, triangular, bernoulli,
choice, integers and shuffle.
In a simulation use sim.stream("name"): streams are keyed by name, so the
arrival stream is the same whatever else the model draws. This is what makes
common-random-number comparisons effective.
Input data: fitting and traces¶
Input distributions should come from data. fit_distribution fits the
exponential, gamma, lognormal, normal, uniform, triangular and Poisson
families by maximum likelihood and ranks them:
import numpy as np
from simulsi.randomness import fit_distribution
observed = np.random.default_rng(7).gamma(2.5, 1.6, size=400) # e.g. service times
report = fit_distribution(observed)
print(report.format()) # AIC, BIC, KS statistic and p-value per family
service = report.best.distribution # a simulsi Gamma, ready to sample
print(service, report.best.distribution.to_spec())
- Families whose support does not fit the data (gamma or lognormal with
zeros, Poisson with fractions) are listed in
report.skipped. - KS p-values use parameters estimated from the same data, so they are
optimistic. With fewer than about 30 observations, or when the best family
is still rejected, use
Empirical(data)instead. report.plot()overlays the top fits on a histogram.simulsi fit data.csv --column servicedoes the same from the command line and prints a spec forexperiment.yaml.
Trace-driven runs replay history instead of sampling. With recorded arrivals and service times, a valid model should reproduce the recorded waits, which makes this a strong validation check:
from simulsi.processes import TraceReplay, load_trace, trace_arrivals
history = load_trace("arrivals.csv") # rows like {"time": 3.5, "service": 4.1}
def job(sim, record):
yield from sim.use(server, record["service"])
trace_arrivals(sim, history, job) # one arrival per row, at row["time"]
repair_times = TraceReplay([12.0, 30.5, 8.2], cycle=True)
repair_times.sample() # next recorded value
Metrics¶
from simulsi import Simulation
sim = Simulation(seed=1)
m = sim.metrics
m.observe("wait_time", 4.2) # one observation (tally: mean, std, quantiles)
m.record("work_in_progress", 7) # a level that holds until changed (time-weighted)
m.increment("orders_shipped") # a count (also reported as a rate per time unit)
m.histogram("order_size", 3, bins=[0, 2, 5, 10])
sim.on_finish(lambda s: s.metrics.set("cost", 1234.5)) # a final value
r = sim.run(until=10)
print(r.metrics["wait_time.mean"], r.metrics["orders_shipped.rate"], r.metrics["cost"])
print(r.details["metrics"]["tallies"]["order_size"]["histogram"])
sim.warmup(t) (or Model(warmup=...)) discards statistics collected before
t so steady-state estimates are not biased by an empty-and-idle start.
Failures and disruptions¶
from simulsi import Simulation
from simulsi.processes import FailureProcess, RecoveryProcess, capacity_reduction
from simulsi.randomness import Exponential, Triangular
sim = Simulation(seed=3)
press = sim.resource("press", capacity=2)
crew = sim.resource("crew", capacity=1)
FailureProcess(sim, press, time_to_failure=Exponential(mean=300),
recovery=RecoveryProcess(Triangular(10, 20, 60), crew=crew), units=1)
capacity_reduction(sim, press, at=500, duration=60, capacity=1) # planned maintenance
m = sim.run(until=2000).metrics
print(m["failure.press.count"], m["resource.press.availability"])
ScheduledDisruption(sim, at=..., duration=..., apply=..., revert=...) covers
anything else - a demand spike, a weather window, a closed route.
State snapshots, event logs and graphs¶
from simulsi.introspection import model_graph
from simulsi.models import mmc
sim = mmc.create({"servers": 2}, seed=1, trace=True)
sim.run(until=50)
snap = sim.snapshot() # time, pending events, entities, resources, queues, metrics
print(snap["now"], snap["resources"]["server"])
sim.log.export("trace.csv") # or .json / .parquet
graphs = model_graph(mmc, seed=1, duration=50)
print(graphs["inventory"])
The flow graph is built from the event log of a traced run (entities moving between arrival, waiting lines, resources, queues and exit) and renders to Mermaid or Graphviz DOT; the state graph shows each entity type's state machine. Both are observed from a run - SimulSI models are ordinary Python, so the structure is not inferred from source code.
Model validation¶
from simulsi.models import mmc
from simulsi.validation import validate_model
report = validate_model(mmc, {"servers": 0})
print(report.format()) # parameter error: below minimum 1
print(validate_model(mmc, {"servers": 2}).ok)
Checks: parameter types, bounds and required values; build errors; zero
capacities; exceptions during a smoke run (e.g. events scheduled in the past);
models that schedule nothing; processes that finish while holding a resource;
processes blocked with no pending events (possible deadlock); and, given
expected_states, states never reached or never declared. A clean report is
evidence, not proof.