Example 4: A ciw Model

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 ‘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.


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


import pandas as pd
# Import the wrapper objects for model interaction.
from examples.example_4_ciw.ex_4_ciw_model import Experiment, multiple_replications
from vidigi.utils import event_log_from_ciw_recs
from vidigi.animation import animate_activity_log
import plotly.io as pio
pio.renderers.default = "notebook"
import os
'''
CiW Implementation of the 111 call centre

Time units of the simulation model are in minutes.
'''
# Imports

import numpy as np
import pandas as pd
import ciw

# Module level variables, constants, and default values

# default resources
N_OPERATORS = 13

# number of nurses available
N_NURSES = 9

# default lambda for arrival distribution
MEAN_IAT = 100.0 / 60.0

## default service time parameters (triangular)
CALL_LOW = 5.0
CALL_MODE = 7.0
CALL_HIGH = 10.0

# nurse distribution parameters
NURSE_CALL_LOW = 10.0
NURSE_CALL_HIGH = 20.0

CHANCE_CALLBACK = 0.4

# run variables
RESULTS_COLLECTION_PERIOD = 1000


# Experiment class
class Experiment:
    '''
    Parameter class for 111 simulation model
    '''
    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):
        '''
        The init method sets up our defaults.
        '''
        self.n_operators = n_operators

        # store the number of nurses in the experiment
        self.n_nurses = n_nurses

        # arrival distribution
        self.arrival_dist = ciw.dists.Exponential(mean_iat)

        # call duration
        self.call_dist = ciw.dists.Triangular(call_low,
                                              call_mode, call_high)

        # duration of call with nurse
        self.nurse_dist = ciw.dists.Uniform(nurse_call_low,
                                            nurse_call_high)

        # prob of call back
        self.chance_callback = chance_callback

        # initialise results to zero
        self.init_results_variables()

    def init_results_variables(self):
        '''
        Initialise all of the experiment variables used in results
        collection.  This method is called at the start of each run
        of the model
        '''
        # variable used to store results of experiment
        self.results = {}
        self.results['waiting_times'] = []

        # total operator usage time for utilisation calculation.
        self.results['total_call_duration'] = 0.0

        # nurse sub process results collection
        self.results['nurse_waiting_times'] = []
        self.results['total_nurse_call_duration'] = 0.0


# Model code

def get_model(args):
    '''
    Build a CiW model using the arguments provided.

    Params:
    -----
    args: Experiment
        container class for Experiment. Contains the model inputs/params

    Returns:
    --------
    ciw.network.Network
    '''
    model = ciw.create_network(arrival_distributions=[args.arrival_dist,
                                                      ciw.dists.NoArrivals()],
                               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 model


# Model wrapper functions

def single_run(experiment,
               rc_period=RESULTS_COLLECTION_PERIOD,
               random_seed=None):
    '''
    Conduct a single run of the simulation model.

    Params:
    ------
    args: Scenario
        Parameter container

    random_seed: int
        Random seed to control simulation run.
    '''

    # results dictionary.  Each KPI is a new entry.
    run_results = {}

    # random seed
    ciw.seed(random_seed)

    # parameterise model
    model = get_model(experiment)

    # simulation engine
    sim_engine = ciw.Simulation(model)

    # run the model
    sim_engine.simulate_until_max_time(rc_period)

    # return processed results for run.

    # get all results
    recs = sim_engine.get_all_records()

    # operator service times
    op_servicetimes = [r.service_time for r in recs if r.node==1]
    # nurse service times
    nurse_servicetimes = [r.service_time for r in recs if r.node==2]

    # operator and nurse waiting times
    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]

    # mean measures
    run_results['01_mean_waiting_time'] = np.mean(op_waits)

    # end of run results: calculate mean operator utilisation
    run_results['02_operator_util'] = \
        (sum(op_servicetimes) / (rc_period * experiment.n_operators)) * 100.0

    # end of run results: nurse waiting time
    run_results['03_mean_nurse_waiting_time'] = np.mean(nurse_waits)

    # end of run results: calculate mean nurse utilisation
    run_results['04_nurse_util'] = \
        (sum(nurse_servicetimes) / (rc_period * experiment.n_nurses)) * 100.0

    # return the results from the run of the model
    return run_results, recs

def multiple_replications(experiment,
                          rc_period=RESULTS_COLLECTION_PERIOD,
                          n_reps=5):
    '''
    Perform multiple replications of the model.

    Params:
    ------
    experiment: Experiment
        The experiment/paramaters to use with model

    rc_period: float, optional (default=DEFAULT_RESULTS_COLLECTION_PERIOD)
        results collection period.
        the number of minutes to run the model to collect results

    n_reps: int, optional (default=5)
        Number of independent replications to run.

    Returns:
    --------
    pandas.DataFrame
    '''

    # loop over single run to generate results dicts in a python list.
    results = [single_run(experiment, rc_period)[0] for rep in range(n_reps)]
    logs = [single_run(experiment, rc_period)[1] for rep in range(n_reps)]

    # format and return results in a dataframe
    df_results = pd.DataFrame(results)
    df_results.index = np.arange(1, len(df_results)+1)
    df_results.index.name = 'rep'
    return df_results, logs
