Skip to content

Expanding the model

Example

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

On this page, we will continue on the model we created on the creating a model page. We will add a server that performs some operation on the product, and add a queue so products can queue before the server. In doing so, we will go through the following topics:

  • Working with entity relations (the RelationComponent).
  • Reacting to changes in entity relations.
  • Logging messages.
  • Sampling random numbers.
graph LR
  A@{ shape: stadium, label: "Source" }
  B@{ shape: rect, label: "Queue" }
  C@{ shape: rect, label: "Server" }
  D@{ shape: stadium, label: "Sink" }
  A --> B;
  B --> C;
  C --> D;

Figure 1. The Source-Queue-Server-Sink model we will be making.

The components we will be making on this page 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 down components into smaller, re-usable components.

Adding a product state

Let's say that in our case the products need to be filled by the server. To keep a state of the products, we add a product data component that only stores a boolean: whether it has been filled or not:

// Product.cs
[StructLayout(LayoutKind.Sequential)]
internal struct Product : IDataComponent
{
    public bool Filled = false;

    public Product()
    {
    }
}

Creating the queue

The queue we will be creating will accept up to a certain number of products and hold them until another object pulls a product from the queue. It will be a FIFO (First In, First Out) queue where products in the queue will be stored as children to the queue, moving the first child out of the queue when an entity can be passed to the next object.

// Queue.cs
internal class QueueBehavior : ScriptBehaviorComponent
{
    public Entity Target;
    public ulong Capacity = 5;
    public bool InputOpen = true;

    public override void OnEntered(ulong newChild)
    {
        Logger.Debug("Queue received {0}", newChild.GetName());

        if (ConnectedEntity.GetComponent<RelationComponent>().Value.ChildCount >= Capacity)
            InputOpen = false;

        MoveNext();
    }

    public override void OnExited(ulong oldChild)
    {
        InputOpen = true;
    }

    public void MoveNext()
    {
        // If the queue is empty, do nothing and return
        var relation = ConnectedEntity.GetComponent<RelationComponent>();
        if (relation.Value.ChildCount == 0)
            return;

        // Only move an entity out of the queue when the target is ready to receive it
        if (Target.GetComponent<ServerBehavior>().InputOpen)
        {
            Entity first = relation.Value.First;
            SubModel.Get().UpdateParentOnEntity(first, Target);
        }
    }
}

Let's first look at the variables that are defined at the top of the queue component. The Target specifies to which entity the products will be sent when they leave the queue, Capacity defines the maximum number of products that are in the queue, and InputOpen is used to specify whether the queue is ready to receive products.

Next, we use the OnEntered and OnExited functions. As the name suggest, OnEntered is called when an entity "enters" the entity (is added as a child) connected to the script behavior. OnExited is called when an entity "leaves" (a child is removed).

Inside OnEntered, the queue first logs which entity it has received on the debug log level. We can use this to show exactly what happens in our model, but only when we have the logger's log level set to debug or lower. This way, you can debug your model as you build it, but hide additional information like this when it is not required. We will show later how to easily change the shown log level. Next, the queue uses the ChildCount method from the RelationComponent to check if the capacity has been reached. If so, the input is closed, so it won't receive new products from the source anymore (once we've updated the source). Notice how we get the component from the ConnectedEntity: script behaviors always store this connected entity to be able to access the entity they are attached to. Lastly, the MoveNext function is called, which tries to move the product from the queue to the server.

When OnExited is called, a product has left the queue. We know the queue can't be full at that point, so the queue opens its input so products are allowed to enter again.

In MoveNext the first child is moved only when there are children and the server is ready to receive a product. The function UpdateParentOnEntity moves the first child from the queue entity to the target entity (the server in this case), making it a child of the target entity.

Creating the server

The server will operate on one product at a time, changing its state. Once a product has been processed, it will pass it on to the next simulation object.

// Server.cs
internal struct ServerProcessEvent : ILocalEvent<ServerProcessEvent>
{
    public Entity ServerEntity;

    public void OnEvent()
    {
        SubModel subModel = SubModel.Get();
        ServerBehavior server = ServerEntity.GetComponent<ServerBehavior>();

        if (server.Target.GetComponent<SinkBehavior>().InputOpen)
        {
            // Product can be moved, process it and move it
            Entity child = ServerEntity.GetComponent<RelationComponent>().Value.First;
            child.GetComponent<Product>().Value.Filled = true;
            subModel.UpdateParentOnEntity(child, server.Target);
        }
        else
        {
            // Could not move the product, retry
            server.Process();
        }
    }
}

internal class ServerBehavior : ScriptBehaviorComponent
{
    public double BaseProcessTime = 10.0;

    public Entity From;
    public Entity Target;

    public bool InputOpen = true;

    public override void OnEntered(ulong newChild)
    {
        Logger.Debug("Server received {0}", newChild.GetName());
        InputOpen = false;
        Process();
    }

    public override void OnExited(ulong newChild)
    {
        QueueBehavior queue = From.GetComponent<QueueBehavior>();
        InputOpen = true;
        queue.MoveNext();
    }

    public void Process()
    {
        SubModel subModel = SubModel.Get();

        // Create random process time values between 9 and 11
        double processTime = subModel.SampleRandomGenerator() * 2.0 - 1.0 + BaseProcessTime;
        ulong unitProcessTime = (ulong)(processTime * subModel.ModelPrecision);

        Logger.Debug("Server process delay: {0:F2}s", processTime);
        EventScheduler.ScheduleLocalEvent(0, unitProcessTime, new ServerProcessEvent()
        {
            ServerEntity = ConnectedEntity
        });
    }
}

The server defines a couple of variables at the top too: The BaseProcessTime is used to set the base time it takes to process a product. The actual time it takes to process a product varies randomly to one second shorter or longer than the base process time. Both a from and to entity are stored so the server knows from which entity to pull new products and to which entity to send processed products. Lastly, InputOpen is once again used to be able to stop others from sending products to the server if it is not ready to receive them.

For the server, the OnEntered closes the input, because the server only processes one product at a time, and starts processing the product by calling Process. The OnExited function requests a new product from the queue.

When the server processes a product, it uses the BaseProcessTime plus a random offset between -1 and 1 seconds to calculate the time the processing of the current product will take. To sample a random number in ERS, we use the SampleNumberGenerator function. This function returns a random value between 0 and 1, which we can convert to a range between -1 and 1: multiply the sampled value by 2 to get a value between 0 and 2, then subtract one to get to -1 and 1. We will see later how to set a seed for the random number generator so every run of the model has the same results.
The unitProcessTime is the process time with the model precision applied to it to get the process time in unit time. This value is used to schedule a local event that updates the product's state and moves it to the next entity. If the sink is not ready to receive a product, the server retries it until it works.

Updating the source and sink

The source

We have now added the queue and the server and included checks to make sure if they can move products to the next entity. However, when the queue fills up, the source will still create and move products. Let's update the GeneratorProductEvent event in the source so it generates products with the new product component and checks whether the queue is ready to receive a product. Note that we moved the event from Program.cs to Source.cs to keep the code structure readable as we expand the model.

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

    // Only create a product when it can be moved to the next entity
    if (source.Target.GetComponent<QueueBehavior>().InputOpen)
    {
        // Create the product
        Entity entity = subModel.CreateEntity(SourceEntity, $"Product{source.Produced + 1}");
        entity.AddComponent<Product>();
        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
    });
}

