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(){
// enable logging of built-in simulation metrics
enableComponentLogger()
// Create simulation entities in here
Car()
Resource("Car Wash")
}.run(5.minutes)
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(duration)
. The simulation will progress for duration
which is an instance of kotlin.time.Duration
. By doing so we may stop right in the middle of a process.
As shown in the example from above a simulation is usually started with sim.run(duration)
. The simulation will progress for duration
which is an instance of kotlin.time.Duration
. By doing so we may stop right in the middle of a process.
sim.run(2.hours)
sim.run(1.4.days) // fractionals are suportes as well
sim.run(until = now + 3.hours) // simulation-time plus 3 hours
Alternatively for backward compatbility reasons and to write down examples without any specific time dimension, we can also run for a given number of ticks which is resolved by the tickDuration
of the simulation enviroment.
sim.run(23) // run for 23 ticks
sim.run(5) // run for some more ticks
sim.run(until = 42.asTickTime()) // run until internal simulation clock is 42
sim.run() // run until event queue is empty
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.
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
Configuring a Simulation
To minimze initial complexity when creating an environment, some options can be enabled within the scope of an environment
* enableTickMetrics()
- See tick metrics
* enableComponentLogger()
- Enable the component logger to track component status
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()
}
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 Environment
s 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++
}
}
createSimulation {
dependency { Counter(0) }
dependency { 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. With kalasim
, 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
. Setting a seed is in particular useful when running a simulation repetitively (possibly with parallelization).
createSimulation(randomSeed = 123){
// internally kalasim will create a random generator
//val r = Random(randomSeed)
// this random generator is used automatically when
// creating distributions
val normDist = normal(2)
}
With this internal random generator r
, a wide range of 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.
Important
All randomization/distribution helpers are accessible from an Environment
or SimulationEntity
context only. That's because kalasim needs the context to associate the random generator of the simulation (which is also bound to the current thread).
Controlled randomization is a key aspect of every process simulation. Make sure to always strive for reproducibility by not using randomization outside the simulation context.
Continuous Distributions
Numeric 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, rectify=false)
triangular(lowerLimit = 0, mode = 1, upperLimit = 2)
constant(value)
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.
The normal distribution can be rectified, effectively capping sampled values at 0 (example normal(3.days, rectify=true)
). This allows for zero-inflated distribution models under controlled randomization.
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 ()
.
Constant Random Variables
The API also allow to model constant random variables using const(<some-value>)
. These are internally resolved as org.apache.commons.math3.distribution.ConstantRealDistribution
. E.g. consider the time until a request is considered as failed:
val dist = constant(3)
// create a component generator with a fixed inter-arrival-time
ComponentGenerator(iat = dist) { Customer() }
Duration Distributions
Typically randomization in a discrete event simulation is realized by stochastic sampling of time durations. To provide a type-safe API for this very common usecase, all continuous distributions are also modeled to sample kotlin.time.Duration
in addtion Double
. Examples:
// Create a uniform distribution between 3 days and 4 days and a bit
val timeUntilArrival = uniform(lower = 3.days, upper = 4.days + 2.hours)
// We can sample distributions by using invoke, that is ()
val someTime : Duration= timeUntilArrival()
// Other distributions that support the same style
exponential(mean = 3.minutes)
normal(mean = 10.days, sd = 1.hours, rectify=true)
triangular(lowerLimit = 0.days, mode = 2.weeks, upperLimit = 3.years)
constant(42.days)
Tip
In addition to dedicated duration distributions, all numeric distributions can be converted to duration distributions using duration unit indicators suffices. E.g normal(23).days
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 intervaluuid()
- Creates a random-controlled - i.e. deterministic - series of universally unique IDs (backed byjava.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()
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() }