Logging
Goal: trace simulation events, debug behavior, and analyze what happened during a run.
env.debug(), env.info(), env.warning() and env.error() emit log events (class LogEvent in
simulatte.events) on the environment's event bus, stamped with the simulation time. Log sinks
(simulatte.logsinks) subscribe to them and write them out. Every Environment attaches default sinks:
- a text sink writing to stderr, or to
log_file, in text or JSON format - an in-memory history for post-run analysis (
env.log_history) - an SQLite database when
log_db_pathis given (env.log_db)
Each sink has its own level and component filters.
1) Basic usage
from simulatte.environment import Environment
env = Environment(log_level="DEBUG")
env.run(until=100)
env.info("Simulation checkpoint", component="Main")
env.debug("Detailed info", component="Server", job_id="J1")
env.warning("Queue getting long", component="Router", queue_size=15)
env.error("Timeout exceeded", component="AGV")
Output (to stderr by default):
0.0d 00:01:40.00 | INFO | Main | Simulation checkpoint
0.0d 00:01:40.00 | DEBUG | Server | Detailed info
0.0d 00:01:40.00 | WARNING | Router | Queue getting long
0.0d 00:01:40.00 | ERROR | AGV | Timeout exceeded
2) Log levels
Each environment has its own level, applied to its default sinks; the default is INFO:
quiet = Environment(log_level="WARNING") # Only WARNING and ERROR
verbose = Environment(log_level="DEBUG") # Everything, including every domain event
At DEBUG the text, JSON and SQLite sinks also write every domain event (see section 3) as a DEBUG record, with
the namespace of its type as component:
0.0d 00:00:0.00 | DEBUG | job | job.queued job='job-0' server='wc-0' priority=0.0 queue_length=1
This makes every emitting site build its event, so a DEBUG run is slower. At any other level, and for the
history, sinks listen to log events only and domain events are not built for them.
3) Built-in events and component logs
The production components (Server, ShopFloor, PreShopPool, Router) write no log messages. They emit typed
events on env.bus; subscribe before the run to collect them:
from simulatte.environment import Environment
from simulatte.server import JobQueued
from simulatte.shopfloor import JobFinished
env = Environment()
seen = []
env.bus.subscribe(seen.append, (JobQueued, JobFinished)) # or "*" for every domain event
# ... build the system and run ...
for event in seen:
print(event.t, event.type_name, event.job)
Each event has t (simulation time), seq (emission order) and type_name, plus the payload fields listed below.
Ids in payloads are entity ids: job-0, job-1, ... for jobs, and the name= given to a component or its generated
id. The builders name their entities wc-0, wc-1, ..., shopfloor, router and psp, each with the optional
prefix=.
The intralogistics components (FleetCoordinator, AGVs, traffic managers, warehouses, charging stations and
parking areas) emit typed events as well. The fleet coordinator keeps its warnings and errors as log messages
(component="FleetCoordinator"); query them from the in-memory history after the run:
# ... run ...
fleet_problems = env.log_history.query(component="FleetCoordinator")
for e in fleet_problems:
print(e.t, e.level, e.message)
Catalog
Server
Classes in simulatte.server:
| Event type | Class | Payload |
|---|---|---|
job.queued |
JobQueued |
job, server, priority, queue_length (waiting requests when the job joins, itself included) |
job.granted |
JobGranted |
job, server |
job.queue_left |
JobQueueLeft |
job, server, reason (cancelled) |
job.released |
JobReleased |
job, server |
server.queue_reordered |
ServerQueueReordered |
server |
server.work_credited |
ServerWorkCredited |
server, job, processing_time (emitted by Server.process_job after it adds the processing time to worked_time) |
ShopFloor
Classes in simulatte.shopfloor, in the order they occur for each job:
| Event type | Class | Payload |
|---|---|---|
shopfloor.entered |
ShopFloorEntered |
job, shopfloor |
operation.started |
OperationStarted |
job, server, op_index, processing_time, planned_end (after the before-operation hooks and material delivery) |
operation.completed |
OperationCompleted |
job, server, op_index, processing_time |
shopfloor.wip_updated |
ShopFloorWipUpdated |
shopfloor, changes (server id to its new WIP) |
job.finished |
JobFinished |
job, shopfloor, makespan, lateness, total_queue_time |
After job.finished, the metrics collector and the on_job_finished callbacks, the job is retired:
entity.retired (class EntityRetired in simulatte.entities) removes it from env.entities.live().
shop_floor.jobs_done still holds it.
PreShopPool
Classes in simulatte.psp:
| Event type | Class | Payload |
|---|---|---|
psp.entered |
PspEntered |
job, psp, position |
psp.exited |
PspExited |
job, psp, reason (released, postponed or removed) |
Release policies
Class in simulatte.policies. ConWIP, ContinuousRelease, Draco, LumsCor, Slar and SlarLimit emit a
decision event for each action they take on a job (Draco can emit two for one job, see below); it carries no state
change (the psp.exited and shopfloor.entered events that follow do):
| Event type | Class | Payload |
|---|---|---|
policy.decision |
PolicyDecision |
policy (the policy's class name), job, action (release, postpone or force_pin) |
A postpone is a release after a short delay: the job leaves the pool at once (psp.exited with reason postponed,
location transit) and enters the shop floor 0.001 time units later. Draco emits force_pin when it pins the winner
at the server's queue head, followed by release when the winner came from the pool.
Router
The router has no events of its own. A new job appears as entity.created (class EntityCreated in
simulatte.entities, with kind == "job"), followed in the same instant by psp.entered or shopfloor.entered.
FleetCoordinator, AGVs and transfer orders
Classes in simulatte.intralogistics.events. The coordinator keeps its warnings and errors as log messages
(component="FleetCoordinator", for example "No path from ..."); everything else is an event:
| Event type | Class | Payload |
|---|---|---|
fleet.agv_added |
FleetAgvAdded |
fleet, agv (at coordinator construction, per AGV) |
fleet.pending_changed |
FleetPendingChanged |
fleet, order, op (added or removed), index |
order.status_changed |
OrderStatusChanged |
order, status, previous, reason (for example dispatched, picked, delivered, cancelled, travel_failed) |
order.assigned |
OrderAssigned |
order, agv |
order.unassigned |
OrderUnassigned |
order, agv (re-queue after an interruption, mission cleanup) |
agv.state_changed |
AgvStateChanged |
agv, state, previous (every AGV.transition_to) |
agv.placed |
AgvPlaced |
agv, node, previous (created at a bound node, or current_node assigned directly) |
agv.move_started |
AgvMoveStarted |
agv, from_node, to_node, t_end, motion (the speed profile's motion description), loaded |
agv.move_ended |
AgvMoveEnded |
agv, node, battery |
agv.move_interrupted |
AgvMoveInterrupted |
agv, node (the node the AGV stays at), reason |
agv.load_changed |
AgvLoadChanged |
agv, load (SKU id to quantity, or null) |
agv.battery_changed |
AgvBatteryChanged |
agv, battery (recharge or battery swap) |
agv.stranded |
AgvStranded |
agv, node, reason (no_reachable_charger or insufficient_after_charging) |
An order retires (entity.retired) when it reaches COMPLETED, CANCELLED or FAILED, after its mission cleanup.
Traffic, warehouses, charging stations and parking areas
Classes in simulatte.intralogistics.events:
| Event type | Class | Payload |
|---|---|---|
traffic.reserved |
TrafficReserved |
agv, node (initial placement, or enter_node granted) |
traffic.released |
TrafficReleased |
agv, node (leave_node) |
traffic.wait_started |
TrafficWaitStarted |
agv, node (the next node the AGV means to enter), reason (node_occupied, path_delay or deadlock_backoff) |
traffic.wait_ended |
TrafficWaitEnded |
agv, node, reason (granted, cancelled, interrupted or elapsed) |
warehouse.inventory_changed |
WarehouseInventoryChanged |
warehouse, sku, level, delta (positive for a put, negative for a pick) |
warehouse.slot_changed |
WarehouseSlotChanged |
warehouse, in_use (a pick or put slot acquired or released) |
charging.started |
ChargingStarted |
station, agv, mode (recharge or swap; the AGV was granted a slot) |
charging.ended |
ChargingEnded |
station, agv, mode (the slot was released) |
charging.pool_changed |
ChargingPoolChanged |
station, swap_pool (a swap took a charged battery, or one was returned) |
parking.entered |
ParkingEntered |
area, agv (ParkingArea.enter) |
parking.left |
ParkingLeft |
area, agv (ParkingArea.leave) |
Only the ResourceBasedTrafficManager reserves nodes; with the default free traffic, several AGVs share a node and
no reservation events occur. FleetCoordinator never calls ParkingArea.enter or leave itself.
MaterialCoordinator
MaterialCoordinator is a protocol used by ShopFloor; it emits no built-in event family, and a custom implementation may emit its own events under a documented component name.
4) Log to file
env = Environment(log_file="simulation.log")
env.info("This goes to the file")
env.close()
The file is opened once, in append mode, when the environment is created, and closed by env.close().
5) JSON format
For structured logging (useful for log aggregation tools):
env = Environment(log_file="simulation.json", log_format="json")
env.info("Job completed", component="Server", job_id="J1", duration=5.2)
Output:
{"sim_time": 0, "sim_time_formatted": "0.0d 00:00:0.00", "wall_time": "2025-12-25T12:00:00+00:00", "seq": 0, "kind": "log", "type": "log", "level": "INFO", "message": "Job completed", "component": "Server", "extra": {"job_id": "J1", "duration": 5.2}}
A domain event written at DEBUG has "kind": "domain", its event type as type, the namespace of the type as
component, and its payload as data.
6) Query log history
env.log_history keeps the most recent log events (default: 1000 entries). Each one is a LogEvent with t
(simulation time), seq, level, message, component and extra:
env = Environment(log_history_size=500)
# ... run simulation ...
# Get all ERROR events
errors = env.log_history.query(level="ERROR")
# Get Server events between t=100 and t=200
server_events = env.log_history.query(
component="Server",
since=100.0,
until=200.0,
)
# Iterate all events
for event in env.log_history:
print(f"{event.t}: {event.message}")
7) Query the SQLite log
With log_db_path, log events are also stored in an SQLite database, in the events table with the columns
env_id, seq, t, kind, type, level, component, message, data_json. env.log_db is the SQLite sink:
env = Environment(log_db_path="runs.db")
# ... run simulation ...
errors = env.log_db.query(level="ERROR", since=100.0, limit=50) # LogEvent objects of this environment
rows = env.log_db.execute_sql(
"SELECT component, COUNT(*) AS n FROM events WHERE env_id = ? GROUP BY component",
(env.log_db.env_id,),
)
env.close()
Several environments can share one database file; env_id tells their rows apart. query() returns the log
records only; at DEBUG the domain events are stored as rows with kind = 'domain' and their payload in
data_json. Query before env.close(): closing the environment closes the connection.
8) Component filtering and custom sinks
Component filters belong to each sink. env.sinks lists the sinks attached to the environment:
for sink in env.sinks:
sink.disable_component("FleetCoordinator") # Silence the fleet's warnings and errors
env.log_history.enable_component("FleetCoordinator") # Re-enable it in the history only
More sinks attach with attach(env); env.close() closes them too:
from simulatte.logsinks import HistorySink, JsonSink, TextSink
errors = HistorySink(10_000, level="ERROR").attach(env)
TextSink("fleet.log", components=["FleetCoordinator"]).attach(env)
JsonSink("trace.jsonl", level="DEBUG", exclude=["agv"]).attach(env) # log events and domain events, without AGVs
9) Per-simulation logs with Runner
When running parallel experiments, each simulation can write to its own log file:
from pathlib import Path
from simulatte.builders import build_immediate_release_system
from simulatte.runner import Runner
def builder(*, env):
env.info("Simulation starting", component="Main")
return build_immediate_release_system(env=env)
def extract(system):
_psp, servers, shopfloor, _router, _policy = system
avg_util = sum(s.utilization_rate for s in servers) / len(servers)
return {"jobs_done": len(shopfloor.jobs_done), "avg_utilization": avg_util}
if __name__ == "__main__":
runner = Runner(
builder=builder,
seeds=range(10),
parallel=True,
extract_fn=extract,
log_dir=Path("logs"), # Each run gets its own file
log_format="json", # Optional: use JSON format
log_level="INFO", # Optional: the level of every run's environment
# progress=None (default) auto-enables tqdm on TTY; set False to disable
)
results = runner.run(until=1000)
print(results)
# Creates: logs/sim_0000_seed_0.log, logs/sim_0001_seed_1.log, ...
10) Context manager
For explicit resource cleanup:
with Environment(log_file="run.log") as env:
# ... run simulation ...
pass
# The log file and the other sinks are closed
11) Logging inside components
Add logging to your custom components:
from simulatte.server import Server
class MyServer(Server):
def process_job(self, job, processing_time):
self.env.debug(
f"Processing {job.sku}",
component=self.__class__.__name__,
job_id=job.id,
processing_time=processing_time,
)
yield from super().process_job(job, processing_time)
self.env.info(
f"Completed {job.sku}",
component=self.__class__.__name__,
job_id=job.id,
processing_time=processing_time,
)
The keyword arguments become the extra of the record. With Environment(debug=True) they must be wire values
(numbers, strings, booleans, None, lists and string-keyed maps); anything else raises, so a log call cannot put
an arbitrary object into a trace or a digest.