The Experiment class#

For a detailed walk through of building a basic Experiment class from scratch see the introduction to experiments notebook

The overall design of a class representing a experiment will depend on the simulation study, and to some extent user preference. Here we advocate two design principals:

  1. Make use of default values for input parameters, either from constant variables, or read in from file.

  2. Keep the code inside Experiment independent of the simulation software used.

Challenges with principal 2!

By independent we mean do not include any functions, classes or variables that are imported from a simulation package inside Experiment. However, what is stored in Experiment may vary depending on the simulation package that you are using.

1. Imports#

import numpy as np

2. Notebook level variables, constants, and default values#

Here we will create a set of constant/default values for our Experiment class, but you could also consider reading these in from a file.

# default resources
N_OPERATORS = 13

# number of nurses available
N_NURSES = 9

# default mean inter-arrival time (exp)
MEAN_IAT = 60 / 100

## 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

# Seeds for arrival and service time distributions (for repeatable single run)
ARRIVAL_SEED = 42
CALL_SEED = 101

# additional seeds for new activities
CALLBACK_SEED = 1966
NURSE_SEED = 2020

3. Distribution classes#

The model requires four distribution classes (Uniform,Bernoulli, Triangular, and Exponential) to encapsulate the random number generation, parameters and random seeds used in the sampling.

For an introduction to these concepts see the random sampling notebook

class Bernoulli():
    '''
    Convenience class for the Bernoulli distribution.
    packages up distribution parameters, seed and random generator.
    
    Use the Bernoulli distribution to sample success or failure.
    '''
    def __init__(self, p, random_seed=None):
        '''
        Constructor
        
        Params:
        ------
        p: float
            probability of drawing a 1
        
        random_seed: int, optional (default=None)
            A random seed to reproduce samples.  If set to none then a unique
            sample is created.
        '''
        self.rand = np.random.default_rng(seed=random_seed)
        self.p = p
        
    def sample(self, size=None):
        '''
        Generate a sample from the exponential distribution
        
        Params:
        -------
        size: int, optional (default=None)
            the number of samples to return.  If size=None then a single
            sample is returned.
        
        Returns:
        -------
        float or np.ndarray (if size >=1)
        '''
        return self.rand.binomial(n=1, p=self.p, size=size)
class Uniform():
    '''
    Convenience class for the Uniform distribution.
    packages up distribution parameters, seed and random generator.
    '''
    def __init__(self, low, high, random_seed=None):
        '''
        Constructor
        
        Params:
        ------
        low: float
            lower range of the uniform
            
        high: float
            upper range of the uniform
        
        random_seed: int, optional (default=None)
            A random seed to reproduce samples.  If set to none then a unique
            sample is created.
        '''
        self.rand = np.random.default_rng(seed=random_seed)
        self.low = low
        self.high = high
        
    def sample(self, size=None):
        '''
        Generate a sample from the exponential distribution
        
        Params:
        -------
        size: int, optional (default=None)
            the number of samples to return.  If size=None then a single
            sample is returned.
            
        Returns:
        -------
        float or np.ndarray (if size >=1)
        '''
        return self.rand.uniform(low=self.low, high=self.high, size=size)
class Triangular():
    '''
    Convenience class for the triangular distribution.
    packages up distribution parameters, seed and random generator.
    '''
    def __init__(self, low, mode, high, random_seed=None):
        '''
        Constructor. Accepts and stores parameters of the triangular dist
        and a random seed.
        
        Params:
        ------
        low: float
            The smallest values that can be sampled
            
        mode: float
            The most frequently sample value
            
        high: float
            The highest value that can be sampled
        
        random_seed: int, optional (default=None)
            Used with params to create a series of repeatable samples.
        '''
        self.rand = np.random.default_rng(seed=random_seed)
        self.low = low
        self.high = high
        self.mode = mode
        
    def sample(self, size=None):
        '''
        Generate one or more samples from the triangular distribution
        
        Params:
        --------
        size: int
            the number of samples to return.  If size=None then a single
            sample is returned.
            
        Returns:
        -------
        float or np.ndarray (if size >=1)
        '''
        return self.rand.triangular(self.low, self.mode, self.high, size=size)
class Exponential():
    '''
    Convenience class for the exponential distribution.
    packages up distribution parameters, seed and random generator.
    '''
    def __init__(self, mean, random_seed=None):
        '''
        Constructor
        
        Params:
        ------
        mean: float
            The mean of the exponential distribution
        
        random_seed: int, optional (default=None)
            A random seed to reproduce samples.  If set to none then a unique
            sample is created.
        '''
        self.rand = np.random.default_rng(seed=random_seed)
        self.mean = mean
        
    def sample(self, size=None):
        '''
        Generate a sample from the exponential distribution
        
        Params:
        -------
        size: int, optional (default=None)
            the number of samples to return.  If size=None then a single
            sample is returned.
            
        Returns:
        -------
        float or np.ndarray (if size >=1)
        '''
        return self.rand.exponential(self.mean, size=size)

As an example this is how you would create a Exponential distribution:

arrival_dist = Exponential(mean=10.0, random_seed=42)
arrival_dist.sample()
24.042086039659946

3. Experiment class#

The design below uses python’s optional arguments to provide default parameterisation for models. To modify parameters a user simply needs to pass in the appropriate argument when creating the model.

Note that the class only needs to represent an simulation experiment it does not necessarily need to be called Experiment. For example, in the past I regularly used Scenario.

Alternative design

Here we do not give the user the option to vary the type of sampling distribution used for activities in the model. However, these could be parameters for the model. This would reduce the number of arguments accepted by the constructor (pro!), but at the cost of requiring streamlit logic that is a little bit more complicated (con).

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,
                 arrival_seed=None, call_seed=None,
                 callback_seed=None, nurse_seed=None):
        '''
        The init method sets up our defaults, resource counts, distributions
        and result collection objects.
        '''
        # no. resources
        self.n_operators = n_operators
        self.n_nurses = n_nurses

        # create distribution objects
        self.arrival_dist = Exponential(mean_iat, random_seed=arrival_seed)
        self.call_dist = Triangular(call_low, call_mode, call_high, 
                                    random_seed=call_seed)
        
        self.callback_dist = Bernoulli(chance_callback, 
                                       random_seed=callback_seed)
        
        self.nurse_dist = Uniform(nurse_call_low, nurse_call_high, 
                                  random_seed=nurse_seed)

        # resources
        # these variable are placeholders. 
        self.operators = None
        self.nurses = None
        
        # 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

4. Example usage#

Usage of Experiment is very simple. For example to create the default experiment we use the following code:

default_experiment = Experiment()
# check number of nurses
default_experiment.n_nurses
9
# sample from the arrival distribution
default_experiment.arrival_dist.sample()
0.14771155939684844

Remember that default_experiment is an instance of the class Experiment. It is an object. This means we can easily create multiple experiments each with different parameters.

default_experiment = Experiment()
extra_operator = Experiment(n_operators=14)
extra_operator_and_nurse = Experiment(n_operators=14, n_nurses=10)
print(default_experiment.n_operators)
print(default_experiment.n_nurses)
13
9
print(extra_operator.n_operators)
print(extra_operator.n_nurses)
14
9
print(extra_operator_and_nurse.n_operators)
print(extra_operator_and_nurse.n_nurses)
14
10