Skip to content

Creating a simulation

Example

The full source code of the model can be downloaded here.

Note

This page assumes you have read the introduction and followed the installation instructions.

This page gives a step-by-step walkthrough where we will build a simple simulation in ERS that consists of a single simulator / submodel. In doing so, we will go through the following topics:

The model will consist of two objects: a source and a sink. The source generates products for as long as the simulation runs, and passes them immediately to the sink. The sink keeps track of the number of received products and removes them from the simulation.

graph LR
  A@{ shape: stadium, label: "Source" }
  B@{ shape: stadium, label: "Sink" }
  A --> B;

Figure 1. The Source-Sink model.

The components we will be making on this page and the next will be specialized and not very re-usable. In the using channels and re-usable components pages, we will show how to make more generalized simulation objects and break them down into smaller, re-usable components.

Info

This simulation will gradually be expanded upon in the following sections, introducing more concepts and features along the way. We highly recommend following through all sections to get a good understanding of ERS.

C# specific issue

As mentioned, entities in ERS are actually just numbers (unsigned 64-bit integers). The Entity type in C# is just an alias for ulong / UInt64. Unfortunately, C# does not allow exporting a type alias as part of our ERS library. Because of this, we like to add a global using to all our ERS projects in C#. We add a GlobalUsings.cs file containing the alias in the root directory of the project (next to the .csproj file):

// GlobalUsings.cs
global using Entity = System.UInt64;

In the following sections, we assume you have also defined this alias. If you do not wish to use this alias, you can use either ulong or UInt64 as the type for entities.

For more information, see the GlobalUsings page.

Step 1: Set up the ModelContainer, Simulator and SubModel

The first step to setting up a simulation in ERS is to create a ModelContainer. Even when your model only has one simulator-submodel pair, you need a model container to update the model. As part of setting up the model container, we set the model precision.

The model precision is used to add more nuance to the time steps of the simulation. Time units in ERS are any arbitrary unit. You can decide yourself whether they represent seconds, milliseconds, minutes, or something else. The time units in this model will be seconds. But what if we want to be precise and the generation of a product takes 1.5 seconds? We can only use time steps in whole numbers, so we can schedule an event one or two seconds into the future, some value in between. That is why we need model precision: instead of seconds, the time units will be microseconds (\(1^{-6}\) seconds). So, if we now want to schedule 1.5 seconds, we can schedule an event 1.5 million time steps from now. Setting the precision in a model container does not do anything: no behavior is changed, only a value is stored and never read. The reason the precision is stored in the model container, is to be able to easily retrieve it later. We will do this later when we are scheduling events.

Next, add a simulator to the model container using the AddSimulator method. This method takes the names of the simulator and the simulator type, and returns the newly created simulator.

Lastly, we are able to enter and exit the submodel to edit it. It is required to enter and exit submodels so ERS knows which submodel you are using at any given moment. During a simulation run, submodels are automatically entered and exited. For more information, see the submodel page.

// Program.cs
using Ers;

ERS.Initialize();

ModelContainer modelContainer = ModelContainer.Create();
modelContainer.Precision = 1_000_000;

Simulator sim = modelContainer.AddSimulator("Sim1", SimulatorType.DiscreteEvent);
sim.EnterSubModel();
// The submodel of Sim1 can be edited here
sim.ExitSubModel();

ERS.Uninitialize();

Step 2: Create the components

The source

Now we need to create the components that implement our model's logic. Let's first look at the source object. We said we want it to generate products for as long as the simulation is running, and pass these products immediately to the sink. So, what we need is the following:

  • An event that generates a product, which is executed in an infinite loop.
  • Some delay between generating products.
  • A link to the sink, so the source knows where to send the products.

Here is our full SourceBehavior component along with its event:

struct GenerateProductEvent : ILocalEvent<GenerateProductEvent>
{
    public Entity SourceEntity;

    public void OnEvent()
    {
        SubModel subModel = SubModel.Get();
        SourceBehavior source = SourceEntity.GetComponent<SourceBehavior>();

        // Create the product
        Entity entity = subModel.CreateEntity(SourceEntity, $"Product{source.Produced + 1}");
        source.Produced++;

        // Move the product to the next simulation object
        subModel.UpdateParentOnEntity(entity, source.Target);

        // Re-schedule this function, creating a loop
        ulong delay = subModel.ApplyModelPrecision(source.GenerationTime);
        EventScheduler.ScheduleLocalEvent(0, delay, new GenerateProductEvent()
        {
            SourceEntity = SourceEntity
        });
    }
}

class SourceBehavior : ScriptBehaviorComponent
{
    // The entity to which products are sent.
    public Entity Target;
    public ulong GenerationTime = 5;
    public ulong Produced = 0;

    public override void OnStart()
    {
        // Start infinite product generating event loop
        ulong delay = SubModel.Get().ApplyModelPrecision(GenerationTime);
        EventScheduler.ScheduleLocalEvent(0, delay, new GenerateProductEvent()
        {
            SourceEntity = ConnectedEntity
        });
    }
}

