Learning Objectives

  • To explain how a KSL model is instrumented so its inputs can be changed generically for analysis
  • To expose model inputs as controls using the @set:KSLControl annotation and access them with model.controls()
  • To change random-variable parameters generically with the RVParameterSetter class
  • To generalize model construction using the ModelBuilderIfc interface
  • To define and execute many configurations with Scenario, ScenarioRunner, and ConcurrentScenarioRunner
  • To screen for the best configuration using the MultipleComparisonAnalyzer (MCB)

Two Questions, One Prerequisite

  • Earlier chapters compared a few system configurations. This chapter scales that up. It does two things:
    • simulate many configurations — scenarios (this deck)
    • systematically explore the design space — design of experiments (next deck)
  • Both beg the same question: how do we specify a configuration to analyze?
  • A simulation model has many parameters; each setting is a different configuration, and all settings form the design space.
  • The one prerequisite: we must instrument the model so its parameters of interest can be controlled from outside the model code.

Roadmap: Instrument, Run, Compare

  • Instrument the model — make inputs settable by name
    • controls via @set:KSLControl and model.controls()
    • random-variable parameters via RVParameterSetter
    • a unified flat key naming scheme for both
  • Generalize constructionModelBuilderIfc, a repeatable model recipe
  • Run many configurationsScenario with ScenarioRunner (sequential) or ConcurrentScenarioRunner (parallel)
  • CompareMultipleComparisonAnalyzer (MCB)
  • Running example throughout: the PalletWorkCenter model, varying the number of workers

Controls: The @set:KSLControl Annotation

  • A control exposes a model-element property so it can be set generically before a run (via reflection, in the ksl.controls package)
  • Annotate the property’s setter with @set:KSLControl; supply a controlType from the ControlType enum plus optional bounds
  • Numeric types (Double, Int, Long, Short, Byte, Float) are all handled as a Double and converted on assignment; Boolean maps 1.0/0.0
  • Here numWorkers on PalletWorkCenter is exposed as an integer control with a lower bound of 1
@set:KSLControl(
    controlType = ControlType.INTEGER,
    lowerBound = 1.0
)
var numWorkers = numWorkers
    set(value) {
        require(value >= 1) { "The number of workers must be >= 1" }
        require(!model.isRunning) { "Cannot change the number of workers while the model is running!" }
        field = value
    }

Accessing Controls and Their Keys

  • Model.controls() returns all controls for every element in the model
  • Each control has a unique key: the element’s name + the property name, e.g. PWC.numWorkers
  • Look up a control by key and assign its value; the underlying property updates
  • KSL elements may already be annotated (e.g. every Variable has an initialValue control)
val model = Model("Pallet Model MCB")
val palletWorkCenter = PalletWorkCenter(model, name = "PWC")

val controls = model.controls()
for (controlKey in controls.controlKeys()) {
    println(controlKey)
}
// look up a control by its unique key, then set it
val control = controls.control("PWC.numWorkers")!!
control.value = 3.0   // numWorkers is now 3
PWC.numWorkers
NumBusyWorkers.initialValue
Num Processed.initialCounterLimit
P{total time > 480 minutes}.initialValue

Changing Random-Variable Parameters

  • Random variables are immutable, and parameter counts vary by type — so the KSL provides a generic protocol: RVParameterSetter (ksl.rvariable.parameters)
  • rvParameters maps each RV’s name to an RVParameters object holding its Double/Int/DoubleArray parameters
  • Change a value with changeDoubleParameter(name, value) — but the change is staged, not yet applied
  • Call applyParameterChanges(model) to push the change into the model
val setter = RVParameterSetter(model)
val map = setter.rvParameters
for ((key, value) in map) {
    println("$key -> $value")
}
// change the mode of the triangular processing-time RV
val rv = map["ProcessingTimeRV"]!!
rv.changeDoubleParameter("mode", 14.0)
setter.applyParameterChanges(model)   // <-- required to take effect

One Flat Key Space for Controls and Parameters

  • RVParameterSetter also exposes a flattened view via flatParametersAsDoubles
  • Each key = RV name + . + parameter name, so every parameter gets a unique string key
  • This matches the control-key format (PWC.numWorkers) — one uniform naming scheme for both controls and RV parameters
  • That shared scheme is exactly what scenarios use to specify inputs
