# Import the wrapper objects for model interaction.
from ex_4_ciw_model import Experiment, multiple_replications
from vidigi.ciw import event_logger_from_ciw_recs, trial_logger_from_ciw_recs
from vidigi.utils import EventPosition, create_event_position_dfA ciw 2.x example
Note that this example is written using ciw 2.x
It will not run with 3.x - but could theoretically be adapted to do so
The underlying model code is from Monks, T., Harper, A., & Heather, A. (2023). Towards Sharing Tools, Artefacts, and Reproducible Simulation: a ciw model example (v1.0.1). Zenodo. https://doi.org/10.5281/zenodo.10051494
See here for the adaptation embedded within that repo: https://github.com/Bergam0t/ciw-example-animation/tree/main
In SimPy models, we have to manually add our event logs at various points. However, for Ciw models, we instead can make use of the event_log_from_ciw_recs helper function from vidigi.utils to automatically reshape the logs ciw generates into the correct format for vidigi to work with.
Let’s start by running the model and viewing the logs ciw outputs.
"""
CiW Implementation of the 111 call centre
Time units of the simulation model are in minutes.
"""
# Imports
import ciw
import numpy as np
import pandas as pd
# Module level variables, constants, and default values
N_OPERATORS = 13
N_NURSES = 9
MEAN_IAT = 100.0 / 60.0
CALL_LOW = 5.0
CALL_MODE = 7.0
CALL_HIGH = 10.0
NURSE_CALL_LOW = 10.0
NURSE_CALL_HIGH = 20.0
CHANCE_CALLBACK = 0.4
RESULTS_COLLECTION_PERIOD = 1000
# Experiment class
class Experiment:
def __init__(
self,
n_operators=N_OPERATORS,
n_nurses=N_NURSES,
mean_iat=MEAN_IAT,
call_low=CALL_LOW,
call_mode=CALL_MODE,
call_high=CALL_HIGH,
chance_callback=CHANCE_CALLBACK,
nurse_call_low=NURSE_CALL_LOW,
nurse_call_high=NURSE_CALL_HIGH,
random_seed=None,
):
self.n_operators = n_operators
self.n_nurses = n_nurses
self.arrival_dist = ciw.dists.Exponential(mean_iat)
self.call_dist = ciw.dists.Triangular(call_low, call_mode, call_high)
self.nurse_dist = ciw.dists.Uniform(nurse_call_low, nurse_call_high)
self.chance_callback = chance_callback
self.init_results_variables()
def init_results_variables(self):
self.results = {
"waiting_times": [],
"total_call_duration": 0.0,
"nurse_waiting_times": [],
"total_nurse_call_duration": 0.0,
}
# Model code
def get_model(args):
"""
Build a CiW model using the arguments provided.
"""
network = ciw.create_network(
arrival_distributions=[args.arrival_dist, None],
service_distributions=[args.call_dist, args.nurse_dist],
routing=[[0.0, args.chance_callback], [0.0, 0.0]],
number_of_servers=[args.n_operators, args.n_nurses],
)
return network
# Model wrapper functions
def single_run(experiment, rc_period=RESULTS_COLLECTION_PERIOD, random_seed=None):
run_results = {}
ciw.seed(random_seed)
model = get_model(experiment)
sim_engine = ciw.Simulation(model)
sim_engine.simulate_until_max_time(rc_period)
recs = sim_engine.get_all_records()
op_servicetimes = [r.service_time for r in recs if r.node == 1]
nurse_servicetimes = [r.service_time for r in recs if r.node == 2]
op_waits = [r.waiting_time for r in recs if r.node == 1]
nurse_waits = [r.waiting_time for r in recs if r.node == 2]
run_results["01_mean_waiting_time"] = np.mean(op_waits)
run_results["02_operator_util"] = (
sum(op_servicetimes) / (rc_period * experiment.n_operators)
) * 100.0
run_results["03_mean_nurse_waiting_time"] = np.mean(nurse_waits)
run_results["04_nurse_util"] = (
sum(nurse_servicetimes) / (rc_period * experiment.n_nurses)
) * 100.0
return run_results, recs
def multiple_replications(experiment, rc_period=RESULTS_COLLECTION_PERIOD, n_reps=5):
results = []
logs = []
for rep in range(n_reps):
run_result, log = single_run(experiment, rc_period)
results.append(run_result)
logs.append(log)
df_results = pd.DataFrame(results)
df_results.index = np.arange(1, len(df_results) + 1)
df_results.index.name = "rep"
return df_results, logsN_OPERATORS = 18
N_NURSES = 9
RESULTS_COLLECTION_PERIOD = 1000
user_experiment = Experiment(
n_operators=N_OPERATORS, n_nurses=N_NURSES, chance_callback=0.4
)
# run multiple replications
results, logs = multiple_replications(user_experiment, n_reps=10)Here, the ‘logs’ object is the result of running sim_engine.get_all_records()
However, note that while we run multiple replications, we only pass the records for a single replication to the event_log_from_ciw_recs function.
While we’ve done multiple replications, for the purpose of the animation we want only a single set of logs, so we will extract those from the logs variable we created.
# the 'logs' object contains a list, where each entry is the recs object for that run
logs_run_1 = logs[0]
print(len(logs_run_1))2227
Let’s look at the first row of our result. What do we have?
logs[0][0]Record(id_number=1533, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=928.2665472465916, waiting_time=0.4604764322907613, service_start_date=928.7270236788823, service_time=5.280845677909156, service_end_date=934.0078693567915, time_blocked=0.0, exit_date=934.0078693567915, destination=2, queue_size_at_arrival=18, queue_size_at_departure=17, server_id=3, record_type='service')
Let’s print all of the outputs for a single individual.
[print(log) for log in logs_run_1 if log.id_number == 500]Record(id_number=500, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=282.0604797501058, waiting_time=0.0, service_start_date=282.0604797501058, service_time=6.668194741388618, service_end_date=288.7286744914944, time_blocked=0.0, exit_date=288.7286744914944, destination=2, queue_size_at_arrival=12, queue_size_at_departure=18, server_id=13, record_type='service')
Record(id_number=500, customer_class='Customer', original_customer_class='Customer', node=2, arrival_date=288.7286744914944, waiting_time=30.463225055971407, service_start_date=319.1918995474658, service_time=18.31678922087241, service_end_date=337.50868876833823, time_blocked=0.0, exit_date=337.50868876833823, destination=-1, queue_size_at_arrival=25, queue_size_at_departure=28, server_id=9, record_type='service')
[None, None]
It looks like we get one entry per node that is visited.
Let’s now use some functions vidigi provides to turn it into a format that can be used for our animation.
The _from_ciw_recs family of functions
Prior to vidigi 2.0.0, we would have made use of the event_log_from_ciw_recs helper function from vidigi.utils to automatically reshape ciw logs into the correct format for vidigi to work with. This would return the ciw logs as a dataframe in the format that vidigi can use. However, from v2.0.0, the recommendation has changed to using event_logger_from_ciw_recs returning a vidigi EventLogger object, which opens up a wide range of additional visualisations and works more comfortably with the vidigi animation functions. v2.0.0 also introduced the new trial_log_from_ciw_recs, which we will explore as well.
First, we’ll have a go with event_logger_from_ciw_recs. We earlier imported this from the ciw submodule of vidigi with from vidigi.ciw import event_logger_from_ciw_recs.
For each node, we must pass in an appropriate name. Vidigi will use these and append ’_begins’ and ’_ends’, as well as calculating arrivals and departures from the model and creating resource IDs to allow it to correctly show the utilisation of a resource at each step.
# let's now try turning this into an event log
event_log_test = event_logger_from_ciw_recs(
logs_run_1, node_name_list=["operator", "nurse"]
)
event_log_test<vidigi.logging.EventLogger at 0x7f75b6f0e5a0>
Let’s use the .to_dataframe() method of our event logger to take a peek at the created dataframe.
event_log_test.to_dataframe().head(25)| entity_id | event_type | event | time | pathway | resource_id | |
|---|---|---|---|---|---|---|
| 0 | 1 | arrival_departure | arrival | 1.169860 | Model | NaN |
| 1 | 1 | queue | operator_wait_begins | 1.169860 | Model | NaN |
| 2 | 1 | resource_use | operator_begins | 1.169860 | Model | 1.0 |
| 3 | 1 | resource_use_end | operator_ends | 7.470014 | Model | 1.0 |
| 4 | 1 | arrival_departure | depart | 7.470014 | Model | NaN |
| 5 | 2 | arrival_departure | arrival | 1.709839 | Model | NaN |
| 6 | 2 | queue | operator_wait_begins | 1.709839 | Model | NaN |
| 7 | 2 | resource_use | operator_begins | 1.709839 | Model | 2.0 |
| 8 | 2 | resource_use_end | operator_ends | 10.453672 | Model | 2.0 |
| 9 | 2 | arrival_departure | depart | 10.453672 | Model | NaN |
| 10 | 3 | arrival_departure | arrival | 2.103499 | Model | NaN |
| 11 | 3 | queue | operator_wait_begins | 2.103499 | Model | NaN |
| 12 | 3 | resource_use | operator_begins | 2.103499 | Model | 3.0 |
| 13 | 3 | resource_use_end | operator_ends | 9.301549 | Model | 3.0 |
| 14 | 3 | queue | nurse_wait_begins | 9.301549 | Model | NaN |
| 15 | 3 | resource_use | nurse_begins | 9.301549 | Model | 1.0 |
| 16 | 3 | resource_use_end | nurse_ends | 21.038331 | Model | 1.0 |
| 17 | 3 | arrival_departure | depart | 21.038331 | Model | NaN |
| 18 | 4 | arrival_departure | arrival | 3.516870 | Model | NaN |
| 19 | 4 | queue | operator_wait_begins | 3.516870 | Model | NaN |
| 20 | 4 | resource_use | operator_begins | 3.516870 | Model | 4.0 |
| 21 | 4 | resource_use_end | operator_ends | 11.508091 | Model | 4.0 |
| 22 | 4 | queue | nurse_wait_begins | 11.508091 | Model | NaN |
| 23 | 4 | resource_use | nurse_begins | 11.508091 | Model | 2.0 |
| 24 | 4 | resource_use_end | nurse_ends | 30.444661 | Model | 2.0 |
Let’s look at using TrialLogger.
trial_log = trial_logger_from_ciw_recs(
logs, node_name_list=["operator", "nurse"]
)trial_log<vidigi.logging.TrialLogger at 0x7f75b6993230>
trial_log.get_log_by_run(run=5, as_df=True).head(25)| entity_id | event_type | event | time | pathway | run_number | resource_id | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | arrival_departure | arrival | 0.447076 | Model | 5 | NaN |
| 1 | 1 | queue | operator_wait_begins | 0.447076 | Model | 5 | NaN |
| 2 | 1 | resource_use | operator_begins | 0.447076 | Model | 5 | 1.0 |
| 3 | 1 | resource_use_end | operator_ends | 6.542522 | Model | 5 | 1.0 |
| 4 | 1 | arrival_departure | depart | 6.542522 | Model | 5 | NaN |
| 5 | 2 | arrival_departure | arrival | 0.648398 | Model | 5 | NaN |
| 6 | 2 | queue | operator_wait_begins | 0.648398 | Model | 5 | NaN |
| 7 | 2 | resource_use | operator_begins | 0.648398 | Model | 5 | 2.0 |
| 8 | 2 | resource_use_end | operator_ends | 7.637025 | Model | 5 | 2.0 |
| 9 | 2 | arrival_departure | depart | 7.637025 | Model | 5 | NaN |
| 10 | 3 | arrival_departure | arrival | 1.661917 | Model | 5 | NaN |
| 11 | 3 | queue | operator_wait_begins | 1.661917 | Model | 5 | NaN |
| 12 | 3 | resource_use | operator_begins | 1.661917 | Model | 5 | 3.0 |
| 13 | 3 | resource_use_end | operator_ends | 10.216653 | Model | 5 | 3.0 |
| 14 | 3 | arrival_departure | depart | 10.216653 | Model | 5 | NaN |
| 15 | 4 | arrival_departure | arrival | 2.011635 | Model | 5 | NaN |
| 16 | 4 | queue | operator_wait_begins | 2.011635 | Model | 5 | NaN |
| 17 | 4 | resource_use | operator_begins | 2.011635 | Model | 5 | 4.0 |
| 18 | 4 | resource_use_end | operator_ends | 9.416627 | Model | 5 | 4.0 |
| 19 | 4 | arrival_departure | depart | 9.416627 | Model | 5 | NaN |
| 20 | 5 | arrival_departure | arrival | 2.371072 | Model | 5 | NaN |
| 21 | 5 | queue | operator_wait_begins | 2.371072 | Model | 5 | NaN |
| 22 | 5 | resource_use | operator_begins | 2.371072 | Model | 5 | 5.0 |
| 23 | 5 | resource_use_end | operator_ends | 9.474446 | Model | 5 | 5.0 |
| 24 | 5 | queue | nurse_wait_begins | 9.474446 | Model | 5 | NaN |
Using the TrialLogger class gets us access to a wide range of convenience plots and metrics in addition to the animation we will create shortly.
trial_log.plot_queue_size(
event_list=["operator_wait_begins"],
limit_duration=RESULTS_COLLECTION_PERIOD,
every_x_time_units=10
)Now we need to create a suitable class to pass in the resource numbers to the animation function.
Like with SimPy, we need to tell vidigi where to put each step on our plot. We will refer to the names we used - so as we named our nodes ‘operator’ and ‘nurse’, we will want
- arrival
- operator_wait_begins (to show queueing for the operator)
- operator_begins (to show resource use of the operator)
- nurse_wait_begins (to show queuing for the nurse after finishing being seen by the operator)
- nurse_begins (to show resource use of the nurse)
For the _begins steps, which relat to resource use, we will also pass in a name that relates to the number of resources we need, which we defined in our model_params class above.
So, for the operator_begins step, for example, we tell ut to look for n_operators, whch is one of the parameters in our model_params class. We pass the params class into the animation function.
# Create required event_position_df for vidigi animation
event_position_df = create_event_position_df(
[
EventPosition(
event="operator_wait_begins", x=205, y=270, label="Waiting for Operator"
),
EventPosition(
event="operator_begins",
x=210,
y=210,
resource="n_operators",
label="Speaking to Operator",
),
EventPosition(
event="nurse_wait_begins", x=205, y=110, label="Waiting for Nurse"
),
EventPosition(
event="nurse_begins",
x=210,
y=50,
resource="n_nurses",
label="Speaking to Nurse",
),
EventPosition(event="depart", x=270, y=10, label="Exit"),
]
)
event_position_df| event | x | y | label | resource | direction | flip_icons | resource_icon | |
|---|---|---|---|---|---|---|---|---|
| 0 | operator_wait_begins | 205 | 270 | Waiting for Operator | NaN | None | None | None |
| 1 | operator_begins | 210 | 210 | Speaking to Operator | n_operators | None | None | None |
| 2 | nurse_wait_begins | 205 | 110 | Waiting for Nurse | NaN | None | None | None |
| 3 | nurse_begins | 210 | 50 | Speaking to Nurse | n_nurses | None | None | None |
| 4 | depart | 270 | 10 | Exit | NaN | None | None | None |
Finally, we can create the animation.
We can access the main animation function animate_activity_log directly from our TrialLogger object (or an EventLogger object if we’d just done a single run).
We will pass in our resource counts as a dict to the scenario parameter.
trial_log.animate_activity_log(
run_number=5,
event_position_df=event_position_df,
scenario={'n_operators':N_OPERATORS, 'n_nurses': N_NURSES},
debug_mode=True,
setup_mode=False,
every_x_time_units=1,
include_play_button=True,
entity_icon_size=20,
gap_between_entities=8,
gap_between_queue_rows=25,
plotly_height=700,
frame_duration=200,
plotly_width=1200,
override_x_max=300,
override_y_max=300,
limit_duration=RESULTS_COLLECTION_PERIOD,
wrap_queues_at=25,
wrap_resources_at=50,
step_snapshot_max=75,
time_display_units="dhm",
display_stage_labels=True,
)Animation function called at 13:54:23
Iteration through time-unit-by-time-unit logs complete 13:54:31
Snapshot df concatenation complete at 13:54:31
Reshaped animation dataframe finished construction at 13:54:32
Placement dataframe started construction at 13:54:32
Placement dataframe finished construction at 13:54:32
Output animation generation complete at 13:54:37
Total Time Elapsed: 14.16 seconds