A Simple 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


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.

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.ciw import event_log_from_ciw_recs
from vidigi.animation import animate_activity_log
from vidigi.utils import create_event_position_df, EventPosition
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

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, 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))
2220

Let’s look at the first row of our result. What do we have?

logs[0][0]
Record(id_number=1459, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=883.8533451001377, waiting_time=0.0, service_start_date=883.8533451001377, service_time=7.455646510453107, service_end_date=891.3089916105909, time_blocked=0.0, exit_date=891.3089916105909, destination=2, queue_size_at_arrival=5, queue_size_at_departure=13, server_id=1, 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=313.5391166933884, waiting_time=0.0, service_start_date=313.5391166933884, service_time=7.516092086574929, service_end_date=321.0552087799633, time_blocked=0.0, exit_date=321.0552087799633, destination=-1, queue_size_at_arrival=12, queue_size_at_departure=17, server_id=11, record_type='service')
[None]

It looks like we get one entry per node that is visited.

As mentioned, we 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)
entity_id pathway event_type event time resource_id
0 1 Model arrival_departure arrival 0.861046 NaN
1 1 Model queue operator_wait_begins 0.861046 NaN
2 1 Model resource_use operator_begins 0.861046 1.0
3 1 Model resource_use_end operator_ends 8.849696 1.0
4 1 Model arrival_departure depart 8.849696 NaN
5 2 Model arrival_departure arrival 0.945221 NaN
6 2 Model queue operator_wait_begins 0.945221 NaN
7 2 Model resource_use operator_begins 0.945221 2.0
8 2 Model resource_use_end operator_ends 8.559661 2.0
9 2 Model queue nurse_wait_begins 8.559661 NaN
10 2 Model resource_use nurse_begins 8.559661 2.0
11 2 Model resource_use_end nurse_ends 24.479248 2.0
12 2 Model arrival_departure depart 24.479248 NaN
13 3 Model arrival_departure arrival 0.959145 NaN
14 3 Model queue operator_wait_begins 0.959145 NaN
15 3 Model resource_use operator_begins 0.959145 3.0
16 3 Model resource_use_end operator_ends 8.703014 3.0
17 3 Model queue nurse_wait_begins 8.703014 NaN
18 3 Model resource_use nurse_begins 8.703014 4.0
19 3 Model resource_use_end nurse_ends 22.017077 4.0
20 3 Model arrival_departure depart 22.017077 NaN
21 4 Model arrival_departure arrival 2.053677 NaN
22 4 Model queue operator_wait_begins 2.053677 NaN
23 4 Model resource_use operator_begins 2.053677 4.0
24 4 Model resource_use_end operator_ends 11.359563 4.0

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 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
0 operator_wait_begins 205 270 Waiting for Operator None
1 operator_begins 210 210 Speaking to Operator n_operators
2 nurse_wait_begins 205 110 Waiting for Nurse None
3 nurse_begins 210 50 Speaking to Nurse n_nurses
4 depart 270 10 Exit None

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,
        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 08:51:35
Iteration through time-unit-by-time-unit logs complete 08:51:41
Snapshot df concatenation complete at 08:51:41
Reshaped animation dataframe finished construction at 08:51:41
Placement dataframe started construction at 08:51:41
Placement dataframe finished construction at 08:51:41
Output animation generation complete at 08:51:47
Total Time Elapsed: 11.73 seconds
Back to top