val flatMap = setter.flatParametersAsDoubles
for ((key, value) in flatMap) {
    println("$key -> $value")
}
ProcessingTimeRV.min       -> 8.0
ProcessingTimeRV.mode      -> 14.0
ProcessingTimeRV.max       -> 15.0
TransportTimeRV.mean       -> 5.0
NumPalletsRV.probOfSuccess -> 0.8
NumPalletsRV.numTrials     -> 100.0

Generalizing Construction: ModelBuilderIfc

  • To reuse a model (in experiments, apps, or concurrent runs), wrap the “create model → add elements → configure” recipe in a ModelBuilderIfc
  • Its build() returns a fresh, fully-configured Model; calling it twice yields two completely independent models
  • Keep build() simple and set run defaults (replications, run length) inside it
interface ModelBuilderIfc {
    fun build(
        modelConfiguration: Map<String, String>? = null,
        experimentRunParameters: ExperimentRunParametersIfc? = null
    ): Model
}

object BuildPalletWorkCenter : ModelBuilderIfc {
    override fun build(
        modelConfiguration: Map<String, String>?,
        experimentRunParameters: ExperimentRunParametersIfc?
    ): Model {
        val model = Model("Pallet Model")
        PalletWorkCenter(model, name = "PWC")
        model.numberOfReplications = 30
        return model
    }
}

Defining and Running Scenarios: ScenarioRunner

  • A Scenario = a model (or model builder) + an inputs map of flat key → value + a unique name
  • The inputs map uses the same flat keys we just saw — controls and RV parameters together
  • ScenarioRunner batch-runs a list of scenarios and captures every result in a KSLDatabase (kslDb)
  • The scenario name becomes the experiment name, so keep names unique
fun buildScenarios(): List<Scenario> {
    val model = Model("Pallet Model")
    PalletWorkCenter(model, name = "PWC")
    model.numberOfReplications = 30
    val s1 = Scenario(model = model, name = "One Worker",
        inputs = mapOf("PWC.numWorkers" to 1.0))
    val s2 = Scenario(model = model, name = "Two Worker",
        inputs = mapOf("PWC.numWorkers" to 2.0))
    val s3 = Scenario(model = model, name = "Three Worker",
        inputs = mapOf("PWC.numWorkers" to 3.0))
    return listOf(s1, s2, s3)
}

val runner = ScenarioRunner("Pallet Comparison", buildScenarios())
runner.simulate()

Running Scenarios Concurrently: ConcurrentScenarioRunner

  • ScenarioRunner runs scenarios one at a time; ConcurrentScenarioRunner runs them in parallel, cutting wall-clock time by roughly the number of cores
  • Same constructor (name, scenario list, database); simulate() is a suspend function, so wrap main in runBlocking
  • Requires builder-based scenarios, never a shared Model: each scenario calls build() for its own private model — a shared model would race. Submitting a shared-model scenario throws immediately.
  • By default all scenarios start from the same streams → common random numbers (CRN), exactly what we want when comparing alternatives; call useIndependentRandomStreams() to opt out
fun main() = runBlocking {
    val s1 = Scenario(BuildPalletWorkCenter, "One Worker", mapOf("PWC.numWorkers" to 1.0))
    val s2 = Scenario(BuildPalletWorkCenter, "Two Worker", mapOf("PWC.numWorkers" to 2.0))
    val s3 = Scenario(BuildPalletWorkCenter, "Three Worker", mapOf("PWC.numWorkers" to 3.0))

    val runner = ConcurrentScenarioRunner("Pallet Comparison", listOf(s1, s2, s3))
    runner.simulate()
    runner.print()
}

Comparing Scenario Results: MCB

  • Both runners store all results in a KSLDatabase (runner.kslDb), organized by scenario (see output structure)
  • Build a MultipleComparisonAnalyzer (MCB) directly from the database to screen for the best configuration — same API for either runner
  • multipleComparisonAnalyzerFor() takes the experiment names and the response name to compare
val responseName = PalletWorkCenter(Model()).totalProcessingTime.name
val mca = runner.kslDb.multipleComparisonAnalyzerFor(
    expNames = runner.scenarioList.map { it.name },
    responseName = responseName
)
println(mca)
  • Next deck: hand-listing scenarios works for a handful of configurations — when factors and levels multiply, we turn to systematic experimental design.

Where the Results Are Stored

Scenario output directories and the captured KSL database
⌂ Index