All we had to do was add an if-statement to only create and move a product when the queue is ready to receive one. Because it already re-schedules itself, it will automatically retry creating and moving the product when the queue is full. Of course, you could set it up to have a separate generation and retry time, but the current implementation is fine for this example.

The sink

When we created the server, we designed it to check whether the sink is ready to receive a product. In this model the sink is always able to receive a product. However, we add this check now so it can be used later to have all components work with all other components. For now, we only add an InputOpen variable to the sink:

// Sink.cs
internal class SinkBehavior : ScriptBehaviorComponent
{
    public ulong Received = 0;
    public bool InputOpen = true;

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

Note that we moved the code for the sink to a separate file (Sink.cs), just like we did with the source.

Updating the model setup

Now that we have all the components ready, the model setup needs to be updated. We need to register the new components, create entities for them, attach the components, and connect the components. To do this we expand the section of code where we set up the source and sink before:

// Program.cs

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

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

// ModelContainer is created here as before...

SubModel subModel = SubModel.Get();

// Create the simulation objects
Entity sourceEntity = subModel.CreateEntity("Source");
SourceBehavior source = sourceEntity.AddComponent<SourceBehavior>();

Entity queueEntity = subModel.CreateEntity("Queue");
QueueBehavior queue = queueEntity.AddComponent<QueueBehavior>();

Entity serverEntity = subModel.CreateEntity("Server");
ServerBehavior server = serverEntity.AddComponent<ServerBehavior>();

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

// Connect the simulation objects
source.Target = queueEntity;
queue.Target = serverEntity;
server.From = queueEntity;
server.Target = sinkEntity;

As mentioned before, you can adjust the log level to only show messages from a certain level or up. We also want to set a seed for the random number generator so all simulation runs give the same result. Let's configure both now:

ERS.Initialize();

Logger.SetLogLevel(LogLevel.Info);

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

The SetLogLevel function sets the log level to the specified value. Here we set it to "info". Because debug is a lower level than info, the debug level log messages we added to the queue and server earlier will not be shown. If you would like to see those messages, you can adjust the log level here.

In the last line, you can see how the seed for the random number generators in the model containers are set to one. Every time the model runs, the same values will be sampled and thus the simulation will give the same results. It should also make sure you get the same results in this model that we get:

[I] Thread [33020] Starting Enterprise Resource Simulator Core
[I] Thread [33020] Version 0.4.0 | Build Date: 2025-06-10 18:40:26
[I] Thread [33020] Reading initial settings file
[I] Thread [33020] [Info] is new LogLevel!
[I] Thread [33020] [Info] is new LogLevel!
[I] Thread [33020] Sink received 361 products
[I] Thread [33020] Waiting for JobSystem to finish remaining tasks, then shut down
[I] Thread [33020] Exiting JobSystem
[I] Thread [33020] Closing Enterprise Resource Simulator Core

Tip

As you may have noticed, all components are currently hard coded to work in a specific configuration: the source moves to the queue, the queue to the server, and the server to the sink. This isn't very flexible: we can't easily have the source move straight to the server if we wanted to do that. We would like to be able to create the entities and attach components as before, but only have to change the connections between them to change the layout of the model. As mentioned before, the using channels page will explain how this can be done.

Next steps

Was this page helpful?