N_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)

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))
2248
# 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=0, original_customer_class=0, node=1, arrival_date=297.2080070711834, waiting_time=0.0, service_start_date=297.2080070711834, service_time=6.854649823920681, service_end_date=304.06265689510406, time_blocked=0.0, exit_date=304.06265689510406, destination=-1, queue_size_at_arrival=11, queue_size_at_departure=13, server_id=10, record_type='service')
[None]

Unlike SimPy, where we have to manually add our event logs at various points, we instead can make 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.

For each node, we 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_log_from_ciw_recs(logs_run_1, node_name_list=["operator", "nurse"])

event_log_test.head(25)
patient pathway event_type event time resource_id
0 1 Model arrival_departure arrival 0.301004 NaN
1 1 Model queue operator_wait_begins 0.301004 NaN
2 1 Model resource_use operator_begins 0.301004 1.0
3 1 Model resource_use operator_ends 7.691679 1.0
4 1 Model queue nurse_wait_begins 7.691679 NaN
5 1 Model resource_use nurse_begins 7.691679 1.0
6 1 Model resource_use nurse_ends 26.900932 1.0
7 1 Model arrival_departure depart 26.900932 NaN
8 2 Model arrival_departure arrival 1.712850 NaN
9 2 Model queue operator_wait_begins 1.712850 NaN
10 2 Model resource_use operator_begins 1.712850 2.0
11 2 Model resource_use operator_ends 8.540378 2.0
12 2 Model arrival_departure depart 8.540378 NaN
13 3 Model arrival_departure arrival 2.222167 NaN
14 3 Model queue operator_wait_begins 2.222167 NaN
15 3 Model resource_use operator_begins 2.222167 3.0
16 3 Model resource_use operator_ends 10.025845 3.0
17 3 Model arrival_departure depart 10.025845 NaN
18 4 Model arrival_departure arrival 2.398089 NaN
19 4 Model queue operator_wait_begins 2.398089 NaN
20 4 Model resource_use operator_begins 2.398089 4.0
21 4 Model resource_use operator_ends 8.974532 4.0
22 4 Model arrival_departure depart 8.974532 NaN
23 5 Model arrival_departure arrival 3.028583 NaN
24 5 Model queue operator_wait_begins 3.028583 NaN

Now we need to create a suitable class to pass in the resource numbers to the animation function.

# Create a suitable class to pass in the resource numbers to the animation function
class model_params():
    def __init__(self):
        self.n_operators = N_OPERATORS
        self.n_nurses = N_NURSES

params = model_params()

print(f"Number of operators: {params.n_operators}")
print(f"Number of nurses: {params.n_nurses}")
Number of operators: 18
Number of nurses: 9

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

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 t 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 = pd.DataFrame([
                    {'event': 'arrival',
                     'x':  30, 'y': 350,
                     'label': "Arrival"},

                    {'event': 'operator_wait_begins',
                     'x':  205, 'y': 270,
                     'label': "Waiting for Operator"},

                    {'event': 'operator_begins',
                     'x':  210, 'y': 210,
                     'resource':'n_operators',
                     'label': "Speaking to operator"},

                    {'event': 'nurse_wait_begins',
                     'x':  205, 'y': 110,
                     'label': "Waiting for Nurse"},

                    {'event': 'nurse_begins',
                     'x':  210, 'y': 50,
                     'resource':'n_nurses',
                     'label': "Speaking to Nurse"},

                    {'event': 'exit',
                     'x':  270, 'y': 10,
                     'label': "Exit"}

                ])

event_position_df
event x y label resource
0 arrival 30 350 Arrival NaN
1 operator_wait_begins 205 270 Waiting for Operator NaN
2 operator_begins 210 210 Speaking to operator n_operators
3 nurse_wait_begins 205 110 Waiting for Nurse NaN
4 nurse_begins 210 50 Speaking to Nurse n_nurses
5 exit 270 10 Exit NaN

Finally, we can create the animation.

# Create animation
params = model_params()

animate_activity_log(
        event_log=event_log_test,
        event_position_df= event_position_df,
        scenario=model_params(),
        debug_mode=True,
        setup_mode=False,
        every_x_time_units=1,
        include_play_button=True,
        icon_and_text_size=20,
        gap_between_entities=8,
        gap_between_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 00:25:28
Iteration through minute-by-minute logs complete 00:25:33
Snapshot df concatenation complete at 00:25:33
Reshaped animation dataframe finished construction at 00:25:33
Placement dataframe finished construction at 00:25:34
Output animation generation complete at 00:25:39
Total Time Elapsed: 11.07 seconds