Skip to content

Simulation Basics

The beauty of discrete event simulation is its very limited vocabulary which still allows expressing complex system dynamics. In essence, kalasim relies on just a handful of elements to model real-world processes.

Simulation Environment

All entities in a simulation are governed by an environment context. Every simulation lives in exactly one such environment. The environment provides means for controlled randomization, dependency injection, and most importantly manages the event queue.

The environment context of a kalasim simulation is an instance of org.kalasim.Environment, which can be created using simple instantiation or via a builder called createSimulation

val env : Environment = createSimulation(enableConsoleLogger = true){
    // Create simulation entities in here 
    Car()
    Resource("Car Wash")
}.run(5.0)

Within its environment, a simulation contains one or multiple components with process definitions that define their behavior and interplay with other simulation entities.

Very often, the user will define custom Environments to streamline simulation API experience.

class MySim(val numCustomers: Int = 5) : Environment() {
    val customers = List(numCustomers) { Customer(it) }
}

val sim = MySim(10)
sim.run()

// analyze customers
sim.customers.first().statusTimeline.display()

To configure references first, an Environment can also be instantiated by configuring dependencies first with configureEnvironment. Check out the Traffic example to learn how that works.

Running a simulation

In a discrete event simulation a clear distinction is made between real time and simulation time. With real time we refer to the wall-clock time. It represents the execution time of the experiment. The simulation time is an attribute of the simulator.

As shown in the example from above a simulation is usually started with sim.run(ticks). Here ticks is the number of ticks, that is simulation time units. The simulation will progress for ticks. By doing so we may stop right in the middle of a process.

Alternatively, we can also run until the event queue is empty (or forever depending on the model) by omitting the argument:

sim.run(23) // run for 23 ticks
sim.run(5) // run for some more ticks

sim.run(until = 42.tickTime) // run until internal simulation clock is 23 

sim.run() // run until event queue is empty

The user can define a tick transformation to map the internal clock to her wall-time clock. If a tick transformation is configured, it also allows to express run durations more naturally:

sim.run(2.hours)

sim.run(1.4.days) // fractionals are suportes as well
sim.run(until = Instant.now() + 3.hours) // wall-time plus 3 hours
sim.run(until = now + 3.hours) // simulation-time plus 3 hours

Tip

A component can always stop the current simulation by calling stopSimulation() in its process definition. See here for fully worked out example.

Event Queue

The core of kalasim is an event queue ordered by scheduled execution time, that maintains a list of events to be executed. To provide good insert, delete and update performance, kalasim is using a PriorityQueue internally. Components are actively and passively scheduled for reevaluating their state. Technically, event execution refers to the continuation of a component's process definition.

kalasim event model
Kalasim Execution Model

Execution Order

In the real world, events often appear to happen at the same time. However, in fact events always occur at slightly differing times. Clearly the notion of same depends on the resolution of the used time axis. Birthdays will happen on the same day whereas the precise birth events will always differ in absolute timing.

Even if real-world processes may run "in parallel", a simulation is processed sequentially and deterministically. With the same random-generator initialization, you will always get the same simulation results when running your simulation multiple times.

Although, kalasim supports double-precision to schedule events, events will inevitably arise that are scheduled for the same time. Because of its single-threaded, deterministic execution model (like most DES frameworks), kalasim processes events sequentially – one after another. If two events are scheduled at the same time, the one scheduled first will also be the processed first (FIFO).

As pointed out in Ucar, 2019, there are many situations where such simultaneous events may occur in simulation. To provide a well-defined behavior in such situations, process interaction methods (namely wait, request, activate and reschedule) support user-provided schedule priorities. With the parameter priority in these interaction methods, it is possible to order components scheduled for the same time in the event-queue. Events with higher priority are executed first in situations where multiple events are scheduled for the same simulation time.

There are different predefined priorities which correspond the following sort-levels

  • LOWEST (-20)
  • LOW (-10)
  • NORMAL (0)
  • IMPORTANT (10)
  • CRITICAL (20)

The user can also create more fine-grained priorities with Priority(23)

In contrast to other DSE implementations, the user does not need to make sure that a resource release() is prioritized over a simultaneous request(). The engine will automatically reschedule tasks accordingly.

So the key points to recall are

  • Real world events may appear to happen at the same discretized simulation time
  • Simulation events are processed one after another, even if they are scheduled for the same time
  • Race-conditions between events can be avoided by setting a priority

Dependency Injection

Kalasim is building on top of koin to inject dependencies between elements of a simulation. This allows creating simulation entities such as resources, components or states conveniently without passing around references.

class Car : Component() {

    val gasStation by inject<GasStation>()

    // we could also distinguish different resources of the same type 
    // using a qualifier
//    val gasStation2 : GasStation by inject(qualifier = named("gs_2"))

    override fun process() = sequence {
        request(gasStation) {
            hold(2, "refill")
        }

        val trafficLight = get<TrafficLight>()
        wait(trafficLight, "green")
    }
}

createSimulation{
    dependency { TrafficLight() }
    dependency { GasStation() }

    // declare another gas station and specify 
    dependency(qualifier = named(FUEL_PUMP)) {}

    Car()
}
As shown in the example, the user can simply pull dependencies from the simulation environment using get<T>() or inject<T>(). This is realized with via Koin Context Isolation provided by a thread-local DependencyContext. This context is a of type DependencyContext. It is automatically created when calling createSimulation or by instantiating a new simulation Environment. This context is kept as a static reference, so the user may omit it when creating simulation entities. Typically, dependency context management is fully transparent to the user.

