Skip to content

Commit

Permalink
finalized call center example
Browse files Browse the repository at this point in the history
  • Loading branch information
holgerbrandl committed Nov 1, 2023
1 parent f6ddcb2 commit ab17965
Show file tree
Hide file tree
Showing 11 changed files with 944 additions and 511 deletions.
481 changes: 0 additions & 481 deletions docs/userguide/docs/articles/callcenter.ipynb

This file was deleted.

Binary file removed docs/userguide/docs/articles/img.png
Binary file not shown.
Binary file removed docs/userguide/docs/articles/img_1.png
Binary file not shown.
1 change: 1 addition & 0 deletions docs/userguide/docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Elaborate
* [Bank Office](examples/bank_office.md#bank-office-with-resources) - A classical queue problem where customers need to be served. Here solved 4 times in different ways using different `kalasim` models.
* [Dining Philosophers](examples/dining_philosophers.md) - Philosophers sit at a round table with bowls of spaghetti and try to eat. It ain't easy...
* [Office Tower](examples/office_tower.md) - A busy office building, where workers need to get from floor to floor using a limited number of elevators.
* [Call Center](examples/office_tower.md) - A support center sizing analysis to figure the correct number of support technicians before failing the business in real life.



Expand Down
602 changes: 602 additions & 0 deletions docs/userguide/docs/examples/callcenter.ipynb

Large diffs are not rendered by default.

288 changes: 288 additions & 0 deletions docs/userguide/docs/examples/callcenter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
# Call Center

Resource planning is the bread and butter of any successful business.


Let's take a glimpse into the near future where kalasim has taken up a dominant role in the simulation industry. Hundreds of happy customers are utilizing the support hotline 24/7 from around the globe to obtain professional simulation assistance. Envision a customer service department, where a steady stream of customer requests come in, and the customer service agents are tasked with addressing these inquiries.


![Vintage Gas Pump](callcenter_plantronics.jpg){: .center}

<p align="center">
<i>Call Center (©Plantronics, CC BY-SA 3.0)</i>
</p>

<!-- source https://www.communitechservices.com/no-toggle-single-screen-access -->

Choosing an appropriate shift model and planning shift capacity are key this - and any successful - business operations. Due to the complex dynamics and interplay, it's often very challenging to determine capacity and pinpoint bottlenecks in such systems on paper.

Here is how the envisioned call center functions:

* The requests arrive throughout the day and are queued and pooled, waiting for an available responder.
* The responders are available in two shifts, excluding weekends. Ideally, the two shifts should not have separate queues since there is already a pooled queue.
* If a responder from Shift A is working on a request but is about to end their shift, they will hand over the request they are working on to Shift B.
* Shifts A and B will have different capacities to mimic day/night shift regimes.

Except for weekends, when there are no available shifts, the first available responder the following week will handle the unattended requests.

To get started, let's initialize the environment by loading the latest version of kalasim:


```kotlin
@file:Repository("*mavenLocal")

%useLatestDescriptors on

@file:DependsOn("com.github.holgerbrandl:kalasim:0.12-SNAPSHOT")
@file:DependsOn("com.github.holgerbrandl:kravis:0.9.95")
```

## Shift System

To engineer a discrete event simulation for this particular business process, we begin by implementing the shift system. Technically, we use a [sequence builder](https://kotlinlang.org/docs/sequences.html) to create a weekly shift sequence that is repeated, allowing the subsequent model to run indefinitely.


```kotlin
enum class ShiftID { A, B, WeekEnd }

val shiftModel = sequence {
while(true) {
repeat(5) { yield(ShiftID.A); yield(ShiftID.B) }
yield(ShiftID.WeekEnd)
}
}
```

## Customer Inquiries

Now, we model the customer requests as simulation entities. Each request is modelled as a [`Component`](../component.md) with a dedicated small lifecycle where the call center agent is [request](../component.md#request)ed. The actual process time varies; it is exponentially distributed with an average of around 25 minutes.


```kotlin
class Request : Component() {
val callCenter = get<Resource>()

override fun process() = sequence {
request(callCenter, capacityLimitMode = CapacityLimitMode.SCHEDULE) {
hold(exponential(25.minutes).sample())
}
}
}
```

## Shift Manager

Next, we need to model the call center manager to modulate the shift (or in simulation speak [resource](resources.md)) capacity dynamically.


```kotlin
class ShiftManager : Component() {
val shiftIt = shiftModel.iterator()
val callCenter = get<Resource>()

override fun repeatedProcess() = sequence {
val currentShift = shiftIt.next()

log("starting new shift ${currentShift}")

// adjust shift capacity at the beginning of the shift
callCenter.capacity = when(currentShift) {
ShiftID.A -> 2.0
ShiftID.B -> 5.0
ShiftID.WeekEnd -> 0.0
}

// wait for end of shift
hold(if(currentShift == ShiftID.WeekEnd) 48.hours else 12.hours)
}
}
```

## Simulation Environment

Finally, we integrate everything into a simulation [environment](basics.md#simulation-environment) for easy experimentation. To facilitate convenient experimentation with various configurations and decision policies, we maintain the Shift Manager as an abstract entity, obliging the simulator to incorporate a specific implementation to conduct an experiment.


```kotlin
abstract class CallCenter(
val interArrivalRate: Duration = 10.minutes,
logEvents: Boolean = false
) :
Environment(
enableComponentLogger = logEvents,
// note tick duration is just needed here to simplify visualization
tickDurationUnit = DurationUnit.HOURS
) {

// intentionally not defined at this point
abstract val shiftManager: Component

val serviceAgents = dependency { Resource("Service Agents") }

init {
ComponentGenerator(iat = exponential(interArrivalRate)) { Request() }
}
}
```

Let's run the unit for 1000 hours


```kotlin
val sim = object : CallCenter() {
override val shiftManager = ShiftManager()
}

sim.run(30.days)
```

To understand the dynamics of the model, we could now try inpspecting its progression. First we check out the queue length


```kotlin
sim.serviceAgents.requesters.queueLengthTimeline.display()

```





![jpeg](callcenter_files/callcenter_15_0.jpg)




## Model Accuracy During Shift Handover

Clearly, this first version has the limitation that tasks overlapping with a shift-change do not immediately respect changes in capacity. That is, when shifting from a highly-staffed shift to a lesser-staffed shift, ongoing tasks will be completed regardless of the reduced capacity.

It's not straightforward to cancel these tasks and request them again in the next shift. This is because a `release()` will, by design, check if new requests could be served. So, ongoing tasks could be easily released, but re-requesting them - even with higher priority - would lead them to be processed slightly later than the requests that were immediately approved.

To elegantly solve this problem, we can use two other methods - `interrupt()` and `standby()`. With `interrupt()`, we can stop all ongoing tasks at a shift change. With `standby()`, we can schedule process continuation in the next simulation cycle.

For the revised model, we just need to create a different `ShiftManager` with our revised handover process:




```kotlin
class InterruptingShiftManager(
val aWorkers: Double = 2.0,
val bWorkers: Double = 5.0
) : Component() {
val shiftIt = shiftModel.iterator()
val serviceAgents = get<Resource>()

override fun repeatedProcess() = sequence {
val currentShift = shiftIt.next()

log("starting new shift $currentShift")

// adjust shift capacity at the beginning of the shift
serviceAgents.capacity = when(currentShift) {
ShiftID.A -> aWorkers
ShiftID.B -> bWorkers
ShiftID.WeekEnd -> 0.0
}

// complete hangover calls from previous shift
fun shiftLegacy() = serviceAgents.claimers.components
.filter { it.isInterrupted }

// incrementally resume interrupted tasks while respecting new capacity
while(shiftLegacy().isNotEmpty() && serviceAgents.capacity > 0) {
val numRunning = serviceAgents.claimers.components
.count { it.isScheduled }
val spareCapacity =
max(0, serviceAgents.capacity.roundToInt() - numRunning)

// resume interrupted tasks from last shift to max out new capacity
shiftLegacy().take(spareCapacity).forEach { it.resume() }

standby()
}

// wait for end of shift
hold(if(currentShift == ShiftID.WeekEnd) 48.hours else 12.hours)

// stop and reschedule the ongoing tasks
serviceAgents.claimers.components.forEach {
// detect remaining task time and request this with high prio so
// that these tasks are picked up next in the upcoming shift
it.interrupt()
}
}
}
```

We can now instantiate a new call center with the improved hand-over process


```kotlin
val intSim = object: CallCenter() {
override val shiftManager = InterruptingShiftManager()
}

// Let's run this model for a month
intSim.run(180.days)
```

Again we study the request queue length as indicator for system stability:


```kotlin
intSim.serviceAgents.requesters.queueLengthTimeline
.display("Request queue length with revised handover process")

```





![jpeg](callcenter_files/callcenter_22_0.jpg)




As we can see, the model is not stable. The number of support technicians is not adequate to serve the varying number of customers. Although the request frequency did not change, the more accurate shift transition modeling has impacted the result.

To determine the correct sizing - i.e., the number of service operators needed to reliably serve customers - we will increase the day staff by one support technician and repeat the experiment.


```kotlin
val betterStaffed = object: CallCenter() {
override val shiftManager = InterruptingShiftManager(bWorkers = 6.0)
}

betterStaffed.run(90.days)


val queueLengthTimeline = betterStaffed.serviceAgents
.requesters.queueLengthTimeline

queueLengthTimeline.display("Request queue length with revised handover process")
```





![jpeg](callcenter_files/callcenter_24_0.jpg)




Notably, this model has the almost the same dynamics, but is stable from a queuing perspective.

## Summary

We have successfully modelled a variable shift length schedule and performed a sizing analysis. An initial model indicated that a weekday shift would be sufficiently staffed with 5 workers. However, a more detailed model, which also considers transition effects between shifts, led to the conclusion that 6 support technicians are required to serve the multitude of customers of Future Kalasim Inc.


Could we have solved this more elegantly using the mathematics of queuing theory? Such models are a great starting point, but usually, they very quickly fail. That's when discrete event simulation can develop its beauty and potential. Flexible shift schedules are common in many industries, and the model introduced above could be easily adjusted to account for more business constraints and processes.

The use-case was adopted from the [simmer mailing list](https://groups.google.com/g/simmer-devel/c/gsr6F7CJQf8/m/euW1ZaU0DAAJ)


Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions docs/userguide/docs_howto.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ jupyter nbconvert --kernel=kotlin --to markdown atm_queue.ipynb --out atm_queu
jupyter nbconvert --kernel=kotlin --to markdown gas_station.ipynb --out gas_station.md


cd ${KALASIM_HOME}/docs/userguide/docs/examples

jupyter nbconvert --kernel=kotlin --execute callcenter.ipynb --to notebook --inplace
jupyter nbconvert --kernel=kotlin --to markdown callcenter.ipynb --out callcenter.md


cd ${KALASIM_HOME}/docs/userguide/docs/animation

Expand Down
1 change: 1 addition & 0 deletions docs/userguide/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ nav:
- The Ferryman: examples/ferryman.md
- Emergyency Room: examples/emergency_room.md
- Lunar Mining: animation/lunar_mining.md
- Call Center: examples/callcenter.md

- Advanced:
- advanced.md
Expand Down
17 changes: 16 additions & 1 deletion src/main/kotlin/org/kalasim/plot/kravis/KravisVis.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import kravis.*
import kravis.device.JupyterDevice
import org.jetbrains.letsPlot.label.ggtitle
import org.kalasim.*
import org.kalasim.Component
import org.kalasim.analysis.ResourceActivityEvent
import org.kalasim.monitors.*
import java.awt.GraphicsEnvironment
import java.awt.*
import java.nio.file.Files
import java.time.Instant
import kotlin.time.Duration

fun canDisplay() = !GraphicsEnvironment.isHeadless() && hasR()
Expand Down Expand Up @@ -254,3 +257,15 @@ internal fun List<Component>.clistTimeline() = flatMap { eqn ->
.statsData().asList().map { eqn to it }
}



// todo move to kravis library
fun GGPlot.showFile() {
val file = Files.createTempFile("kravis" + Instant.now().toString().replace(":", ""), ".png")

save(file, Dimension(1000, 800))

Desktop.getDesktop().open(file.toFile())
}


Loading

0 comments on commit ab17965

Please sign in to comment.