Let's first look at how it generates the products. The GenerateProductEvent struct defines the data and logic of our event. Each local event must implement the ILocalEvent interface, which has the OnEvent function. When the event is executed, the OnEvent function is called and it creates a new entity in the current submodel. To make sure each product has a unique name, the source keeps track of the number of created entities and names them accordingly.

At the end of the event, it schedules itself again in a new event to create an infinite loop. The ScheduleLocalEvent function schedules an event on the simulator attached to the current submodel. Its first parameter gives the priority of the event, the second the delay, and the last the event that needs to be executed by the event. When passing the event as the third parameter, we also fill the data that is used by the event. In this case, we pass the ConnectedEntity, which is the entity which the script behavior is attached to. The priority is used to resolve the order of events when they are scheduled to execute on the same point in time. A lower value indicates a higher priority.

As you can see, we apply the model precision to the delay. As mentioned earlier, setting the precision does not do anything: it was stored for convenience so we can now use it here. If we now wish to change the precision of the model, we don't need to change all delays throughout the model. Instead there is a centralized precision value that we use whenever an event is scheduled.

However, how is the first event scheduled? The OnStart function of a script behavior is always called once when a simulation starts. The SourceBehavior component uses this function to create the first event. To learn more about OnStart and similar functions, have a look at the script behavior section of the ECS docs.

To know where to send the products, the SourceBehavior stores a target entity. The target is used in the UpdateParentOnEntity function to set the created entity as a child to the target.

The sink

The sink is a bit simpler: it should count the number of received products and destroy each product afterwards. To do this, we can use the OnEntered function on a script behaviors. This function is called whenever a new child entity is added to entity to which a script behavior is attached.

class SinkBehavior : ScriptBehaviorComponent
{
    public ulong Received = 0;

    public override void OnEntered(Entity newChild)
    {
        Received++;
        SubModel.Get().DestroyEntity(newChild);
    }
}

Registering the types

Any component and event that you create should be registered to ERS. This should be done before the model container is created (for more information, see registering components).

When a type is registered, ERS will know how much memory to allocate and how it should interpret the memory. For example, ERS will prepare space for each component in the ECS. To register a component, use the ComponentRegistry<T>.Register() method. To register a local event, use the LocalEventRegistry<T>.Register() method.

// This code is just before the creation of the model container

// Register the custom components
ComponentRegistry<SourceBehavior>.Register();
ComponentRegistry<SinkBehavior>.Register();

// Register the local events
LocalEventRegistry<GenerateProductEvent>.Register();

Step 3: Create the entities

Now that we have created the necessary components, we can create entities that represent the actual objects in our simulation. We create both a source and a sink entity in the submodel just after where we added the component types.

// This code is between entering and exiting the submodel

SubModel subModel = SubModel.Get();

Entity sourceEntity = subModel.CreateEntity("Source");
SourceBehavior source = sourceEntity.AddComponent<SourceBehavior>();

Entity sinkEntity = subModel.CreateEntity("Sink");
SinkBehavior sink = sinkEntity.AddComponent<SinkBehavior>();

source.Target = sinkEntity;

As you can see, we first create an entity for the source and attach the component using AddComponent. Next, the same is done for the sink. To point the source to the sink, we set the Target we defined in the SourceBehavior to the sink entity.

Step 4: Run the model

Now that we have set up the entire model, we can finally run it. Let's run the model for one minute. To do this we first determine the time step at which the simulation should end (endTime). We want end our model at one minute, so we calculate \(60 * precision = 60*10^{6}\) time units. To actually run the simulation, we add a loop that updates the model in steps of one second until the current time of the simulation (CurrentTime) has reached the end time.

You can run the model in many ways. To simply advance the model by a given time, you can call the Update method once (method 1). However, if you wish to have your program perform additional logic in between updates you can call the Update method in a loop (method 2). For example, when you want to gather data while the simulation is running. Both methods give the same simulation result.

We also log the number of products received to the sink as an info-level log.

// This codes follows after the creation of entities

ulong endTime = 60 * modelContainer.Precision;

// Method 1: one big update
modelContainer.Update(endTime);

// Method 2: use a loop and simulate until the preferred time is reached
while (modelContainer.CurrentTime < endTime)
{
    modelContainer.Update(modelContainer.Precision);
    // Do something else
}

Logger.Info($"Sink received {sink.Received} products");

sim.ExitSubModel();
ERS.Uninitialize();

When you run the model, you should see an output similar to this:

[I] Thread [13892] Starting Enterprise Resource Simulator Core
[I] Thread [13892] Version 0.4.0 | Build Date: 2025-05-28 15:25:04
[I] Thread [13892] Reading initial settings file
[I] Thread [13892] [Info] is new LogLevel!
[I] Thread [13892] Sink received 12 products
[I] Thread [13892] Waiting for JobSystem to finish remaining tasks, then shut down
[I] Thread [13892] Exiting JobSystem
[I] Thread [13892] Closing Enterprise Resource Simulator Core

Next steps

Was this page helpful?