Environment().apply {
    // implicit context provisioning (recommended)
    val inUse = State(true)

    // explicit context provisioning
    val inUse2 = State(true, koin = getKoin())
}

In the latter case, the context reference is provided explicitly. This is usually not needed nor recommended.

Instead of sub-classing, we can also use qualifiers to refer to dependencies of the same type

class Car : Component() {

    val gasStation1 : GasStation by inject(qualifier = named("gas_station_1"))
    val gasStation2 : GasStation by inject(qualifier = named("gas_station_2"))

    override fun process() = sequence {
        // pick a random gas-station
        request(gasStation, gasStation, oneOf = true) {
            hold(2, "refill")
        }
    }
}

createSimulation{
    dependency(qualifier = named("gas_station_1")) { GasStation() }
    dependency(qualifier = named("gas_station_2")) { GasStation() }

    Car()
}

Threadsafe Registry

Because of its thread locality awareness, the dependency resolver of kalasim allows for parallel simulations. That means, that even when running multiple simulations in parallel in different threads, the user does not have to provide a dependency context (called koin) argument when creating new simulation entities (such as components).

For a simulation example with multiple parallel Environments see ATM Queue

Simple Types

Koin does not allow injecting simple types. To inject simple variables, consider using a wrapper class. Example

////SimpleInject.kts
import org.kalasim.*

data class Counter(var value: Int)

class Something(val counter: Counter) : Component() {

    override fun process() = sequence<Component> {
        counter.value++
    }
}
configureEnvironment {
    add { Counter(0) }
    add { Something(get()) }
}.run(10)

For details about how to use lazy injection with inject<T>() and instance retrieval with get<T>() see koin reference.

Examples

Randomness & Distributions

Experimentation in a simulation context relates to large part to controlling randomness. Here, this is achieved by using probabilistic distributions which are internally backed by apache-commons-math. A simulation always allows deterministic execution while still supporting pseudo-random sampling. When creating a new simulation environment, the user can provide a random seed which used internally to initialize a random generator. By default kalasim, is using a fixed seed of 42.

createSimulation(randomSeed = 123){
    val randomGenerator = rg // which is resolved as    
}

With this internal random generator rg, several probability distributions are supported to provide controlled randomization. That is, the outcome of a simulation experiment will be the same if the same seed is being used.

Continuous Distributions

The following continuous distributions can be used to model randomness in a simulation model

  • uniform(lower = 0, upper = 1)
  • exponential(mean = 3)
  • normal(mean = 0, sd = 1)
  • triangular(lowerLimit = 0, mode = 1, upperLimit = 4.3)

Example:

object : Component() {
    val waitDist = exponential(3.3) // this is bound to env.rg

    override fun process() = sequence {
        hold(waitDist()) 
    }
} 

As shown in the example, probability distributions can be sampled with invoke ().

All distributions functions provide common parameter defaults where possible, and are defined as extension functions of org.kalasim.SimContext. This makes the accessible in environment definitions, all simulation entities, as well as process definitions:

Important

All randomization/distribution helpers are accessible from an Environment or SimulationEntity context only. That's because kalasim needs the context to associate the correct internal random generator.

A user can cap the sampled values with the extension function RealDistribution.clip(). E.g. normal(mean=3).clip(0) at 0 (or any other value, allowing zero-inflated distribution models with controlled randomization.

Constant Random Variables

The API also allow to model constant random variables. E.g. consider the time until a request is considered as failed:

val dist =  constant(3)
ComponentGenerator(iat = dist) { Customer() }

Internally, 3 is converted into a org.apache.commons.math3.distribution.ConstantRealDistribution.

Enumerations

Very often when working out simulation models, there is a need to sample with controlled randomization, from discrete populations, such as integer-ranges, IDs, enums or collections. Kalasim supports various integer distributions, uuid-sampling, as well as type-safe enumeration-sampling.

  • discreteUniform(lower, upper) - Uniformly distributed integers in provided interval
  • uuid() - Creates a random-controlled - i.e. deterministic - series of universally unique IDs (backed by java.util.UUID)

Apart fom numeric distributions, also distributions over arbitrary types are supported with enumerated(). This does not just work with enums but with arbitrary types including data classes.

enum class Fruit{ Apple, Banana, Peach }

// create a uniform distribution over the fruits
val fruit = enumerated(values())
// sample the fruits
val aFruit: Fruit = fruit()

// create a non-uniform distribution over the fruits
val biasedFruit = enumerated(Apple to 0.7, Banana to 0.1, Peach to 0.2 )
// sample the distribution
biasedFruit()

Controlled randomization is a key aspect of every process simulation. Make sure to always strive for repeatability by not using randomization outside of what the simulation context provides.

Custom Distributions

Whenever, distributions are needed in method signatures in kalasim, the more general interface org.apache.commons.math3.distribution.RealDistribution is being used to support a much wider variety of distributions if needed. So we can also use other implementations as well. For example

ComponentGenerator(iat = NakagamiDistribution(1, 0.3)) { Customer() }