From 83c72c106ad8569ba0f5d45e11986203000383e4 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 11 Apr 2024 17:19:56 +0100 Subject: [PATCH 01/31] writing failing test for system capacity --- ciw/tests/test_simulation.py | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index ae6122d..634d330 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -1326,3 +1326,51 @@ def test_names_for_customer_classes(self): mean_child_wait = sum(child_waits) / len(child_waits) self.assertEqual(round(mean_adult_wait, 8), 0.00301455) self.assertEqual(round(mean_child_wait, 8), 0.00208601) + + def test_system_capacity(self): + """ + Expected results would be: + + node id arrival_date current_capacity start_date end_date + ---------------------------------------------------------------- + 1 1 0.8 1 0.8 5.9 + 2 2 1.0 2 1.0 6.1 + 1 3 1.6 3 5.9 11.0 + 2 4 2.0 4 6.1 11.1 + 1 5 2.4 5 11.0 16.1 + 2 6 3.0 reject + 1 7 3.2 reject + 1, 2 8, 9 4.0 reject + 2 10 4.8 reject + 1 11 5.0 reject + 2 12 5.6 reject + 1 13 6.0 5 16.1 21.2 + 2 14 6.4 5 11.1 16.2 + 1 15 7.0 reject + """ + N = ciw.create_network( + arrival_distributions=[ciw.dists.Deterministic(0.8), ciw.dists.Deterministic(1)], + service_distributions=[ciw.dists.Deterministic(5.1), ciw.dists.Deterministic(5.1)], + routing=[[0.0, 0.0], [0.0, 0.0]], + number_of_servers=[1, 1], + system_capacity=5 + ) + Q = ciw.Simulation(N, tracker=ciw.trackers.SystemPopulation()) + Q.simulate_until_max_time(22) + recs_service = sorted(Q.get_all_records(only=['service']), key=lambda r: r.arrival_date) + recs_reject = sorted(Q.get_all_records(only=['rejection']), key=lambda r: r.arrival_date) + + expected_arrivals = [0.8, 1.0, 1.6, 2.0, 2.4, 6.0, 6.4] + expected_ssds = [0.8, 1.0, 5.9, 6.1, 11.0, 16.1, 11.1] + expected_seds = [5.9, 6.1, 11.0, 11.1, 16.1, 21.2, 16.2] + expected_nodes = [1, 2, 1, 2, 1, 1, 2] + expected_rejection_times = [3.0, 3.2, 4.0, 4.0, 4.8, 5.0, 5.6, 7.0] + + self.assertTrue(all([s[1] <= 5 for s in Q.statetracker.history])) + self.assertEqual([r.arrival_date for r in recs_service], expected_arrivals) + self.assertEqual([r.node for r in recs_service], expected_nodes) + self.assertEqual([r.service_start_date for r in recs_service], expected_ssds) + self.assertEqual([r.service_end_date for r in recs_service], expected_seds) + self.assertEqual([r.arrival_date for r in recs_reject if r.arrival_date <= 7.0], expected_rejection_times) + + From 7150683b26362f80bc260543afa4405cc29b58d9 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 12 Apr 2024 13:51:40 +0100 Subject: [PATCH 02/31] implement system capacity --- ciw/arrival_node.py | 3 +- ciw/import_params.py | 9 +++ ciw/simulation.py | 7 +++ ciw/tests/test_network.py | 20 +++++++ ciw/tests/test_simulation.py | 105 +++++++++++++++++++---------------- 5 files changed, 96 insertions(+), 48 deletions(-) diff --git a/ciw/arrival_node.py b/ciw/arrival_node.py index 52d8880..7df5030 100644 --- a/ciw/arrival_node.py +++ b/ciw/arrival_node.py @@ -28,6 +28,7 @@ def __init__(self, simulation): self.number_of_individuals_per_class = {clss: 0 for clss in self.simulation.network.customer_class_names} self.number_accepted_individuals = 0 self.number_accepted_individuals_per_class = {clss: 0 for clss in self.simulation.network.customer_class_names} + self.system_capacity = self.simulation.network.system_capacity self.event_dates_dict = { nd + 1: {clss: False for clss in self.simulation.network.customer_class_names } for nd in range(self.simulation.network.number_of_nodes) @@ -153,7 +154,7 @@ def release_individual(self, next_node, next_individual): Either rejects the next_individual die to lack of capacity, or sends that individual to baulk or not. """ - if next_node.number_of_individuals >= next_node.node_capacity: + if (next_node.number_of_individuals >= next_node.node_capacity) or (self.simulation.number_of_individuals >= self.system_capacity): self.record_rejection(next_node, next_individual) self.simulation.nodes[-1].accept(next_individual, completed=False) else: diff --git a/ciw/import_params.py b/ciw/import_params.py index 37dce67..1a5bc40 100644 --- a/ciw/import_params.py +++ b/ciw/import_params.py @@ -21,6 +21,7 @@ def create_network( reneging_time_distributions=None, reneging_destinations=None, service_disciplines=None, + system_capacity=float('inf') ): """ Takes in kwargs, creates dictionary. @@ -41,6 +42,7 @@ def create_network( "arrival_distributions": arrival_distributions, "number_of_servers": number_of_servers, "service_distributions": service_distributions, + 'system_capacity': system_capacity } if baulking_functions is not None: @@ -144,6 +146,7 @@ def create_network_from_dictionary(params_input): n.process_based = True else: n.process_based = False + n.system_capacity = params['system_capacity'] return n @@ -208,6 +211,7 @@ def fill_out_dictionary(params): "service_disciplines": [ ciw.disciplines.FIFO for _ in range(len(params["number_of_servers"])) ], + "system_capacity": float('inf') } for a in default_dict: @@ -352,3 +356,8 @@ def validify_dictionary(params): ) if not correct_destinations: raise ValueError("Ensure all reneging destinations are possible.") + + if not isinstance(params['system_capacity'], int) and params['system_capacity'] != float('inf'): + raise ValueError("Ensure system capacity is a positive integer.") + if params['system_capacity'] <= 0: + raise ValueError("Ensure system capacity is a positive integer.") diff --git a/ciw/simulation.py b/ciw/simulation.py index 5279550..a762460 100644 --- a/ciw/simulation.py +++ b/ciw/simulation.py @@ -66,6 +66,13 @@ def __repr__(self): """ return self.name + @property + def number_of_individuals(self): + """ + The number of individuals currently in the system. + """ + return sum(n.number_of_individuals for n in self.transitive_nodes) + def find_arrival_dists(self): """ Create the dictionary of arrival time distribution diff --git a/ciw/tests/test_network.py b/ciw/tests/test_network.py index 1fddbf5..fd88466 100644 --- a/ciw/tests/test_network.py +++ b/ciw/tests/test_network.py @@ -1024,3 +1024,23 @@ def test_raise_error_invalid_class_change_matrix(self): ciw.create_network(**params_geq1) with self.assertRaises(ValueError): ciw.create_network(**params_neg) + + def test_raising_errors_system_capacity(self): + params_neg = { + 'arrival_distributions': [ciw.dists.Exponential(10), ciw.dists.Exponential(10)], + 'service_distributions': [ciw.dists.Exponential(30), ciw.dists.Exponential(20)], + 'number_of_servers': [2, 2], + 'routing': [[0.0, 0.0], [0.0, 0.0]], + 'system_capacity': -4 + } + params_str = { + 'arrival_distributions': [ciw.dists.Exponential(10), ciw.dists.Exponential(10)], + 'service_distributions': [ciw.dists.Exponential(30), ciw.dists.Exponential(20)], + 'number_of_servers': [2, 2], + 'routing': [[0.0, 0.0], [0.0, 0.0]], + 'system_capacity': '77' + } + with self.assertRaises(ValueError): + ciw.create_network(**params_neg) + with self.assertRaises(ValueError): + ciw.create_network(**params_str) diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index 634d330..d9c45fe 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -1140,6 +1140,64 @@ def test_generic_deadlock_detector(self): DD = ciw.deadlock.NoDetection() self.assertEqual(DD.detect_deadlock(), False) + def test_system_capacity(self): + """ + Expected results would be: + + node id arrival_date current_capacity start_date end_date + ---------------------------------------------------------------- + 1 1 0.8 1 0.8 5.9 + 2 2 1.0 2 1.0 6.1 + 1 3 1.6 3 5.9 11.0 + 2 4 2.0 4 6.1 11.2 + 1 5 2.4 5 11.0 16.1 + 2 6 3.0 reject + 1 7 3.2 reject + 1,2 8,9 4.0 reject + 1 10 4.8 reject + 2 11 5.0 reject + 1 12 5.6 reject + 2 13 6.0 5 11.2 16.3 + 1 14 6.4 5 16.1 21.2 + 2 15 7.0 reject + 1 16 7.2 reject + 1,2 17,18 8.0 reject + 1 19 8.8 reject + 2 20 9.0 reject + 1 21 9.6 reject + 2 22 10.0 reject + 1 23 10.4 reject + 2 24 11.0 5 16.3 21.4 + 1 25 11.2 5 21.2 26.3 (incomplete) + 2 26 12.0 reject + 1 27 12.0 reject + """ + N = ciw.create_network( + arrival_distributions=[ciw.dists.Deterministic(0.8), ciw.dists.Deterministic(1)], + service_distributions=[ciw.dists.Deterministic(5.1), ciw.dists.Deterministic(5.1)], + routing=[[0.0, 0.0], [0.0, 0.0]], + number_of_servers=[1, 1], + system_capacity=5 + ) + ciw.seed(0) ## Set seed to exactly replicate simultaneous events + Q = ciw.Simulation(N, tracker=ciw.trackers.SystemPopulation()) + Q.simulate_until_max_time(22) + recs_service = sorted(Q.get_all_records(only=['service']), key=lambda r: r.arrival_date) + recs_reject = sorted(Q.get_all_records(only=['rejection']), key=lambda r: r.arrival_date) + + expected_arrivals = [0.8, 1.0, 1.6, 2.0, 2.4, 6.0, 6.4, 11.0] + expected_ssds = [0.8, 1.0, 5.9, 6.1, 11.0, 11.2, 16.1, 16.3] + expected_seds = [5.9, 6.1, 11.0, 11.2, 16.1, 16.3, 21.2, 21.4] + expected_nodes = [1, 2, 1, 2, 1, 2, 1, 2] + expected_first_10_rejection_times = [3.0, 3.2, 4.0, 4.0, 4.8, 5.0, 5.6, 7.0, 7.2, 8.0] + + self.assertTrue(all([s[1] <= 5 for s in Q.statetracker.history])) + self.assertEqual([round(r.arrival_date, 2) for r in recs_service], expected_arrivals) + self.assertEqual([r.node for r in recs_service], expected_nodes) + self.assertEqual([round(r.service_start_date, 2) for r in recs_service], expected_ssds) + self.assertEqual([round(r.service_end_date, 2) for r in recs_service], expected_seds) + self.assertEqual([round(r.arrival_date, 2) for r in recs_reject][:10], expected_first_10_rejection_times) + class TestServiceDisciplines(unittest.TestCase): def test_first_in_first_out(self): @@ -1327,50 +1385,3 @@ def test_names_for_customer_classes(self): self.assertEqual(round(mean_adult_wait, 8), 0.00301455) self.assertEqual(round(mean_child_wait, 8), 0.00208601) - def test_system_capacity(self): - """ - Expected results would be: - - node id arrival_date current_capacity start_date end_date - ---------------------------------------------------------------- - 1 1 0.8 1 0.8 5.9 - 2 2 1.0 2 1.0 6.1 - 1 3 1.6 3 5.9 11.0 - 2 4 2.0 4 6.1 11.1 - 1 5 2.4 5 11.0 16.1 - 2 6 3.0 reject - 1 7 3.2 reject - 1, 2 8, 9 4.0 reject - 2 10 4.8 reject - 1 11 5.0 reject - 2 12 5.6 reject - 1 13 6.0 5 16.1 21.2 - 2 14 6.4 5 11.1 16.2 - 1 15 7.0 reject - """ - N = ciw.create_network( - arrival_distributions=[ciw.dists.Deterministic(0.8), ciw.dists.Deterministic(1)], - service_distributions=[ciw.dists.Deterministic(5.1), ciw.dists.Deterministic(5.1)], - routing=[[0.0, 0.0], [0.0, 0.0]], - number_of_servers=[1, 1], - system_capacity=5 - ) - Q = ciw.Simulation(N, tracker=ciw.trackers.SystemPopulation()) - Q.simulate_until_max_time(22) - recs_service = sorted(Q.get_all_records(only=['service']), key=lambda r: r.arrival_date) - recs_reject = sorted(Q.get_all_records(only=['rejection']), key=lambda r: r.arrival_date) - - expected_arrivals = [0.8, 1.0, 1.6, 2.0, 2.4, 6.0, 6.4] - expected_ssds = [0.8, 1.0, 5.9, 6.1, 11.0, 16.1, 11.1] - expected_seds = [5.9, 6.1, 11.0, 11.1, 16.1, 21.2, 16.2] - expected_nodes = [1, 2, 1, 2, 1, 1, 2] - expected_rejection_times = [3.0, 3.2, 4.0, 4.0, 4.8, 5.0, 5.6, 7.0] - - self.assertTrue(all([s[1] <= 5 for s in Q.statetracker.history])) - self.assertEqual([r.arrival_date for r in recs_service], expected_arrivals) - self.assertEqual([r.node for r in recs_service], expected_nodes) - self.assertEqual([r.service_start_date for r in recs_service], expected_ssds) - self.assertEqual([r.service_end_date for r in recs_service], expected_seds) - self.assertEqual([r.arrival_date for r in recs_reject if r.arrival_date <= 7.0], expected_rejection_times) - - From 1147be6233b606fb326a1f220444f5e608b0c736 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 12 Apr 2024 15:12:10 +0100 Subject: [PATCH 03/31] calculate number of individuals in system in a simpler way --- ciw/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ciw/simulation.py b/ciw/simulation.py index a762460..7e92831 100644 --- a/ciw/simulation.py +++ b/ciw/simulation.py @@ -71,7 +71,7 @@ def number_of_individuals(self): """ The number of individuals currently in the system. """ - return sum(n.number_of_individuals for n in self.transitive_nodes) + return (self.nodes[0].number_of_individuals - 1) - self.nodes[-1].number_of_individuals def find_arrival_dists(self): """ From 6c90235dd46da9806ef2a8bc0b9f29e3cabb9335 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Mon, 15 Apr 2024 12:07:07 +0100 Subject: [PATCH 04/31] write docs for system capacity --- docs/Guides/index.rst | 1 + docs/Guides/system_capacity.rst | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 docs/Guides/system_capacity.rst diff --git a/docs/Guides/index.rst b/docs/Guides/index.rst index d83814f..2e0166c 100644 --- a/docs/Guides/index.rst +++ b/docs/Guides/index.rst @@ -26,6 +26,7 @@ Contents: slotted.rst server_priority.rst dynamic_customerclasses.rst + system_capacity.rst state_trackers.rst deadlock.rst process_based.rst diff --git a/docs/Guides/system_capacity.rst b/docs/Guides/system_capacity.rst new file mode 100644 index 0000000..d7eeea3 --- /dev/null +++ b/docs/Guides/system_capacity.rst @@ -0,0 +1,31 @@ +.. system-capacity: + +=================================================== +How to Set a Maximium Capacity for the Whole System +=================================================== + +We have seen that :ref:`node capacities<_tutorial-vi>` can define restricted queueing networks. Ciw also allows for a whole system capacity to be set. When a system capacity is set, when the total number of customers present in *all* the nodes of the system is equal to the system capacity, then newly arriving customers will be rejected. Once the total number of customers drops back below the system capacity, then customers will be accepted into the system again. + +In order to implement this, we use the :code:`system_capacity` keyworks when creating the network:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=1)], + ... service_distributions=[ciw.dists.Exponential(rate=1), + ... ciw.dists.Exponential(rate=1)], + ... routing=[[0.0, 0.5], + ... [0.0, 0.0]], + ... number_of_servers=[3, 2], + ... system_capacity=4 + ... ) + +In this case, the total capacity of nodes 1 and 2 is 4, and the system will never have more than 4 individuals. To see this, let's run this with a :ref:`state tracker` and see that the system never reaches more than 4 people:: + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N, tracker=ciw.trackers.SystemPopulation()) + >>> Q.simulate_until_max_time(100) + >>> state_probs = Q.statetracker.state_probabilities() + >>> state_probs + {0: 0.03369655546653017, 1: 0.1592711312247873, 2: 0.18950832844418355, 3: 0.2983478656854591, 4: 0.31917611917904} + From b27ac5e641e615f26fe483e85e51bd859d40c3e3 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Mon, 15 Apr 2024 14:00:43 +0100 Subject: [PATCH 05/31] implement GroupedNodePopulation state tracker --- ciw/tests/test_state_tracker.py | 90 +++++++++++++++++++++++++++++++++ ciw/trackers/state_tracker.py | 57 +++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/ciw/tests/test_state_tracker.py b/ciw/tests/test_state_tracker.py index d048271..e904c01 100644 --- a/ciw/tests/test_state_tracker.py +++ b/ciw/tests/test_state_tracker.py @@ -1922,3 +1922,93 @@ def test_nodeclassmatrix_accept_method_within_simulation_custnames(self): [0, 0, 1], [0, 0, 0]] ) + +class TestGroupedNodePopulation(unittest.TestCase): + def test_groupednodepop_init_method(self): + Q = ciw.Simulation(N_params) + B = ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]]) + B.initialise(Q) + self.assertEqual(B.simulation, Q) + self.assertEqual(B.state, [0, 0]) + + def test_groupednodepop_change_state_accept_method(self): + Q = ciw.Simulation(N_params) + B = ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]]) + B.initialise(Q) + N = Q.nodes[1] + ind = ciw.Individual(1) + self.assertEqual(B.state, [0, 0]) + B.change_state_accept(N, ind) + self.assertEqual(B.state, [1, 0]) + + def test_groupednodepop_change_state_block_method(self): + Q = ciw.Simulation(N_params) + B = ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]]) + B.initialise(Q) + N1 = Q.nodes[1] + N2 = Q.nodes[2] + ind = ciw.Individual(1) + B.state = [1, 0] + B.change_state_block(N1, N2, ind) + self.assertEqual(B.state, [1, 0]) + + def test_groupednodepop_change_state_release_method(self): + Q = ciw.Simulation(N_params) + B = ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]]) + B.initialise(Q) + N = Q.nodes[1] + N2 = Q.nodes[2] + Nex = Q.nodes[-1] + ind1 = ciw.Individual(1) + ind2 = ciw.Individual(2) + ind3 = ciw.Individual(3) + B.state = [12, 3] + B.change_state_release(N, Nex, ind1, False) + self.assertEqual(B.state, [11, 3]) + B.change_state_release(N, Nex, ind2, True) + self.assertEqual(B.state, [10, 3]) + B.change_state_release(N2, Nex, ind3, True) + self.assertEqual(B.state, [10, 2]) + + def test_groupednodepop_hash_state_method(self): + Q = ciw.Simulation(N_params) + B = ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]]) + B.initialise(Q) + B.state = [7, 3] + self.assertEqual(B.hash_state(), (7, 3)) + + def test_groupednodepop_release_method_within_simulation(self): + Q = ciw.Simulation(N_params, tracker=ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]])) + N = Q.transitive_nodes[2] + inds = [ciw.Individual(i, 'Class 0') for i in range(5)] + N.individuals = [inds] + for ind in N.individuals[0]: + srvr = N.find_free_server(ind) + N.attach_server(srvr, ind) + Q.statetracker.state = [11, 3] + self.assertEqual(Q.statetracker.state, [11, 3]) + Q.current_time = 43.11 + N.release(N.all_individuals[0], Q.nodes[1]) + self.assertEqual(Q.statetracker.state, [11, 3]) + N.all_individuals[1].is_blocked = True + Q.current_time = 46.72 + N.release(N.all_individuals[1], Q.nodes[1]) + self.assertEqual(Q.statetracker.state, [11, 3]) + N.release(N.all_individuals[1], Q.nodes[-1]) + self.assertEqual(Q.statetracker.state, [10, 3]) + + def test_groupednodepop_block_method_within_simulation(self): + Q = ciw.Simulation(N_params, tracker=ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]])) + N = Q.transitive_nodes[2] + Q.statetracker.state = [11, 3] + self.assertEqual(Q.statetracker.state, [11, 3]) + N.block_individual(ciw.Individual(1), Q.nodes[1]) + self.assertEqual(Q.statetracker.state, [11, 3]) + + def test_groupednodepop_accept_method_within_simulation(self): + Q = ciw.Simulation(N_params, tracker=ciw.trackers.GroupedNodePopulation(groups=[[0, 2, 3], [1]])) + N = Q.transitive_nodes[2] + self.assertEqual(Q.statetracker.state, [0, 0]) + Q.current_time = 45.6 + N.accept(ciw.Individual(3, 'Class 2')) + self.assertEqual(Q.statetracker.state, [1, 0]) diff --git a/ciw/trackers/state_tracker.py b/ciw/trackers/state_tracker.py index c9c9b5f..5fdc06b 100644 --- a/ciw/trackers/state_tracker.py +++ b/ciw/trackers/state_tracker.py @@ -242,6 +242,63 @@ def hash_state(self): return tuple(self.state) +class GroupedNodePopulation(StateTracker): + """ + The node population tracker records the number of customers at each group + of nodes, where node groups are defined by the user. + + Example: + (3, 1) + This denotes 3 customers at the first node group, and 1 customer at the + second node group. + """ + + def __init__(self, groups): + """ + Pre-initialises the object with keyword `observed_nodes` + """ + self.groups = groups + self.observed_nodes = [nd for group in groups for nd in group] + + def initialise(self, simulation): + """ + Initialises the state tracker class. + """ + self.simulation = simulation + self.state = [0 for i in self.groups] + self.history = [[self.simulation.current_time, self.hash_state()]] + + def change_state_accept(self, node, ind): + """ + Changes the state of the system when a customer is accepted. + """ + if node.id_number - 1 in self.observed_nodes: + node_in_group = [(node.id_number - 1) in group for group in self.groups] + group_index = node_in_group.index(True) + self.state[group_index] += 1 + + def change_state_block(self, node, destination, ind): + """ + Changes the state of the system when a customer gets blocked. + """ + pass + + def change_state_release(self, node, destination, ind, blocked): + """ + Changes the state of the system when a customer is released. + """ + if node.id_number - 1 in self.observed_nodes: + node_in_group = [(node.id_number - 1) in group for group in self.groups] + group_index = node_in_group.index(True) + self.state[group_index] -= 1 + + def hash_state(self): + """ + Returns a hashable state. + """ + return tuple(self.state) + + class NodeClassMatrix(StateTracker): """ The node-class matrix tracker records the number of customers of each From 10999d52670def90748634b67ed6ba71d4dc6570 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Mon, 15 Apr 2024 14:08:34 +0100 Subject: [PATCH 06/31] add grouped node tracker docs --- docs/Reference/state_trackers.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/Reference/state_trackers.rst b/docs/Reference/state_trackers.rst index a72ba43..ecd968b 100644 --- a/docs/Reference/state_trackers.rst +++ b/docs/Reference/state_trackers.rst @@ -9,6 +9,7 @@ Currently Ciw has the following state trackers: - :ref:`population` - :ref:`nodepop` - :ref:`nodepopsubset` +- :ref:`groupednodepop` - :ref:`nodeclssmatrix` - :ref:`naiveblock` - :ref:`matrixblock` @@ -68,6 +69,24 @@ The Simulation object takes in the optional argument :code:`tracker`, which take >>> Q = ciw.Simulation(N, tracker=ciw.trackers.NodePopulationSubset([0, 1, 4])) # doctest:+SKIP +.. _groupednodepop: + +--------------------------------- +The GroupedNodePopulation Tracker +--------------------------------- + +The GroupedNodePopulation Tracker, similar to the NodePopulation Tracker, records the number of customers at each node. However this allows users to group nodes together into one state. +States take the form of list of numbers. An example of tracking a queueing network with three grouped nodes is shown below:: + + (2, 0, 5) + +This denotes that there are two customers at the first group of nodes, no customers at the second group of nodes, and five customers at the third group of nodes. + +The Simulation object takes in the optional argument :code:`tracker`, which takes an argument :code:`groups` a list of groups of nodes (a list of lists, nodes in the same list are in the same group), used as follows (the first group observes the total population in the 1st and 7th nodes, the second group observed the population of the 4th node, and the 3th group observes the population in the 2nd, 3rd, 5th and 6th nodes:: + + >>> Q = ciw.Simulation(N, tracker=ciw.trackers.GroupedNodePopulation(groups=[[0, 6], [3], [1, 2, 4, 5]])) # doctest:+SKIP + + .. _nodeclssmatrix: --------------------------- From 63247d8837cd1aa9f7f0209d617328077e601a95 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Mon, 15 Apr 2024 23:39:58 +0100 Subject: [PATCH 07/31] implement an offset for schedules and slotted schedules --- ciw/schedules.py | 27 ++++++++---- ciw/tests/test_node.py | 6 +++ ciw/tests/test_processor_sharing.py | 13 +----- ciw/tests/test_scheduling.py | 67 +++++++++++++++++++++++++++++ ciw/tests/test_simulation.py | 4 +- 5 files changed, 96 insertions(+), 21 deletions(-) diff --git a/ciw/schedules.py b/ciw/schedules.py index a72a52e..95b39b6 100644 --- a/ciw/schedules.py +++ b/ciw/schedules.py @@ -37,7 +37,7 @@ class Schedule: get_next_shift() Updates the next shifts from the generator. """ - def __init__(self, schedule: List[Tuple[int, float]], preemption: Union[bool, str] = False) -> NoReturn: + def __init__(self, schedule: List[Tuple[int, float]], preemption: Union[bool, str] = False, offset: float = 0.0) -> NoReturn: """ Initializes the instance of the Schedule object. @@ -53,21 +53,27 @@ def __init__(self, schedule: List[Tuple[int, float]], preemption: Union[bool, st """ if preemption not in [False, 'resume', 'restart', 'resample']: raise ValueError("Pre-emption options should be either 'resume', 'restart', 'resample', or False.") + if not isinstance(offset, float): + raise ValueError("Offset should be a positive float.") + if offset < 0.0: + raise ValueError("Offset should be a positive float.") self.schedule_type = 'schedule' self.schedule_dates = [shift[1] for shift in schedule] self.schedule_servers = [shift[0] for shift in schedule] self.preemption = preemption self.cyclelength = self.schedule_dates[-1] + self.offset = offset def initialise(self) -> NoReturn: """ Initializes the generator object at the beginning of a simulation. """ + self.c = 0 + self.next_shift_change_date = self.offset self.next_c = self.schedule_servers[0] - self.schedule_generator = self.get_schedule_generator(self.schedule_dates, self.schedule_servers) - self.get_next_shift() + self.schedule_generator = self.get_schedule_generator(self.schedule_dates, self.schedule_servers, self.offset) - def get_schedule_generator(self, boundaries: List[float], values:List[int]) -> Generator[Tuple[float, int], None, None]: + def get_schedule_generator(self, boundaries:List[float], values:List[int], offset:float) -> Generator[Tuple[float, int], None, None]: """ A generator that yields the next time and number of servers according to a given schedule. @@ -88,7 +94,7 @@ def get_schedule_generator(self, boundaries: List[float], values:List[int]) -> G index = 0 date = 0 while True: - date = boundaries[index % num_boundaries] + ((index) // num_boundaries * self.cyclelength) + date = offset + boundaries[index % num_boundaries] + ((index) // num_boundaries * self.cyclelength) index += 1 yield date, values[index % num_boundaries] @@ -103,7 +109,7 @@ def get_next_shift(self) -> NoReturn: class Slotted(Schedule): - def __init__(self, slots, slot_sizes, capacitated=False, preemption=False): + def __init__(self, slots, slot_sizes, capacitated=False, preemption=False, offset=0.0): """ Initialises the instance of the Slotted Schedule object """ @@ -112,20 +118,25 @@ def __init__(self, slots, slot_sizes, capacitated=False, preemption=False): raise ValueError("Pre-emption options not availale for non-capacitated slots.") if preemption not in [False, 'resume', 'restart', 'resample']: raise ValueError("Pre-emption options should be either 'resume', 'restart', 'resample', or False.") + if not isinstance(offset, float): + raise ValueError("Offset should be a positive float.") + if offset < 0.0: + raise ValueError("Offset should be a positive float.") self.schedule_type = 'slotted' + self.offset = offset self.slots = slots self.slot_sizes = slot_sizes + self.next_slot_sizes = [self.slot_sizes[-1]] + self.slot_sizes[:-1] self.capacitated = capacitated self.preemption = preemption self.cyclelength = self.slots[-1] self.c = 0 - def initialise(self): """ Initialises the generator object at the beginning of a simulation """ - self.schedule_generator = self.get_schedule_generator(self.slots, [self.slot_sizes[-1]] + self.slot_sizes[:-1]) + self.schedule_generator = self.get_schedule_generator(self.slots, self.next_slot_sizes, self.offset) self.get_next_slot() def get_next_slot(self): diff --git a/ciw/tests/test_node.py b/ciw/tests/test_node.py index aaf131f..fa454f6 100644 --- a/ciw/tests/test_node.py +++ b/ciw/tests/test_node.py @@ -156,6 +156,8 @@ def test_init_method(self): ) Q = ciw.Simulation(N_schedule) N = Q.transitive_nodes[0] + N.have_event() + N.update_next_event_date() self.assertEqual(N.schedule.cyclelength, 100) self.assertEqual(N.c, 1) self.assertEqual(N.schedule.schedule_dates, [30, 60, 90, 100]) @@ -166,6 +168,8 @@ def test_init_method(self): Q = ciw.Simulation(N_priorities) N = Q.transitive_nodes[0] + N.have_event() + N.update_next_event_date() self.assertEqual(N.c, 4) self.assertEqual(Q.network.priority_class_mapping, {'Class 0': 0, 'Class 1': 1}) self.assertEqual(Q.number_of_priority_classes, 2) @@ -755,6 +759,7 @@ def test_reset_individual_attributes(self): def test_date_from_schedule_generator(self): sg = ciw.Schedule(schedule=[[1, 30], [0, 60], [2, 90], [3, 100]]) sg.initialise() + sg.get_next_shift() self.assertEqual(sg.c, 1) self.assertEqual(sg.next_c, 0) self.assertEqual(sg.next_shift_change_date, 30) @@ -765,6 +770,7 @@ def test_date_from_schedule_generator(self): self.assertEqual(next(sg.schedule_generator), (160, 2)) sg.initialise() + sg.get_next_shift() self.assertEqual(sg.c, 1) self.assertEqual(sg.next_c, 0) self.assertEqual(sg.next_shift_change_date, 30) diff --git a/ciw/tests/test_processor_sharing.py b/ciw/tests/test_processor_sharing.py index 6668151..f301026 100644 --- a/ciw/tests/test_processor_sharing.py +++ b/ciw/tests/test_processor_sharing.py @@ -137,19 +137,10 @@ def test_init_method(self): self.assertEqual(N2.class_change, {'Class 0': {'Class 0': 1.0, 'Class 1': 0.0}, 'Class 1': {'Class 0': 0.0, 'Class 1': 1.0}}) self.assertEqual(N.interrupted_individuals, []) - Q = ciw.Simulation(N_schedule, node_class=ciw.PSNode) - N = Q.transitive_nodes[0] - self.assertEqual(N.schedule.cyclelength, 100) - self.assertEqual(N.ps_capacity, 1) - self.assertEqual(N.c, float("inf")) - self.assertEqual(N.schedule.schedule_dates, [30, 60, 90, 100]) - self.assertEqual(N.schedule.schedule_servers, [1, 2, 1, 3]) - self.assertEqual(N.next_event_date, 30) - self.assertEqual(N.interrupted_individuals, []) - self.assertEqual(N.last_occupancy, 0) - Q = ciw.Simulation(N_priorities, node_class=ciw.PSNode) N = Q.transitive_nodes[0] + N.have_event() + N.update_next_event_date() self.assertEqual(N.ps_capacity, 4) self.assertEqual(N.c, float("inf")) self.assertEqual(Q.network.priority_class_mapping, {'Class 0': 0, 'Class 1': 1}) diff --git a/ciw/tests/test_scheduling.py b/ciw/tests/test_scheduling.py index b74ea38..4071d27 100644 --- a/ciw/tests/test_scheduling.py +++ b/ciw/tests/test_scheduling.py @@ -23,6 +23,7 @@ class TestScheduling(unittest.TestCase): def test_change_shift_method(self): Q = ciw.Simulation(N_schedule) N = Q.transitive_nodes[0] + N.have_event() N.next_event_date = 30 self.assertEqual([str(obs) for obs in N.servers], ["Server 1 at Node 1"]) self.assertEqual([obs.busy for obs in N.servers], [False]) @@ -63,6 +64,7 @@ def test_change_shift_method(self): def test_take_servers_off_duty_method(self): Q = ciw.Simulation(N_schedule) N = Q.transitive_nodes[0] + N.have_event() N.add_new_servers(3) self.assertEqual( [str(obs) for obs in N.servers], @@ -106,6 +108,7 @@ def test_take_servers_off_duty_method(self): def test_kill_server_method(self): Q = ciw.Simulation(N_schedule) N = Q.transitive_nodes[0] + N.have_event() s = N.servers[0] self.assertEqual([str(obs) for obs in N.servers], ["Server 1 at Node 1"]) N.kill_server(s) @@ -128,6 +131,7 @@ def test_kill_server_method(self): def test_add_new_servers_method(self): Q = ciw.Simulation(N_schedule) N = Q.transitive_nodes[0] + N.have_event() self.assertEqual([str(obs) for obs in N.servers], ["Server 1 at Node 1"]) N.add_new_servers(3) self.assertEqual( @@ -179,6 +183,7 @@ def test_take_servers_off_duty_preempt_method(self): N.service_centres[0].number_of_servers.preemption = 'resample' Q = ciw.Simulation(N) N = Q.transitive_nodes[0] + N.have_event() N.add_new_servers(3) self.assertEqual( [str(obs) for obs in N.servers], @@ -628,6 +633,30 @@ def test_slotted_services(self): self.assertEqual(observed_service_dates, expected_service_dates) self.assertFalse(any([ind.server for ind in Q.nodes[-1].all_individuals])) + def test_slotted_services_with_offset(self): + """ + Arrivals occur at times [0.3, 0.5, 0.7, 1.2, 1.3, 1.8, 2.6, 2.7, 3.1] + All services last 0.5 time units + Services are slotted at times [1.05, 2.05, 3.05, 4.05] + Slotted services have capacities [3, 2, 5, 3] + """ + N = ciw.create_network( + arrival_distributions=[ciw.dists.Sequential([0.3, 0.2, 0.2, 0.5, 0.1, 0.5, 0.8, 0.1, 0.4, float('inf')])], + service_distributions=[ciw.dists.Deterministic(0.5)], + number_of_servers=[ciw.Slotted(slots=[1, 2, 3, 4], slot_sizes=[3, 2, 5, 3], offset=0.05)] + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(5.6) + recs = Q.get_all_records() + recs = sorted(recs, key=lambda rec: rec.id_number) + + self.assertEqual(len(Q.nodes[-1].all_individuals), 9) + + expected_service_dates = [1.05, 1.05, 1.05, 2.05, 2.05, 3.05, 3.05, 3.05, 4.05] + observed_service_dates = [r.service_start_date for r in recs] + self.assertEqual(observed_service_dates, expected_service_dates) + self.assertFalse(any([ind.server for ind in Q.nodes[-1].all_individuals])) + def test_slotted_services_repeat(self): """ Arrivals occur at times [0.7, 1.4, 2.6, 2.9, 4.3, 5.5, 6.1, 15.0] @@ -863,3 +892,41 @@ def test_slotted_services_capacitated_preemption(self): self.assertEqual(recs[2].exit_date, 25) self.assertEqual(recs[2].record_type, 'service') + def test_offset_error_raising(self): + self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[4, 11], slot_sizes=[3, 1], offset='something')) + self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[4, 11], slot_sizes=[3, 1], offset=-6.7)) + self.assertRaises(ValueError, lambda: ciw.Schedule(schedule=[[1, 10], [2, 25]], offset='something')) + self.assertRaises(ValueError, lambda: ciw.Schedule(schedule=[[1, 10], [2, 25]], offset=-6.7)) + + def test_schedules_with_offset(self): + """ + First with no offset + """ + N = ciw.create_network( + arrival_distributions=[ciw.dists.Sequential([0.1, float('inf')])], + service_distributions=[ciw.dists.Deterministic(10)], + batching_distributions=[ciw.dists.Deterministic(10)], + number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 20], [6, 23], [0, 30]])] + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(3000) + recs = Q.get_all_records() + + expected_service_dates = [0.1, 20, 20, 20, 20, 20, 20, 30, 50, 50] + self.assertEqual(expected_service_dates, [r.service_start_date for r in recs]) + """ + Now with an offset + """ + N = ciw.create_network( + arrival_distributions=[ciw.dists.Sequential([0.1, float('inf')])], + service_distributions=[ciw.dists.Deterministic(10)], + batching_distributions=[ciw.dists.Deterministic(10)], + number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 20], [6, 23], [0, 30]], offset=0.5)] + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(3000) + recs = Q.get_all_records() + + expected_service_dates = [0.5, 20.5, 20.5, 20.5, 20.5, 20.5, 20.5, 30.5, 50.5, 50.5] + self.assertEqual(expected_service_dates, [r.service_start_date for r in recs]) + diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index d9c45fe..f607dfa 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -1105,7 +1105,7 @@ def test_schedules_and_blockages_work_together(self): ], queue_capacities=[2, 2], ) - ciw.seed(11) + ciw.seed(10) Q = ciw.Simulation( N, deadlock_detector=ciw.deadlock.StateDigraph(), @@ -1113,7 +1113,7 @@ def test_schedules_and_blockages_work_together(self): ) Q.simulate_until_deadlock() ttd = Q.times_to_deadlock[((0, 0), (0, 0))] - self.assertEqual(round(ttd, 5), 119.65819) + self.assertEqual(round(ttd, 5), 51.75691) N = ciw.create_network( arrival_distributions=[ciw.dists.Deterministic(1.0), None], From 63273cedc8d4fad1045a536224b505ea6b330d2b Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 16 Apr 2024 09:47:14 +0100 Subject: [PATCH 08/31] add docs for schedule offsets --- docs/Guides/server_schedule.rst | 31 +++++++++++++++++++++++++++++++ docs/Guides/slotted.rst | 32 ++++++++++++++++++++++++++++++++ docs/_static/style.css | 1 + 3 files changed, 64 insertions(+) diff --git a/docs/Guides/server_schedule.rst b/docs/Guides/server_schedule.rst index f385c6d..b8ac955 100644 --- a/docs/Guides/server_schedule.rst +++ b/docs/Guides/server_schedule.rst @@ -51,6 +51,37 @@ Simulating this system, we'll see that no services begin between dates 10 and 30 +Schedule Offsets +---------------- + +A schedule can be offset by a given amount of time. This means that the cyclic schedule will have a delayed start, where no servers are present. This is defined using the :code:`offset` keyword. It's effect can be compared below:: + + ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]]) + +gives: + + +-------------------+---------+--------+--------+---------+---------+--------+ + | Shift Times | 0-10 | 10-30 | 30-100 | 100-110 | 110-130 | ... | + +-------------------+---------+--------+--------+---------+---------+--------+ + | Number of Servers | 2 | 0 | 1 | 2 | 0 | ... | + +-------------------+---------+--------+--------+---------+---------+--------+ + +whereas:: + + ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], offset=7.0) + +gives: + + +-------------------+---------+---------+--------+--------+---------+---------+--------+ + | Shift Times | 0-7 | 7-17 | 17-37 | 37-107 | 107-117 | 117-137 | ... | + +-------------------+---------+---------+--------+--------+---------+---------+--------+ + | Number of Servers | 0 | 2 | 0 | 1 | 2 | 0 | ... | + +-------------------+---------+---------+--------+--------+---------+---------+--------+ + + +An offset of 7 here delays the beginning of the first shift by 7 time units, and that offset does not appear again in the cyclic schedule. The offset should only be defined as a positive float. + + Pre-emption ----------- diff --git a/docs/Guides/slotted.rst b/docs/Guides/slotted.rst index 3cb73e9..dba8ed3 100644 --- a/docs/Guides/slotted.rst +++ b/docs/Guides/slotted.rst @@ -47,6 +47,38 @@ Simulating this system, we'll see that services only begin between during the sl {1.5, 2.3, 2.8, 4.3, 5.1, 5.6} +Schedule Offsets +---------------- + +A slotted schedule can be offset by a given amount of time. This means that the cyclic schedule will have a delayed start. This is defined using the :code:`offset` keyword. It's effect can be compared below:: + + ciw.Slotted(slots=[1.5, 2.3, 2.8], slot_sizes=[2, 5, 3]) + +gives: + + +----------------------------+------+------+------+------+------+------+------+------+ + | Slot Times :math:`t` | 1.5 | 2.3 | 2.8 | 4.3 | 5.1 | 5.6 | 7.1 | ... | + +----------------------------+------+------+------+------+------+------+------+------+ + | Slot Sizes :math:`s_t` | 2 | 5 | 3 | 2 | 5 | 3 | 2 | ... | + +----------------------------+------+------+------+------+------+------+------+------+ + +whereas:: + + ciw.Slotted(slots=[1.5, 2.3, 2.8], slot_sizes=[2, 5, 3], offset=10.0) + +gives: + + +----------------------------+-------+-------+-------+-------+-------+-------+-------+------+ + | Slot Times :math:`t` | 11.5 | 12.3 | 12.8 | 14.3 | 15.1 | 15.6 | 17.1 | ... | + +----------------------------+-------+-------+-------+-------+-------+-------+-------+------+ + | Slot Sizes :math:`s_t` | 2 | 5 | 3 | 2 | 5 | 3 | 2 | ... | + +----------------------------+-------+-------+-------+-------+-------+-------+-------+------+ + + +An offset of 10 here delays the beginning of the first slot by 10 time units, and that offset does not appear again in the cyclic schedule. The offset should only be defined as a positive float. + + + Capacitated & Non-capacitated Slots ----------------------------------- diff --git a/docs/_static/style.css b/docs/_static/style.css index b965f88..9ae6457 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -14,4 +14,5 @@ th { td { font-size: 100%; + border: 1px solid #ddd !important; } \ No newline at end of file From 4285f3317ba524ee2e52991d63513beab6d1dd24 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 16 Apr 2024 14:50:20 +0100 Subject: [PATCH 09/31] reformat server schedule inputs --- ciw/schedules.py | 23 +++++++++---------- ciw/tests/test_network.py | 12 +++++----- ciw/tests/test_node.py | 22 +++++++++---------- ciw/tests/test_processor_sharing.py | 2 +- ciw/tests/test_scheduling.py | 34 ++++++++++++++--------------- ciw/tests/test_simulation.py | 10 ++++----- docs/Guides/preemption.rst | 10 ++++----- docs/Guides/server_schedule.rst | 25 +++++++++++++++------ 8 files changed, 75 insertions(+), 63 deletions(-) diff --git a/ciw/schedules.py b/ciw/schedules.py index 95b39b6..7e4820a 100644 --- a/ciw/schedules.py +++ b/ciw/schedules.py @@ -18,9 +18,9 @@ class Schedule: ---------- schedule_type : str Type of the schedule. - schedule_dates : List[float] + shift_end_dates : List[float] List of shift end dates. - schedule_servers : List[int] + numbers_of_servers : List[int] List of corresponding server numbers. preemption : Union[bool, str] Pre-emption option. @@ -37,15 +37,16 @@ class Schedule: get_next_shift() Updates the next shifts from the generator. """ - def __init__(self, schedule: List[Tuple[int, float]], preemption: Union[bool, str] = False, offset: float = 0.0) -> NoReturn: + def __init__(self, numbers_of_servers: List[int], shift_end_dates: List[float], preemption: Union[bool, str] = False, offset: float = 0.0) -> NoReturn: """ Initializes the instance of the Schedule object. Parameters ---------- - schedule : List[Tuple[int, float]] - A list of tuples representing shifts, where each tuple contains - the number of servers and the shift date. + numbers_of_servers : List[int] + A list containing the number of servers working at each shift + shift_end_dates : List[float] + A list containing the end dates of each shift. preemption : Union[bool, str], optional Pre-emption option, should be either 'resume', 'restart', 'resample', or False. @@ -58,10 +59,10 @@ def __init__(self, schedule: List[Tuple[int, float]], preemption: Union[bool, st if offset < 0.0: raise ValueError("Offset should be a positive float.") self.schedule_type = 'schedule' - self.schedule_dates = [shift[1] for shift in schedule] - self.schedule_servers = [shift[0] for shift in schedule] + self.shift_end_dates = shift_end_dates + self.numbers_of_servers = numbers_of_servers self.preemption = preemption - self.cyclelength = self.schedule_dates[-1] + self.cyclelength = self.shift_end_dates[-1] self.offset = offset def initialise(self) -> NoReturn: @@ -70,8 +71,8 @@ def initialise(self) -> NoReturn: """ self.c = 0 self.next_shift_change_date = self.offset - self.next_c = self.schedule_servers[0] - self.schedule_generator = self.get_schedule_generator(self.schedule_dates, self.schedule_servers, self.offset) + self.next_c = self.numbers_of_servers[0] + self.schedule_generator = self.get_schedule_generator(self.shift_end_dates, self.numbers_of_servers, self.offset) def get_schedule_generator(self, boundaries:List[float], values:List[int], offset:float) -> Generator[Tuple[float, int], None, None]: """ diff --git a/ciw/tests/test_network.py b/ciw/tests/test_network.py index fd88466..c613fa5 100644 --- a/ciw/tests/test_network.py +++ b/ciw/tests/test_network.py @@ -204,7 +204,7 @@ def test_create_network_from_dictionary(self): ciw.dists.Exponential(7.0), ciw.dists.Deterministic(0.7), ], - "number_of_servers": [ciw.Schedule(schedule=[[1, 20], [4, 50]]), 3], + "number_of_servers": [ciw.Schedule(numbers_of_servers=[1, 4], shift_end_dates=[20, 50]), 3], "routing": [[0.5, 0.2], [0.0, 0.0]], "queue_capacities": [10, float("inf")], } @@ -214,8 +214,8 @@ def test_create_network_from_dictionary(self): self.assertEqual(N.service_centres[0].queueing_capacity, 10) self.assertTrue(type(N.service_centres[0].number_of_servers), ciw.schedules.Schedule) self.assertEqual(N.service_centres[0].class_change_matrix, None) - self.assertEqual(N.service_centres[0].number_of_servers.schedule_dates, [20, 50]) - self.assertEqual(N.service_centres[0].number_of_servers.schedule_servers, [1, 4]) + self.assertEqual(N.service_centres[0].number_of_servers.shift_end_dates, [20, 50]) + self.assertEqual(N.service_centres[0].number_of_servers.numbers_of_servers, [1, 4]) self.assertEqual(N.service_centres[1].queueing_capacity, float("inf")) self.assertEqual(N.service_centres[1].number_of_servers, 3) self.assertEqual(N.service_centres[1].class_change_matrix, None) @@ -681,7 +681,7 @@ def test_network_from_kwargs(self): ciw.dists.Exponential(7.0), ciw.dists.Deterministic(0.7), ], - number_of_servers=[ciw.Schedule(schedule=[[1, 20], [4, 50]]), 3], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 4], shift_end_dates=[20, 50]), 3], routing=[[0.5, 0.2], [0.0, 0.0]], queue_capacities=[10, float("inf")], ) @@ -691,8 +691,8 @@ def test_network_from_kwargs(self): self.assertEqual(N.service_centres[0].queueing_capacity, 10) self.assertEqual(type(N.service_centres[0].number_of_servers), ciw.schedules.Schedule) self.assertEqual(N.service_centres[0].class_change_matrix, None) - self.assertEqual(N.service_centres[0].number_of_servers.schedule_dates, [20, 50]) - self.assertEqual(N.service_centres[0].number_of_servers.schedule_servers, [1, 4]) + self.assertEqual(N.service_centres[0].number_of_servers.shift_end_dates, [20, 50]) + self.assertEqual(N.service_centres[0].number_of_servers.numbers_of_servers, [1, 4]) self.assertFalse(N.service_centres[0].number_of_servers.preemption) self.assertEqual(N.service_centres[1].queueing_capacity, float("inf")) self.assertEqual(N.service_centres[1].number_of_servers, 3) diff --git a/ciw/tests/test_node.py b/ciw/tests/test_node.py index fa454f6..0eb269c 100644 --- a/ciw/tests/test_node.py +++ b/ciw/tests/test_node.py @@ -143,7 +143,7 @@ def test_init_method(self): "Class 0": [ciw.dists.Exponential(0.05), ciw.dists.Exponential(0.04)], "Class 1": [ciw.dists.Exponential(0.04), ciw.dists.Exponential(0.06)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 30], [2, 60], [1, 90], [3, 100]]), 3], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2, 1, 3], shift_end_dates=[30, 60, 90, 100]), 3], queue_capacities=[float("Inf"), 10], service_distributions={ "Class 0": [ciw.dists.Deterministic(5.0), ciw.dists.Exponential(0.2)], @@ -160,8 +160,8 @@ def test_init_method(self): N.update_next_event_date() self.assertEqual(N.schedule.cyclelength, 100) self.assertEqual(N.c, 1) - self.assertEqual(N.schedule.schedule_dates, [30, 60, 90, 100]) - self.assertEqual(N.schedule.schedule_servers, [1, 2, 1, 3]) + self.assertEqual(N.schedule.shift_end_dates, [30, 60, 90, 100]) + self.assertEqual(N.schedule.numbers_of_servers, [1, 2, 1, 3]) self.assertEqual(N.next_event_date, 30) self.assertEqual(N.interrupted_individuals, []) self.assertFalse(N.reneging) @@ -757,7 +757,7 @@ def test_reset_individual_attributes(self): self.assertFalse(ind.exit_date) def test_date_from_schedule_generator(self): - sg = ciw.Schedule(schedule=[[1, 30], [0, 60], [2, 90], [3, 100]]) + sg = ciw.Schedule(numbers_of_servers=[1, 0, 2, 3], shift_end_dates=[30, 60, 90, 100]) sg.initialise() sg.get_next_shift() self.assertEqual(sg.c, 1) @@ -891,7 +891,7 @@ def test_server_utilisation_with_schedules(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([2.0, 4.0, 4.0, 0.0, 7.0, 1000.0])], service_distributions=[ciw.dists.Sequential([4.0, 2.0, 6.0, 6.0, 3.0])], - number_of_servers=[ciw.Schedule(schedule=[[1, 9], [2, 23]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[9, 23])], ) Q = ciw.Simulation(N) Q.simulate_until_max_time(23) @@ -1228,7 +1228,7 @@ def test_reneging_with_schedules(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Deterministic(7)], service_distributions=[ciw.dists.Deterministic(11)], - number_of_servers=[ciw.Schedule(schedule=[[1, 16], [0, 10000]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0], shift_end_dates=[16, 10000])], reneging_time_distributions=[ciw.dists.Deterministic(3)], ) Q = ciw.Simulation(N) @@ -1862,7 +1862,7 @@ def test_data_records_for_interrupted_individuals(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Deterministic(7)], service_distributions=[ciw.dists.Deterministic(4)], - number_of_servers=[ciw.Schedule(schedule=[[1, 24], [0, 29], [1, 37]], preemption="resume")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[24, 29, 37], preemption="resume")], ) ciw.seed(0) Q = ciw.Simulation(N) @@ -1912,7 +1912,7 @@ def test_preemptive_priorities_resume_options_due_to_schedule(self): "Class 0": [ciw.dists.Deterministic(10)], "Class 1": [ciw.dists.Sequential([6, 3])], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [2, 100]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[5, 100])], priority_classes=({"Class 0": 0, "Class 1": 1}, ["restart"]), ) Q = ciw.Simulation(N) @@ -1941,7 +1941,7 @@ def test_preemptive_priorities_resume_options_due_to_schedule(self): "Class 0": [ciw.dists.Deterministic(10)], "Class 1": [ciw.dists.Sequential([6, 3])], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [2, 100]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[5, 100])], priority_classes=({"Class 0": 0, "Class 1": 1}, ["resume"]), ) Q = ciw.Simulation(N) @@ -1970,7 +1970,7 @@ def test_preemptive_priorities_resume_options_due_to_schedule(self): "Class 0": [ciw.dists.Deterministic(10)], "Class 1": [ciw.dists.Sequential([6, 3])], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [2, 100]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[5, 100])], priority_classes=({"Class 0": 0, "Class 1": 1}, ["resample"]), ) Q = ciw.Simulation(N) @@ -2020,7 +2020,7 @@ def test_shift_change_before_arrival(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential(sequence=[2.0, float('inf')])], service_distributions=[ciw.dists.Deterministic(value=1.0)], - number_of_servers=[ciw.Schedule(schedule=[[1, 1.0], [2, 10.0]], preemption=False)] + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[1.0, 10.0], preemption=False)] ) Q = ciw.Simulation(N) Q.simulate_until_max_time(10.5) diff --git a/ciw/tests/test_processor_sharing.py b/ciw/tests/test_processor_sharing.py index f301026..f97598c 100644 --- a/ciw/tests/test_processor_sharing.py +++ b/ciw/tests/test_processor_sharing.py @@ -104,7 +104,7 @@ "Class 0": [ciw.dists.Exponential(0.05), ciw.dists.Exponential(0.04)], "Class 1": [ciw.dists.Exponential(0.04), ciw.dists.Exponential(0.06)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 30], [2, 60], [1, 90], [3, 100]]), 3], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2, 1, 3], shift_end_dates=[30, 60, 90, 100]), 3], queue_capacities=[float("Inf"), 10], service_distributions={ "Class 0": [ciw.dists.Deterministic(5.0), ciw.dists.Exponential(0.2)], diff --git a/ciw/tests/test_scheduling.py b/ciw/tests/test_scheduling.py index 4071d27..5ea9ae8 100644 --- a/ciw/tests/test_scheduling.py +++ b/ciw/tests/test_scheduling.py @@ -9,7 +9,7 @@ "Class 0": [ciw.dists.Exponential(0.05), ciw.dists.Exponential(0.04)], "Class 1": [ciw.dists.Exponential(0.04), ciw.dists.Exponential(0.06)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 30], [2, 60], [1, 90], [3, 100]]), 3], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 2, 1, 3], shift_end_dates=[30, 60, 90, 100]), 3], queue_capacities=[float("Inf"), 10], service_distributions={ "Class 0": [ciw.dists.Deterministic(5.0), ciw.dists.Exponential(0.2)], @@ -238,7 +238,7 @@ def test_full_preemptive_simulation(self): arrival_distributions=[ciw.dists.Deterministic(7.0)], service_distributions=[ciw.dists.Deterministic(5.0)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[1, 15], [0, 17], [2, 100]], preemption="resample")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 2], shift_end_dates=[15, 17, 100], preemption="resample")], ) Q = ciw.Simulation(N) @@ -262,7 +262,7 @@ def test_full_preemptive_simulation(self): arrival_distributions=[ciw.dists.Deterministic(7.0)], service_distributions=[ciw.dists.Deterministic(5.0)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[1, 15], [0, 17], [2, 100]], preemption="resume")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 2], shift_end_dates=[15, 17, 100], preemption="resume")], ) Q = ciw.Simulation(N) @@ -282,7 +282,7 @@ def test_full_preemptive_simulation(self): arrival_distributions=[ciw.dists.Deterministic(3.0)], service_distributions=[ciw.dists.Deterministic(10.0)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[4, 12.5], [0, 17], [1, 100]], preemption="resume")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[4, 0, 1], shift_end_dates=[12.5, 17, 100], preemption="resume")], ) Q = ciw.Simulation(N) self.assertEqual(Q.nodes[1].schedule.preemption, 'resume') @@ -304,7 +304,7 @@ def test_overtime(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([1.0, 0.0, 0.0, 8.0, 0.0, 3.0, 10000.0])], service_distributions=[ciw.dists.Sequential([5.0, 7.0, 9.0, 4.0, 5.0, 5.0])], - number_of_servers=[ciw.Schedule(schedule=[[3, 7.0], [2, 11.0], [1, 20.0]], preemption=False)], + number_of_servers=[ciw.Schedule(numbers_of_servers=[3, 2, 1], shift_end_dates=[7.0, 11.0, 20.0], preemption=False)], ) Q = ciw.Simulation(N) Q.simulate_until_max_time(19.0) @@ -317,7 +317,7 @@ def test_overtime_exact(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([1.0, 0.0, 0.0, 8.0, 0.0, 3.0, 10000.0])], service_distributions=[ciw.dists.Sequential([5.0, 7.0, 9.0, 4.0, 5.0, 5.0])], - number_of_servers=[ciw.Schedule(schedule=[[3, 7.0], [2, 11.0], [1, 20.0]], preemption=False)], + number_of_servers=[ciw.Schedule(numbers_of_servers=[3, 2, 1], shift_end_dates=[7.0, 11.0, 20.0], preemption=False)], ) Q = ciw.Simulation(N, exact=26) Q.simulate_until_max_time(19.0) @@ -356,7 +356,7 @@ def test_preemptive_schedules_resume_options(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([1, float("inf")])], service_distributions=[ciw.dists.Sequential([10, 20])], - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 9], [1, 100]], preemption="restart")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[5, 9, 100], preemption="restart")], ) Q = ciw.Simulation(N) Q.simulate_until_max_time(40) @@ -372,7 +372,7 @@ def test_preemptive_schedules_resume_options(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([1, float("inf")])], service_distributions=[ciw.dists.Sequential([10, 20])], - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 9], [1, 100]], preemption="resume")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[5, 9, 100], preemption="resume")], ) Q = ciw.Simulation(N) Q.simulate_until_max_time(40) @@ -388,7 +388,7 @@ def test_preemptive_schedules_resume_options(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([1, float("inf")])], service_distributions=[ciw.dists.Sequential([10, 20])], - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 9], [1, 100]], preemption="resample")], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[5, 9, 100], preemption="resample")], ) Q = ciw.Simulation(N) Q.simulate_until_max_time(40) @@ -418,7 +418,7 @@ def test_priority_preemption_when_zero_servers(self): "Class 0": [ciw.dists.Deterministic(5.5)], "Class 1": [ciw.dists.Deterministic(1.5)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 20.3], [0, 20.6], [1, 100]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[20.3, 20.6, 100])], priority_classes=({"Class 0": 1, "Class 1": 0}, ["resume"]), ) @@ -485,7 +485,7 @@ def test_priority_preemption_when_zero_servers(self): "Class 0": [ciw.dists.Deterministic(5.5)], "Class 1": [ciw.dists.Deterministic(1.5)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 20.3], [0, 22], [1, 100]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[20.3, 22, 100])], priority_classes=({"Class 0": 1, "Class 1": 0}, ["resume"]), ) @@ -546,7 +546,7 @@ def test_resuming_interruption_after_blockage(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Sequential([5, float('inf')]), ciw.dists.Sequential([1, float('inf')])], service_distributions=[ciw.dists.Deterministic(1), ciw.dists.Deterministic(9)], - number_of_servers=[ciw.Schedule(schedule=[[1, 8], [0, 200]], preemption='resume'), 1], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0], shift_end_dates=[8, 200], preemption='resume'), 1], queue_capacities=[float('inf'), 0], routing=[[0.0, 1.0], [0.0, 0.0]] ) @@ -746,7 +746,7 @@ def test_slotted_services_capacitated(self): self.assertEqual(observed_service_dates, expected_service_dates) def test_invalid_preemption_options(self): - self.assertRaises(ValueError, lambda: ciw.Schedule(schedule=[[2, 10], [1, 12]], preemption='something')) + self.assertRaises(ValueError, lambda: ciw.Schedule(numbers_of_servers=[2, 1], shift_end_dates=[10, 12], preemption='something')) self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[2, 3], slot_sizes=[4, 1], capacitated=False, preemption='resume')) self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[2, 3], slot_sizes=[4, 1], capacitated=True, preemption='something')) @@ -895,8 +895,8 @@ def test_slotted_services_capacitated_preemption(self): def test_offset_error_raising(self): self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[4, 11], slot_sizes=[3, 1], offset='something')) self.assertRaises(ValueError, lambda: ciw.Slotted(slots=[4, 11], slot_sizes=[3, 1], offset=-6.7)) - self.assertRaises(ValueError, lambda: ciw.Schedule(schedule=[[1, 10], [2, 25]], offset='something')) - self.assertRaises(ValueError, lambda: ciw.Schedule(schedule=[[1, 10], [2, 25]], offset=-6.7)) + self.assertRaises(ValueError, lambda: ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[10, 25], offset='something')) + self.assertRaises(ValueError, lambda: ciw.Schedule(numbers_of_servers=[1, 2], shift_end_dates=[10, 25], offset=-6.7)) def test_schedules_with_offset(self): """ @@ -906,7 +906,7 @@ def test_schedules_with_offset(self): arrival_distributions=[ciw.dists.Sequential([0.1, float('inf')])], service_distributions=[ciw.dists.Deterministic(10)], batching_distributions=[ciw.dists.Deterministic(10)], - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 20], [6, 23], [0, 30]])] + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 6, 0], shift_end_dates=[5, 20, 23, 30])] ) Q = ciw.Simulation(N) Q.simulate_until_max_time(3000) @@ -921,7 +921,7 @@ def test_schedules_with_offset(self): arrival_distributions=[ciw.dists.Sequential([0.1, float('inf')])], service_distributions=[ciw.dists.Deterministic(10)], batching_distributions=[ciw.dists.Deterministic(10)], - number_of_servers=[ciw.Schedule(schedule=[[1, 5], [0, 20], [6, 23], [0, 30]], offset=0.5)] + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 6, 0], shift_end_dates=[5, 20, 23, 30], offset=0.5)] ) Q = ciw.Simulation(N) Q.simulate_until_max_time(3000) diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index f607dfa..8e36926 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -416,7 +416,7 @@ def test_exactness(self): arrival_distributions=[ciw.dists.Exponential(20)], service_distributions=[ciw.dists.Deterministic(0.01)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[0, 0.5], [1, 0.55], [0, 3.0]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[0, 1, 0], shift_end_dates=[0.5, 0.55, 3.0])], ) ciw.seed(777) Q = ciw.Simulation(N) @@ -437,7 +437,7 @@ def test_exactness(self): arrival_distributions=[ciw.dists.Exponential(20)], service_distributions=[ciw.dists.Deterministic(0.01)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[0, 0.5], [1, 0.55], [0, 3.0]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[0, 1, 0], shift_end_dates=[0.5, 0.55, 3.0])], ) ciw.seed(777) @@ -578,7 +578,7 @@ class DummyArrivalNode(ciw.ArrivalNode): arrival_distributions=[ciw.dists.Exponential(20)], service_distributions=[ciw.dists.Deterministic(0.01)], routing=[[0.0]], - number_of_servers=[ciw.Schedule(schedule=[[0, 0.5], [1, 0.55], [0, 3.0]])], + number_of_servers=[ciw.Schedule(numbers_of_servers=[0, 1, 0], shift_end_dates=[0.5, 0.55, 3.0])], ) Q = ciw.Simulation(N, node_class=None, arrival_node_class=None) self.assertEqual(Q.NodeTypes, [ciw.Node]) @@ -1094,7 +1094,7 @@ def test_schedules_and_blockages_work_together(self): "Class 0": [ciw.dists.Exponential(0.8), ciw.dists.Exponential(1.2)], "Class 1": [ciw.dists.Exponential(0.5), ciw.dists.Exponential(1.0)], }, - number_of_servers=[ciw.Schedule(schedule=[[1, 10], [0, 20], [2, 30]], preemption="resample"), 2], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 2], shift_end_dates=[10, 20, 30], preemption="resample"), 2], routing={ "Class 0": [[0.1, 0.3], [0.2, 0.2]], "Class 1": [[0.0, 0.6], [0.2, 0.1]], @@ -1121,7 +1121,7 @@ def test_schedules_and_blockages_work_together(self): ciw.dists.Deterministic(0.1), ciw.dists.Deterministic(3.0), ], - number_of_servers=[ciw.Schedule(schedule=[[1, 2.5], [0, 2.8]], preemption="resample"), 1], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0], shift_end_dates=[2.5, 2.8], preemption="resample"), 1], queue_capacities=[float("inf"), 0], routing=[[0.0, 1.0], [0.0, 0.0]], ) diff --git a/docs/Guides/preemption.rst b/docs/Guides/preemption.rst index 58c143b..3e7bdcd 100644 --- a/docs/Guides/preemption.rst +++ b/docs/Guides/preemption.rst @@ -46,15 +46,15 @@ During a pre-emptive schedule, that server will immediately stop service and lea In order to implement pre-emptive or non-pre-emptive schedules, the :code:`ciw.Schedule` object takes in a keywords argument :code:`preemption` the chosen pre-emption option. For example:: number_of_servers=[ - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], preemption=False) # non-preemptive - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], preemption="resample") # preemptive and resamples service time - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], preemption="restart") # preemptive and restarts origional service time - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], preemption="resume") # preemptive continutes services where left off + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption=False) # non-preemptive + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resample") # preemptive and resamples service time + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="restart") # preemptive and restarts origional service time + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resume") # preemptive continutes services where left off ] Ciw defaults to non-pre-emptive schedules, and so the following code implies a non-pre-emptive schedule:: - number_of_servers=[ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]])] # non-preemptive + number_of_servers=[ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100])] # non-preemptive Records of Interrupted Services diff --git a/docs/Guides/server_schedule.rst b/docs/Guides/server_schedule.rst index b8ac955..a989f62 100644 --- a/docs/Guides/server_schedule.rst +++ b/docs/Guides/server_schedule.rst @@ -15,16 +15,17 @@ An example cyclic work schedule is shown in the table below: This schedule is cyclic, therefore after the last shift (30-100), schedule begins again with the shift (0-10). The cycle length for this schedule is 100. -The schedule itself is defined by a list of lists indicating the number of servers that should be on duty during that shift, and the end date of that shift:: +The schedule itself is defined by a list of lists indicating the numbers of servers that should be on duty during the shifts, and the end dates of the shifts:: - [[2, 10], [0, 30], [1, 100]] + numbers_of_servers = [2, 0, 1] + shift_end_dates = [10, 30, 100] Here we are saying that there will be 2 servers scheduled between times 0 and 10, 0 between 10 and 30, etc. This fully defines the cyclic work schedule. From this we create a schedule object in Ciw:: - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]]) + ciw.Schedule(number_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100]) To tell Ciw to use this schedule for a given node, in the :code:`number_of_servers` keyword we replace an integer with the schedule:: @@ -32,7 +33,12 @@ To tell Ciw to use this schedule for a given node, in the :code:`number_of_serve >>> N = ciw.create_network( ... arrival_distributions=[ciw.dists.Exponential(rate=5)], ... service_distributions=[ciw.dists.Exponential(rate=10)], - ... number_of_servers=[ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]])] + ... number_of_servers=[ + ... ciw.Schedule( + ... numbers_of_servers=[2, 0, 1], + ... shift_end_dates=[10, 30, 100] + ... ) + ... ] ... ) Simulating this system, we'll see that no services begin between dates 10 and 30, nor between dates 110 and 130:: @@ -56,7 +62,7 @@ Schedule Offsets A schedule can be offset by a given amount of time. This means that the cyclic schedule will have a delayed start, where no servers are present. This is defined using the :code:`offset` keyword. It's effect can be compared below:: - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]]) + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100]) gives: @@ -68,7 +74,7 @@ gives: whereas:: - ciw.Schedule(schedule=[[2, 10], [0, 30], [1, 100]], offset=7.0) + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], offset=7.0) gives: @@ -107,7 +113,12 @@ Consider the following example:: >>> N = ciw.create_network( ... arrival_distributions=[ciw.dists.Deterministic(value=3.0)], ... service_distributions=[ciw.dists.Deterministic(value=5.0)], - ... number_of_servers=[ciw.Schedule(schedule=[[1, 4.0], [2, 10.0], [0, 100.0]])] + ... number_of_servers=[ + ... ciw.Schedule( + ... numbers_of_servers=[1, 2, 0], + ... shift_end_dates=[4.0, 10.0, 100.0] + ... ) + ... ] ... ) >>> Q = ciw.Simulation(N) >>> Q.simulate_until_max_time(20.0) From 01277d3ef68ece79a00622a594d2a936d157bf06 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 18 Apr 2024 13:53:06 +0100 Subject: [PATCH 10/31] first implementation of routing objects --- ciw/arrival_node.py | 3 +- ciw/import_params.py | 164 ++++++++-------------- ciw/node.py | 21 +-- ciw/routing/__init__.py | 1 + ciw/routing/routing.py | 140 +++++++++++++++++++ ciw/simulation.py | 11 ++ ciw/tests/test_network.py | 169 +++++++++++++++++----- ciw/tests/test_node.py | 8 +- ciw/tests/test_process_based.py | 208 +++------------------------- ciw/tests/test_processor_sharing.py | 32 ++++- ciw/tests/test_simulation.py | 4 +- 11 files changed, 401 insertions(+), 360 deletions(-) create mode 100644 ciw/routing/__init__.py create mode 100644 ciw/routing/routing.py diff --git a/ciw/arrival_node.py b/ciw/arrival_node.py index 7df5030..d777b24 100644 --- a/ciw/arrival_node.py +++ b/ciw/arrival_node.py @@ -92,8 +92,7 @@ def have_event(self): priority_class, simulation=self.simulation, ) - if self.simulation.network.process_based: - next_individual.route = self.simulation.network.customer_classes[next_individual.customer_class].routing[self.next_node - 1](next_individual) + self.simulation.routers[next_individual.customer_class].initialise_individual(next_individual) next_node = self.simulation.transitive_nodes[self.next_node - 1] self.release_individual(next_node, next_individual) diff --git a/ciw/import_params.py b/ciw/import_params.py index 1a5bc40..0ed6c8a 100644 --- a/ciw/import_params.py +++ b/ciw/import_params.py @@ -3,6 +3,7 @@ import ciw.dists from .network import * from .schedules import * +from .routing import * def create_network( @@ -117,35 +118,18 @@ def create_network_from_dictionary(params_input): ) ) for clss_name in params['customer_class_names']: - if all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - classes[clss_name] = CustomerClass( - params['arrival_distributions'][clss_name], - params['service_distributions'][clss_name], - params['routing'], - params["priority_classes"][clss_name], - params["baulking_functions"][clss_name], - params["batching_distributions"][clss_name], - params["reneging_time_distributions"][clss_name], - params["reneging_destinations"][clss_name], - class_change_time_distributions[clss_name], - ) - else: - classes[clss_name] = CustomerClass( - params['arrival_distributions'][clss_name], - params['service_distributions'][clss_name], - params['routing'][clss_name], - params["priority_classes"][clss_name], - params["baulking_functions"][clss_name], - params["batching_distributions"][clss_name], - params["reneging_time_distributions"][clss_name], - params["reneging_destinations"][clss_name], - class_change_time_distributions[clss_name], - ) + classes[clss_name] = CustomerClass( + params['arrival_distributions'][clss_name], + params['service_distributions'][clss_name], + params['routing'][clss_name], + params["priority_classes"][clss_name], + params["baulking_functions"][clss_name], + params["batching_distributions"][clss_name], + params["reneging_time_distributions"][clss_name], + params["reneging_destinations"][clss_name], + class_change_time_distributions[clss_name], + ) n = Network(nodes, classes) - if all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - n.process_based = True - else: - n.process_based = False n.system_capacity = params['system_capacity'] return n @@ -162,9 +146,16 @@ def fill_out_dictionary(params): srv_dists = params["service_distributions"] params["service_distributions"] = {"Customer": srv_dists} if "routing" in params: - if all(isinstance(f, list) for f in params["routing"]): - rtng_mat = params["routing"] - params["routing"] = {"Customer": rtng_mat} + if isinstance(params["routing"], list): + transition_matrix = params["routing"] + params["routing"] = {"Customer": routing.TransitionMatrix(transition_matrix=transition_matrix)} + elif isinstance(params["routing"], dict): + for clss in params["routing"]: + if isinstance(params["routing"][clss], list): + transition_matrix = params["routing"][clss] + params["routing"][clss] = routing.TransitionMatrix(transition_matrix=transition_matrix) + else: + params["routing"] = {"Customer": params["routing"]} if "baulking_functions" in params: if isinstance(params["baulking_functions"], list): blk_fncs = params["baulking_functions"] @@ -187,7 +178,7 @@ def fill_out_dictionary(params): default_dict = { "name": "Simulation", - "routing": {class_name: [[0.0]] for class_name in class_names}, + "routing": {class_name: routing.TransitionMatrix(transition_matrix=[[0.0]]) for class_name in class_names}, "number_of_nodes": len(params["number_of_servers"]), "number_of_classes": len(class_names), "queue_capacities": [float("inf") for _ in range(len(params["number_of_servers"]))], @@ -224,87 +215,46 @@ def validify_dictionary(params): Raises errors if there is something wrong with the parameters dictionary. """ - if all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - consistant_num_classes = ( - params["number_of_classes"] - == len(params["arrival_distributions"]) - == len(params["service_distributions"]) - == len(params["batching_distributions"]) - == len(params["reneging_time_distributions"]) - == len(params["reneging_destinations"]) - ) - else: - consistant_num_classes = ( - params["number_of_classes"] - == len(params["arrival_distributions"]) - == len(params["service_distributions"]) - == len(params["routing"]) - == len(params["batching_distributions"]) - == len(params["reneging_time_distributions"]) - == len(params["reneging_destinations"]) - ) + consistant_num_classes = ( + params["number_of_classes"] + == len(params["arrival_distributions"]) + == len(params["service_distributions"]) + == len(params["routing"]) + == len(params["batching_distributions"]) + == len(params["reneging_time_distributions"]) + == len(params["reneging_destinations"]) + ) if not consistant_num_classes: raise ValueError("Ensure consistant number of classes is used throughout.") - if all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - consistant_class_names = ( - set(params["arrival_distributions"]) - == set(params["service_distributions"]) - == set(params["batching_distributions"]) - == set(params["reneging_time_distributions"]) - == set(params["reneging_destinations"]) - ) - else: - consistant_class_names = ( - set(params["arrival_distributions"]) - == set(params["service_distributions"]) - == set(params["routing"]) - == set(params["batching_distributions"]) - == set(params["reneging_time_distributions"]) - == set(params["reneging_destinations"]) - ) and ( - len(params["arrival_distributions"]) - == len(params["service_distributions"]) - == len(params["batching_distributions"]) - == len(params["reneging_time_distributions"]) - == len(params["reneging_destinations"]) - ) + consistant_class_names = ( + set(params["arrival_distributions"]) + == set(params["service_distributions"]) + == set(params["routing"]) + == set(params["batching_distributions"]) + == set(params["reneging_time_distributions"]) + == set(params["reneging_destinations"]) + ) and ( + len(params["arrival_distributions"]) + == len(params["service_distributions"]) + == len(params["batching_distributions"]) + == len(params["reneging_time_distributions"]) + == len(params["reneging_destinations"]) + ) if not consistant_class_names: raise ValueError("Ensure consistant names for customer classes.") - if all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - num_nodes_count = ( - [params["number_of_nodes"]] - + [len(obs) for obs in params["arrival_distributions"].values()] - + [len(obs) for obs in params["service_distributions"].values()] - + [len(obs) for obs in params["batching_distributions"].values()] - + [len(obs) for obs in params["reneging_time_distributions"].values()] - + [len(obs) for obs in params["reneging_destinations"].values()] - + [len(params["routing"])] - + [len(params["number_of_servers"])] - + [len(params["server_priority_functions"])] - + [len(params["queue_capacities"])] - ) - else: - num_nodes_count = ( - [params["number_of_nodes"]] - + [len(obs) for obs in params["arrival_distributions"].values()] - + [len(obs) for obs in params["service_distributions"].values()] - + [len(obs) for obs in params["routing"].values()] - + [len(obs) for obs in params["batching_distributions"].values()] - + [len(obs) for obs in params["reneging_time_distributions"].values()] - + [len(obs) for obs in params["reneging_destinations"].values()] - + [len(row) for row in [obs for obs in params["routing"].values()][0]] - + [len(params["number_of_servers"])] - + [len(params["server_priority_functions"])] - + [len(params["queue_capacities"])] - + [len(params["service_disciplines"])] - ) + num_nodes_count = ( + [params["number_of_nodes"]] + + [len(obs) for obs in params["arrival_distributions"].values()] + + [len(obs) for obs in params["service_distributions"].values()] + + [len(obs) for obs in params["batching_distributions"].values()] + + [len(obs) for obs in params["reneging_time_distributions"].values()] + + [len(obs) for obs in params["reneging_destinations"].values()] + + [len(params["number_of_servers"])] + + [len(params["server_priority_functions"])] + + [len(params["queue_capacities"])] + ) if len(set(num_nodes_count)) != 1: raise ValueError("Ensure consistant number of nodes is used throughout.") - if not all(isinstance(f, types.FunctionType) or isinstance(f, types.MethodType) for f in params["routing"]): - for clss in params["routing"].values(): - for row in clss: - if sum(row) > 1.0 or min(row) < 0.0 or max(row) > 1.0: - raise ValueError("Ensure that routing matrix is valid.") neg_numservers = any( [(isinstance(obs, int) and obs < 0) for obs in params["number_of_servers"]] ) diff --git a/ciw/node.py b/ciw/node.py index e735ece..b36b4c0 100644 --- a/ciw/node.py +++ b/ciw/node.py @@ -41,11 +41,6 @@ def __init__(self, id_, simulation): self.next_event_date = float("Inf") self.next_shift_change = float("Inf") self.node_capacity = node.queueing_capacity + self.c - if not self.simulation.network.process_based: - self.transition_row = { - clss: self.simulation.network.customer_classes[clss].routing[id_ - 1] + [1.0 - sum(self.simulation.network.customer_classes[clss].routing[id_ - 1])] - for clss in self.simulation.network.customer_class_names - } self.class_change = node.class_change_matrix self.individuals = [[] for _ in range(simulation.number_of_priority_classes)] self.number_of_individuals = 0 @@ -538,21 +533,7 @@ def next_node(self, ind): - if process-based then take the next value from the predefined route, removing the current node from the route """ - if not self.simulation.network.process_based: - customer_class = ind.customer_class - return random_choice( - self.simulation.nodes[1:], - self.transition_row[customer_class], - ) - else: - if ind.route == [] or ind.route[0] != self.id_number: - raise ValueError("Individual process route sent to wrong node") - ind.route.pop(0) - if len(ind.route) == 0: - next_node_number = -1 - else: - next_node_number = ind.route[0] - return self.simulation.nodes[next_node_number] + return self.simulation.routers[ind.customer_class].next_node(ind, self.id_number) def preempt(self, individual_to_preempt, next_individual): """ diff --git a/ciw/routing/__init__.py b/ciw/routing/__init__.py new file mode 100644 index 0000000..94cee78 --- /dev/null +++ b/ciw/routing/__init__.py @@ -0,0 +1 @@ +from .routing import * diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py new file mode 100644 index 0000000..eec31dc --- /dev/null +++ b/ciw/routing/routing.py @@ -0,0 +1,140 @@ +import ciw + +class NetworkRouting: + """ + A class to hold a number of routing objects for each node. + """ + def __init__(self, routers): + """ + Sets up the router objects for each node. + """ + self.routers = routers + + def initialise(self, simulation): + """ + Gives the simulation and node attributes to the routing objects. + """ + self.simulation = simulation + for router, node in zip(self.routers, self.simulation.transitive_nodes): + router.initialise(self.simulation, node) + + def initialise_individual(self, ind): + """ + A method that is called at the arrival node when the individual is spawned. + """ + pass + + def next_node(self, ind, node_id): + """ + Chooses the next node. + """ + return self.routers[node_id - 1].next_node(ind) + + +class TransitionMatrix(NetworkRouting): + """ + A class to hold a number of probabilistic routing objects. + """ + def __init__(self, transition_matrix): + """ + Sets up the relevant probabilistic router objects for each node. + """ + n_destinations = len(transition_matrix) + self.routers = [ + Probabilistic( + destinations=[i for i in range(1, n_destinations + 1)], + probs=row + ) for row in transition_matrix + ] + + def initialise(self, simulation): + super().initialise(simulation) + if len(self.routers) != simulation.network.number_of_nodes: + raise ValueError("Ensure a transition matrix is given, and that the number of rows is equal to the number of nodes in the network.") + + +class ProcessBased(NetworkRouting): + """ + A class to route an individual based on a pre-defined process. + """ + def __init__(self, route_function): + """ + Initialises the routing object. + + Takes: + - route_function: a function that returns a pre-defined route + """ + self.route_function = route_function + + def initialise(self, simulation): + """ + Gives the simulation and node attributes to the routing objects. + """ + self.simulation = simulation + + def initialise_individual(self, ind): + """ + A method that is called at the arrival node when the individual is spawned. + """ + ind.route = self.route_function(ind) + + def next_node(self, ind, node_id): + """ + Chooses the next node from the process-based pre-defined route. + """ + if len(ind.route) == 0: + node_index = -1 + else: + node_index = ind.route.pop(0) + return self.simulation.nodes[node_index] + + +class NodeRouting: + """ + A generic routing class to determine next sampled node. + """ + def initialise(self, simulation, node): + """ + Gives the simulation and node attributes to the routing object. + """ + self.simulation = simulation + self.node = node + + +class Probabilistic(NodeRouting): + """ + A router that probabilistically chooses the next node. + """ + def __init__(self, destinations, probs): + """ + Initialises the routing object. + + Takes: + - destinations: a list of node indices + - probs: a list of probabilities associated with each destination + """ + for p in probs: + if not isinstance(p, float): + raise ValueError("Routing probabilities must be between 0 and 1, and sum to less than 1.") + if p < 0 or p > 1: + raise ValueError("Routing probabilities must be between 0 and 1, and sum to less than 1.") + if sum(probs) > 1.0: + raise ValueError("Routing probabilities must be between 0 and 1, and sum to less than 1.") + self.destinations = destinations + [-1] + self.probs = probs + [1 - sum(probs)] + + def initialise(self, simulation, node): + super().initialise(simulation, node) + if len(self.probs) != len(self.destinations): + raise ValueError("Routing probabilities should correspond to destinations, and so should be lists of the same length.") + if not set(self.destinations).issubset(set([nd.id_number for nd in simulation.nodes[1:]])): + raise ValueError("Routing destinations should be a subset of the nodes in the network.") + + def next_node(self, ind): + """ + Probabilistically chooses the next node from the destinations. + """ + node_index = ciw.random_choice(self.destinations, self.probs) + return self.simulation.nodes[node_index] + + diff --git a/ciw/simulation.py b/ciw/simulation.py index 7e92831..90921bf 100644 --- a/ciw/simulation.py +++ b/ciw/simulation.py @@ -50,6 +50,7 @@ def __init__( self.transitive_nodes = [node_type(i + 1, self) for i, node_type in enumerate(self.NodeTypes)] self.nodes = [self.ArrivalNodeType(self)] + self.transitive_nodes + [ExitNode()] self.active_nodes = self.nodes[:-1] + self.routers = self.find_and_initialise_routers() self.nodes[0].initialise() if tracker is None: self.statetracker = trackers.StateTracker() @@ -120,6 +121,16 @@ def show_simulation_to_distributions(self): self.service_times[nd + 1][clss].simulation = self self.batch_sizes[nd + 1][clss].simulation = self + def find_and_initialise_routers(self): + """ + Initialises the routing objects. + """ + routers_dict = {} + for clss in self.network.customer_class_names: + routers_dict[clss] = self.network.customer_classes[clss].routing + routers_dict[clss].initialise(self) + return routers_dict + def find_next_active_node(self): """ Returns the next active node, the node whose next_event_date is next: diff --git a/ciw/tests/test_network.py b/ciw/tests/test_network.py index c613fa5..2f34890 100644 --- a/ciw/tests/test_network.py +++ b/ciw/tests/test_network.py @@ -191,7 +191,10 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + + router0 = N.customer_classes['Class 0'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0}) @@ -227,7 +230,12 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Customer'].service_distributions], ["Exponential(rate=7.0)", "Deterministic(value=0.7)"], ) - self.assertEqual(N.customer_classes['Customer'].routing, [[0.5, 0.2], [0.0, 0.0]]) + router = N.customer_classes['Customer'].routing + self.assertEqual(router.routers[0].destinations, [1, 2, -1]) + self.assertEqual([round(p, 2) for p in router.routers[0].probs], [0.5, 0.2, 0.3]) + self.assertEqual(router.routers[1].destinations, [1, 2, -1]) + self.assertEqual([round(p, 2) for p in router.routers[1].probs], [0.0, 0.0, 1.0]) + self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Customer': 0}) @@ -262,7 +270,12 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -271,7 +284,6 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0, 'Class 1': 0}) @@ -313,7 +325,12 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -322,7 +339,6 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0, 'Class 1': 0}) @@ -354,7 +370,12 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -363,7 +384,6 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.customer_classes['Class 0'].priority_class, 1) self.assertEqual(N.customer_classes['Class 1'].priority_class, 0) self.assertEqual(N.number_of_priority_classes, 2) @@ -403,10 +423,13 @@ def test_create_network_from_dictionary(self): [str(d) for d in N.customer_classes['Customer'].service_distributions], ["Exponential(rate=7.0)", "Uniform(lower=0.4, upper=1.2)", "Deterministic(value=5.33)"], ) - self.assertEqual( - N.customer_classes['Customer'].routing, - [[0.5, 0.0, 0.1], [0.2, 0.1, 0.0], [0.0, 0.0, 0.0]], - ) + router = N.customer_classes['Customer'].routing + self.assertEqual(router.routers[0].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[0].probs], [0.5, 0.0, 0.1, 0.4]) + self.assertEqual(router.routers[1].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[1].probs], [0.2, 0.1, 0.0, 0.7]) + self.assertEqual(router.routers[2].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[2].probs], [0.0, 0.0, 0.0, 1.0]) self.assertEqual( N.customer_classes['Customer'].baulking_functions, [None, None, example_baulking_function], @@ -424,7 +447,7 @@ def test_raising_errors(self): "number_of_nodes": 1, "queue_capacities": [float("inf")], } - params_list = [copy.deepcopy(params) for i in range(27)] + params_list = [copy.deepcopy(params) for i in range(28)] params_list[0]["number_of_classes"] = -2 self.assertRaises( @@ -531,16 +554,18 @@ def test_raising_errors(self): params_list[14] ) params_list[15]["routing"]["Class 0"] = [[0.2], [0.1]] + N = ciw.create_network_from_dictionary(params_list[15]) self.assertRaises( ValueError, - ciw.create_network_from_dictionary, - params_list[15] + ciw.Simulation, + N ) params_list[16]["routing"]["Class 0"] = [[0.2, 0.1]] + N = ciw.create_network_from_dictionary(params_list[16]) self.assertRaises( ValueError, - ciw.create_network_from_dictionary, - params_list[16] + ciw.Simulation, + N ) params_list[17]["routing"]["Class 0"] = [[-0.6]] self.assertRaises( @@ -602,6 +627,52 @@ def test_raising_errors(self): ciw.create_network_from_dictionary, params_list[26] ) + params_list[27]["routing"]["Class 0"] = [['0.5']] + self.assertRaises( + ValueError, + ciw.create_network_from_dictionary, + params_list[27] + ) + + def test_raising_errors_routing(self): + params = { + 'arrival_distributions': [ciw.dists.Exponential(1.0), ciw.dists.Exponential(1.0)], + 'service_distributions': [ciw.dists.Exponential(2.0), ciw.dists.Exponential(2.0)], + 'number_of_servers': [2, 1], + 'routing': [[0.5, 0.6], [0.0, 0.3]] + } + self.assertRaises( + ValueError, + ciw.create_network_from_dictionary, + params + ) + + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(1.0), ciw.dists.Exponential(1.0)], + service_distributions=[ciw.dists.Exponential(2.0), ciw.dists.Exponential(2.0)], + number_of_servers=[2, 1], + routing=[[0.5, 0.2, 0.0], [0.0, 0.3, 0.0], [0.1, 0.1, 0.3]] + ) + self.assertRaises( + ValueError, + ciw.Simulation, + N + ) + + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(1.0), ciw.dists.Exponential(1.0)], + service_distributions=[ciw.dists.Exponential(2.0), ciw.dists.Exponential(2.0)], + number_of_servers=[2, 1], + routing=ciw.routing.NetworkRouting(routers=[ + ciw.routing.Probabilistic(destinations=[0, 1], probs=[0.5, 0.2]), + ciw.routing.Probabilistic(destinations=[0, 2], probs=[0.5, 0.2]) + ]) + ) + self.assertRaises( + ValueError, + ciw.Simulation, + N + ) class TestImportNoMatrix(unittest.TestCase): @@ -611,7 +682,9 @@ def test_optional_transition_matrix(self): service_distributions=[ciw.dists.Exponential(2.0)], number_of_servers=[1], ) - self.assertEqual([c.routing for c in N.customer_classes.values()], [[[0.0]]]) + router = N.customer_classes['Customer'].routing + self.assertEqual(router.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router.routers[0].probs], [0.0, 1.0]) N = ciw.create_network( arrival_distributions={ @@ -625,7 +698,12 @@ def test_optional_transition_matrix(self): number_of_servers=[1], ) - self.assertEqual([c.routing for c in N.customer_classes.values()], [[[0.0]], [[0.0]]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.0, 1.0]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) params = { "arrival_distributions": [ @@ -638,10 +716,11 @@ def test_optional_transition_matrix(self): ], "number_of_servers": [1, 2], } + N = ciw.create_network(**params) self.assertRaises( ValueError, - ciw.create_network_from_dictionary, - params + ciw.Simulation, + N ) @@ -668,7 +747,9 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0}) @@ -705,7 +786,12 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Customer'].service_distributions], ["Exponential(rate=7.0)", "Deterministic(value=0.7)"], ) - self.assertEqual(N.customer_classes['Customer'].routing, [[0.5, 0.2], [0.0, 0.0]]) + router = N.customer_classes['Customer'].routing + self.assertEqual(router.routers[0].destinations, [1, 2, -1]) + self.assertEqual([round(p, 2) for p in router.routers[0].probs], [0.5, 0.2, 0.3]) + self.assertEqual(router.routers[1].destinations, [1, 2, -1]) + self.assertEqual([round(p, 2) for p in router.routers[1].probs], [0.0, 0.0, 1.0]) + self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Customer': 0}) @@ -739,7 +825,12 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -748,7 +839,6 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0, 'Class 1': 0}) @@ -779,7 +869,12 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -788,7 +883,6 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.customer_classes['Class 0'].priority_class, 1) self.assertEqual(N.customer_classes['Class 1'].priority_class, 0) self.assertEqual(N.number_of_priority_classes, 2) @@ -828,10 +922,14 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Customer'].service_distributions], ["Exponential(rate=7.0)", "Uniform(lower=0.4, upper=1.2)", "Deterministic(value=5.33)"], ) - self.assertEqual( - N.customer_classes['Customer'].routing, - [[0.5, 0.0, 0.1], [0.2, 0.1, 0.0], [0.0, 0.0, 0.0]], - ) + router = N.customer_classes['Customer'].routing + self.assertEqual(router.routers[0].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[0].probs], [0.5, 0.0, 0.1, 0.4]) + self.assertEqual(router.routers[1].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[1].probs], [0.2, 0.1, 0.0, 0.7]) + self.assertEqual(router.routers[2].destinations, [1, 2, 3, -1]) + self.assertEqual([round(p, 2) for p in router.routers[2].probs], [0.0, 0.0, 0.0, 1.0]) + self.assertEqual( N.customer_classes['Customer'].baulking_functions, [None, None, example_baulking_function], @@ -898,7 +996,13 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 0'].service_distributions], ["Exponential(rate=7.0)"], ) - self.assertEqual(N.customer_classes['Class 0'].routing, [[0.5]]) + router0 = N.customer_classes['Class 0'].routing + router1 = N.customer_classes['Class 1'].routing + self.assertEqual(router0.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.5, 0.5]) + self.assertEqual(router1.routers[0].destinations, [1, -1]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.0, 1.0]) + self.assertEqual( [str(d) for d in N.customer_classes['Class 1'].arrival_distributions], ["Exponential(rate=4.0)"], @@ -907,7 +1011,6 @@ def test_network_from_kwargs(self): [str(d) for d in N.customer_classes['Class 1'].service_distributions], ["Uniform(lower=0.4, upper=1.2)"], ) - self.assertEqual(N.customer_classes['Class 1'].routing, [[0.0]]) self.assertEqual(N.number_of_priority_classes, 1) self.assertEqual(N.priority_class_mapping, {'Class 0': 0, 'Class 1': 0}) diff --git a/ciw/tests/test_node.py b/ciw/tests/test_node.py index 0eb269c..c4d0780 100644 --- a/ciw/tests/test_node.py +++ b/ciw/tests/test_node.py @@ -98,10 +98,6 @@ def test_init_method(self): Q = ciw.Simulation(N_params) N = ciw.Node(1, Q) self.assertEqual(N.c, 9) - self.assertEqual( - [[round(p, 10) for p in row] for row in N.transition_row.values()], - [[0.1, 0.2, 0.1, 0.4, 0.2], [0.6, 0.0, 0.0, 0.2, 0.2], [0.0, 0.0, 0.4, 0.3, 0.3]], - ) self.assertEqual(N.next_event_date, float("inf")) self.assertEqual(N.all_individuals, []) self.assertEqual(N.id_number, 1) @@ -1018,7 +1014,7 @@ def prioritise_highest_id(srv, ind): arrival_distributions=[ciw.dists.Exponential(1), ciw.dists.Exponential(1)], service_distributions=[ciw.dists.Exponential(2), ciw.dists.Exponential(2)], number_of_servers=[2, 2], - routing=[[0, 0], [0, 0]], + routing=[[0.0, 0.0], [0.0, 0.0]], server_priority_functions=[prioritise_less_busy, prioritise_highest_id], ) Q = ciw.Simulation(N) @@ -1162,7 +1158,7 @@ def test_reneging_sends_to_destination(self): N = ciw.create_network( arrival_distributions=[ciw.dists.Deterministic(7), None], service_distributions=[ciw.dists.Deterministic(11), ciw.dists.Deterministic(2)], - routing=[[0, 0], [0, 0]], + routing=[[0.0, 0.0], [0.0, 0.0]], number_of_servers=[1, 1], reneging_time_distributions=[ciw.dists.Deterministic(3), None], reneging_destinations=[2, -1], diff --git a/ciw/tests/test_process_based.py b/ciw/tests/test_process_based.py index 86cc686..c24a7a2 100644 --- a/ciw/tests/test_process_based.py +++ b/ciw/tests/test_process_based.py @@ -5,51 +5,29 @@ def generator_function_1(ind): - return [1, 1, 1] + return [1, 1] def generator_function_2(ind): - return [1, 1, 2, 1, 3] + return [1, 2, 1, 3] def generator_function_3(ind): rnd = random.random() if rnd < 0.4: - return [1, 2, 2, 3, 2] + return [2, 2, 3, 2] if rnd < 0.5: - return [1, 1] - return [1, 3, 2, 3] - + return [1] + return [3, 2, 3] def generator_function_4(ind): - return [2, 1, 3, 1, 1] - - -def generator_function_5(ind): - return [3, 2, 3, 2, 3] - - -def generator_function_6(ind): - return [3] - - -def generator_function_7(ind): - rnd = random.random() - if rnd < 0.4: - return [2, 1, 2] - return [2, 1, 1, 2] - - -def generator_function_8(ind): - if ind.customer_class == 'Class 0': - return [1] - return [1, 1, 1] + return [] class ClassForProcessBasedMethod: def __init__(self, n): self.n = n def generator_method(self, ind): - return [1, 1, 1] + return [1, 1] class TestProcessBased(unittest.TestCase): @@ -58,9 +36,8 @@ def test_network_takes_routing_function(self): arrival_distributions=[ciw.dists.Exponential(1)], service_distributions=[ciw.dists.Exponential(2)], number_of_servers=[1], - routing=[generator_function_1], + routing=ciw.routing.ProcessBased(generator_function_1), ) - self.assertEqual(N.process_based, True) Q = ciw.Simulation(N) self.assertEqual(str(Q), "Simulation") @@ -69,13 +46,13 @@ def test_individuals_recieve_route(self): arrival_distributions=[ciw.dists.Deterministic(1)], service_distributions=[ciw.dists.Deterministic(1000)], number_of_servers=[1], - routing=[generator_function_1], + routing=ciw.routing.ProcessBased(generator_function_1), ) Q = ciw.Simulation(N) Q.simulate_until_max_time(4.5) inds = Q.nodes[1].all_individuals for ind in inds: - self.assertEqual(ind.route, [1, 1, 1]) + self.assertEqual(ind.route, [1, 1]) def test_routing_correct(self): N = ciw.create_network( @@ -86,27 +63,27 @@ def test_routing_correct(self): ciw.dists.Deterministic(2), ], number_of_servers=[1, 1, 1], - routing=[generator_function_2, ciw.no_routing, ciw.no_routing], + routing=ciw.routing.ProcessBased(generator_function_2), ) Q = ciw.Simulation(N) Q.simulate_until_max_time(4) ind = Q.get_all_individuals()[0] - self.assertEqual(ind.route, [1, 2, 1, 3]) + self.assertEqual(ind.route, [2, 1, 3]) self.assertEqual([dr.node for dr in ind.data_records], [1]) Q = ciw.Simulation(N) Q.simulate_until_max_time(6) ind = Q.get_all_individuals()[0] - self.assertEqual(ind.route, [2, 1, 3]) + self.assertEqual(ind.route, [1, 3]) self.assertEqual([dr.node for dr in ind.data_records], [1, 1]) Q = ciw.Simulation(N) Q.simulate_until_max_time(8) ind = Q.get_all_individuals()[0] - self.assertEqual(ind.route, [1, 3]) + self.assertEqual(ind.route, [3]) self.assertEqual([dr.node for dr in ind.data_records], [1, 1, 2]) Q = ciw.Simulation(N) Q.simulate_until_max_time(10) ind = Q.get_all_individuals()[0] - self.assertEqual(ind.route, [3]) + self.assertEqual(ind.route, []) self.assertEqual([dr.node for dr in ind.data_records], [1, 1, 2, 1]) Q = ciw.Simulation(N) Q.simulate_until_max_time(12) @@ -123,7 +100,7 @@ def test_probablistic_process_routes(self): ciw.dists.Exponential(2), ], number_of_servers=[1, 1, 1], - routing=[generator_function_3, ciw.no_routing, ciw.no_routing], + routing=ciw.routing.ProcessBased(generator_function_3), ) ciw.seed(0) Q = ciw.Simulation(N) @@ -137,150 +114,6 @@ def test_probablistic_process_routes(self): Counter({(1, 3, 2, 3): 244, (1, 2, 2, 3, 2): 204, (1, 1): 52}), ) - def test_error_when_ind_sent_wrong_place(self): - N = ciw.create_network( - arrival_distributions=[ciw.dists.Exponential(1)], - service_distributions=[ciw.dists.Exponential(2)], - number_of_servers=[1], - routing=[ciw.no_routing], - ) - Q = ciw.Simulation(N) - self.assertRaises(ValueError, Q.simulate_until_max_time, 20) - - N = ciw.create_network( - arrival_distributions=[None, ciw.dists.Exponential(1), None], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[ciw.no_routing, generator_function_2, ciw.no_routing], - ) - Q = ciw.Simulation(N) - self.assertRaises(ValueError, Q.simulate_until_max_time, 20) - - def test_routing_from_different_starting_points(self): - N = ciw.create_network( - arrival_distributions=[ - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - None, - ], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[generator_function_2, generator_function_4, ciw.no_routing], - ) - ciw.seed(0) - Q = ciw.Simulation(N) - Q.simulate_until_max_customers(500, method="Finish") - inds = Q.nodes[-1].all_individuals - routes_counter = Counter([tuple(dr.node for dr in ind.data_records) for ind in inds]) - self.assertEqual( - routes_counter, - Counter({(1, 1, 2, 1, 3): 245, (2, 1, 3, 1, 1): 255}) - ) - - N = ciw.create_network( - arrival_distributions=[ - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[generator_function_1, generator_function_4, generator_function_5], - ) - ciw.seed(0) - Q = ciw.Simulation(N) - Q.simulate_until_max_customers(500, method="Finish") - inds = Q.nodes[-1].all_individuals - routes_counter = Counter([tuple(dr.node for dr in ind.data_records) for ind in inds]) - self.assertEqual( - routes_counter, - Counter({(3, 2, 3, 2, 3): 256, (1, 1, 1): 155, (2, 1, 3, 1, 1): 89}), - ) - - N = ciw.create_network( - arrival_distributions=[ - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[generator_function_2, generator_function_4, generator_function_5], - ) - ciw.seed(0) - Q = ciw.Simulation(N) - Q.simulate_until_max_customers(500, method="Finish") - inds = Q.nodes[-1].all_individuals - routes_counter = Counter([tuple(dr.node for dr in ind.data_records) for ind in inds]) - self.assertEqual( - routes_counter, - Counter({(3, 2, 3, 2, 3): 233, (1, 1, 2, 1, 3): 148, (2, 1, 3, 1, 1): 119}), - ) - - N = ciw.create_network( - arrival_distributions=[ - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - ], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[generator_function_2, generator_function_4, generator_function_6], - ) - ciw.seed(0) - Q = ciw.Simulation(N) - Q.simulate_until_max_customers(500, method="Finish") - inds = Q.nodes[-1].all_individuals - routes_counter = Counter([tuple(dr.node for dr in ind.data_records) for ind in inds]) - self.assertEqual( - routes_counter, - Counter({(3,): 385, (1, 1, 2, 1, 3): 57, (2, 1, 3, 1, 1): 58}), - ) - - N = ciw.create_network( - arrival_distributions=[ - ciw.dists.Exponential(1), - ciw.dists.Exponential(1), - None, - ], - service_distributions=[ - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ciw.dists.Exponential(2), - ], - number_of_servers=[1, 1, 1], - routing=[generator_function_1, generator_function_7, ciw.no_routing], - ) - ciw.seed(0) - Q = ciw.Simulation(N) - Q.simulate_until_max_customers(500, method="Finish") - inds = Q.nodes[-1].all_individuals - routes_counter = Counter([tuple(dr.node for dr in ind.data_records) for ind in inds]) - self.assertEqual( - routes_counter, - Counter({(2, 1, 1, 2): 172, (2, 1, 2): 169, (1, 1, 1): 159}) - ) - def test_customer_class_based_routing(self): N = ciw.create_network( arrival_distributions={ @@ -292,7 +125,10 @@ def test_customer_class_based_routing(self): "Class 1": [ciw.dists.Exponential(2)], }, number_of_servers=[1], - routing=[generator_function_8], + routing={ + "Class 0": ciw.routing.ProcessBased(generator_function_4), + "Class 1": ciw.routing.ProcessBased(generator_function_1) + } ) ciw.seed(0) Q = ciw.Simulation(N) @@ -309,10 +145,10 @@ def test_process_based_takes_methods(self): arrival_distributions=[ciw.dists.Deterministic(1)], service_distributions=[ciw.dists.Deterministic(1000)], number_of_servers=[1], - routing=[G.generator_method], + routing=ciw.routing.ProcessBased(G.generator_method), ) Q = ciw.Simulation(N) Q.simulate_until_max_time(4.5) inds = Q.nodes[1].all_individuals for ind in inds: - self.assertEqual(ind.route, [1, 1, 1]) + self.assertEqual(ind.route, [1, 1]) diff --git a/ciw/tests/test_processor_sharing.py b/ciw/tests/test_processor_sharing.py index f97598c..c671a68 100644 --- a/ciw/tests/test_processor_sharing.py +++ b/ciw/tests/test_processor_sharing.py @@ -120,10 +120,34 @@ def test_init_method(self): N = ciw.PSNode(1, Q) self.assertEqual(N.ps_capacity, 9) self.assertEqual(N.c, float("inf")) - self.assertEqual( - [[round(p, 10) for p in row] for row in N.transition_row.values()], - [[0.1, 0.2, 0.1, 0.4, 0.2], [0.6, 0.0, 0.0, 0.2, 0.2], [0.0, 0.0, 0.4, 0.3, 0.3]], - ) + router0 = Q.network.customer_classes["Class 0"].routing + router1 = Q.network.customer_classes["Class 1"].routing + router2 = Q.network.customer_classes["Class 2"].routing + self.assertEqual(router0.routers[0].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router0.routers[1].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router0.routers[2].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router0.routers[3].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router1.routers[0].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router1.routers[1].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router1.routers[2].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router1.routers[3].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router2.routers[0].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router2.routers[1].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router2.routers[2].destinations, [1, 2, 3, 4, -1]) + self.assertEqual(router2.routers[3].destinations, [1, 2, 3, 4, -1]) + self.assertEqual([round(p, 2) for p in router0.routers[0].probs], [0.1, 0.2, 0.1, 0.4, 0.2]) + self.assertEqual([round(p, 2) for p in router0.routers[1].probs], [0.2, 0.2, 0.0, 0.1, 0.5]) + self.assertEqual([round(p, 2) for p in router0.routers[2].probs], [0.0, 0.8, 0.1, 0.1, 0.0]) + self.assertEqual([round(p, 2) for p in router0.routers[3].probs], [0.4, 0.1, 0.1, 0.0, 0.4]) + self.assertEqual([round(p, 2) for p in router1.routers[0].probs], [0.6, 0.0, 0.0, 0.2, 0.2]) + self.assertEqual([round(p, 2) for p in router1.routers[1].probs], [0.1, 0.1, 0.2, 0.2, 0.4]) + self.assertEqual([round(p, 2) for p in router1.routers[2].probs], [0.9, 0.0, 0.0, 0.0, 0.1]) + self.assertEqual([round(p, 2) for p in router1.routers[3].probs], [0.2, 0.1, 0.1, 0.1, 0.5]) + self.assertEqual([round(p, 2) for p in router2.routers[0].probs], [0.0, 0.0, 0.4, 0.3, 0.3]) + self.assertEqual([round(p, 2) for p in router2.routers[1].probs], [0.1, 0.1, 0.1, 0.1, 0.6]) + self.assertEqual([round(p, 2) for p in router2.routers[2].probs], [0.1, 0.3, 0.2, 0.2, 0.2]) + self.assertEqual([round(p, 2) for p in router2.routers[3].probs], [0.0, 0.0, 0.0, 0.3, 0.7]) + self.assertEqual(N.next_event_date, float("inf")) self.assertEqual(N.all_individuals, []) self.assertEqual(N.id_number, 1) diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index 8e36926..2459be2 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -629,7 +629,7 @@ class DummyNode3(ciw.Node): ciw.dists.Exponential(10), ciw.dists.Exponential(10), ], - routing=[[0, 0, 0], [0, 0, 0], [0, 0, 0]], + routing=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], number_of_servers=[1, 1, 1], ) @@ -715,7 +715,7 @@ def create_simulation_with_node_classes(node_classes): ciw.dists.Exponential(10), ciw.dists.Exponential(10), ], - routing=[[0, 0, 0], [0, 0, 0], [0, 0, 0]], + routing=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], number_of_servers=[1, 1, 1], ) return ciw.Simulation(N, node_class=node_classes) From 3ed49a79455f7cf99515a255efba16153227789b Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 18 Apr 2024 14:55:22 +0100 Subject: [PATCH 11/31] separate out error checking at routing initialise into separate method --- ciw/routing/routing.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index eec31dc..4bfd06f 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -99,7 +99,10 @@ def initialise(self, simulation, node): """ self.simulation = simulation self.node = node + self.error_check_at_initialise() + def error_check_at_initialise(self): + pass class Probabilistic(NodeRouting): """ @@ -123,11 +126,10 @@ def __init__(self, destinations, probs): self.destinations = destinations + [-1] self.probs = probs + [1 - sum(probs)] - def initialise(self, simulation, node): - super().initialise(simulation, node) + def error_check_at_initialise(self): if len(self.probs) != len(self.destinations): raise ValueError("Routing probabilities should correspond to destinations, and so should be lists of the same length.") - if not set(self.destinations).issubset(set([nd.id_number for nd in simulation.nodes[1:]])): + if not set(self.destinations).issubset(set([nd.id_number for nd in self.simulation.nodes[1:]])): raise ValueError("Routing destinations should be a subset of the nodes in the network.") def next_node(self, ind): From 5c2842c63eed137d2b3ccbc4aac744bcba08e95c Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 18 Apr 2024 15:43:10 +0100 Subject: [PATCH 12/31] add tests for routers --- ciw/tests/test_routing.py | 83 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 ciw/tests/test_routing.py diff --git a/ciw/tests/test_routing.py b/ciw/tests/test_routing.py new file mode 100644 index 0000000..fcc8c1b --- /dev/null +++ b/ciw/tests/test_routing.py @@ -0,0 +1,83 @@ +import unittest +import ciw +from collections import Counter + +N = ciw.create_network( + arrival_distributions=[ + ciw.dists.Exponential(rate=1.0), + ciw.dists.Exponential(rate=1.0), + ciw.dists.Exponential(rate=1.0), + ], + service_distributions=[ + ciw.dists.Exponential(rate=2.0), + ciw.dists.Exponential(rate=2.0), + ciw.dists.Exponential(rate=2.0), + ], + number_of_servers=[1, 2, 2], + routing=[ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0] + ] +) + +class TestRouting(unittest.TestCase): + def test_generic_network_router(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R = ciw.routing.NodeRouting() + R.initialise(Q, 1) + self.assertEqual(R.simulation, Q) + self.assertEqual(R.node, 1) + + + def test_probabilistic_routing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.6, 0.3, 0.1]) + R2 = ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.0, 0.0, 0.3]) + R3 = ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.33, 0.34, 0.33]) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + samples_1 = Counter([r.id_number for r in [R1.next_node(ind) for _ in range(10000)]]) + samples_2 = Counter([r.id_number for r in [R2.next_node(ind) for _ in range(10000)]]) + samples_3 = Counter([r.id_number for r in [R3.next_node(ind) for _ in range(10000)]]) + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [5976, 3067, 957, 0]) + self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) + self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) + + def test_network_routing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R = ciw.routing.NetworkRouting(routers=[ + ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.6, 0.3, 0.1]), + ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.0, 0.0, 0.3]), + ciw.routing.Probabilistic(destinations=[1, 2, 3], probs=[0.33, 0.34, 0.33]) + ]) + R.initialise(Q) + ind = ciw.Individual(1) + samples_1 = Counter([r.id_number for r in [R.next_node(ind, 1) for _ in range(10000)]]) + samples_2 = Counter([r.id_number for r in [R.next_node(ind, 2) for _ in range(10000)]]) + samples_3 = Counter([r.id_number for r in [R.next_node(ind, 3) for _ in range(10000)]]) + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [5976, 3067, 957, 0]) + self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) + self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) + + def test_transition_matrix_router(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R = ciw.routing.TransitionMatrix(transition_matrix=[ + [0.6, 0.3, 0.1], + [0.0, 0.0, 0.3], + [0.33, 0.34, 0.33] + ]) + R.initialise(Q) + ind = ciw.Individual(1) + samples_1 = Counter([r.id_number for r in [R.next_node(ind, 1) for _ in range(10000)]]) + samples_2 = Counter([r.id_number for r in [R.next_node(ind, 2) for _ in range(10000)]]) + samples_3 = Counter([r.id_number for r in [R.next_node(ind, 3) for _ in range(10000)]]) + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [5976, 3067, 957, 0]) + self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) + self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) From 70fc68f3a019a78d0485cfd57edeabab674c926b Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 18 Apr 2024 17:37:19 +0100 Subject: [PATCH 13/31] fix current docs to include router objects --- ciw/arrival_node.py | 3 +- ciw/routing/routing.py | 2 +- ciw/tests/test_process_based.py | 10 ++--- docs/Guides/process_based.rst | 66 +++++++++++++-------------------- docs/Guides/reneging.rst | 4 +- 5 files changed, 35 insertions(+), 50 deletions(-) diff --git a/ciw/arrival_node.py b/ciw/arrival_node.py index d777b24..3210495 100644 --- a/ciw/arrival_node.py +++ b/ciw/arrival_node.py @@ -92,8 +92,9 @@ def have_event(self): priority_class, simulation=self.simulation, ) - self.simulation.routers[next_individual.customer_class].initialise_individual(next_individual) next_node = self.simulation.transitive_nodes[self.next_node - 1] + next_individual.starting_node = next_node.id_number + self.simulation.routers[next_individual.customer_class].initialise_individual(next_individual) self.release_individual(next_node, next_individual) self.event_dates_dict[self.next_node][self.next_class] = self.increment_time( diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index 4bfd06f..672e0d9 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -76,7 +76,7 @@ def initialise_individual(self, ind): """ A method that is called at the arrival node when the individual is spawned. """ - ind.route = self.route_function(ind) + ind.route = self.route_function(ind, self.simulation) def next_node(self, ind, node_id): """ diff --git a/ciw/tests/test_process_based.py b/ciw/tests/test_process_based.py index c24a7a2..7b8ef53 100644 --- a/ciw/tests/test_process_based.py +++ b/ciw/tests/test_process_based.py @@ -4,15 +4,15 @@ from collections import Counter -def generator_function_1(ind): +def generator_function_1(ind, simulation): return [1, 1] -def generator_function_2(ind): +def generator_function_2(ind, simulation): return [1, 2, 1, 3] -def generator_function_3(ind): +def generator_function_3(ind, simulation): rnd = random.random() if rnd < 0.4: return [2, 2, 3, 2] @@ -20,13 +20,13 @@ def generator_function_3(ind): return [1] return [3, 2, 3] -def generator_function_4(ind): +def generator_function_4(ind, simulation): return [] class ClassForProcessBasedMethod: def __init__(self, n): self.n = n - def generator_method(self, ind): + def generator_method(self, ind, simulation): return [1, 1] diff --git a/docs/Guides/process_based.rst b/docs/Guides/process_based.rst index 5716395..d53d3d8 100644 --- a/docs/Guides/process_based.rst +++ b/docs/Guides/process_based.rst @@ -7,25 +7,25 @@ How to Define Process-Based Routing Ciw has the capability to run simulations with process-based routing. This means a customer's entire route is determined at the start and not determined probablistically as they progress through the network. This allows routes to account for an individuals history, for example, repeating nodes a certain number of times. -A customer's entire route is determined at the start, generated from a routing function, that takes in an individual and returns a route, which is a list of the order of the nodes. For example:: +A customer's entire route is determined at the start, generated from a routing function, that takes in an individual and returns a route, which is a list containing the order of the nodes they are to visit. The function should also take in the simulation itself, allowing time and state-dependent routing. For example:: - >>> def routing_function(ind): - ... return [1, 2, 2, 1] + >>> def routing_function(ind, simulation): + ... return [2, 2, 1] -This takes in an individual at Node 1 and assigns it the route [1, 2, 2, 1]. Then after service at Node 1 the individual is sent to Node 2, then back Node 2, then back to Node 1, before exiting the system. Ensuring the exact repetition of these nodes would not be possible in a purely probabilistic system. +This takes in an individual and assigns it the route [2, 2, 1]. Whichever node this individual arrives to (determined by the arrival distributions), after service there they are sent to Node 2, then back Node 2, then back to Node 1, before exiting the system. Ensuring the exact repetition of these nodes would not be possible in a purely probabilistic system. -In order to utilise this, we replace the routing matrix with a list of these routing functions to be used at each starting point. For example:: +In order to utilise this, we use a :code:`ProcessBased` routing object, that takes in this routing function. For example:: >>> import ciw - >>> def repeating_route(ind): - ... return [1, 1, 1] + >>> def repeating_route(ind, simulation): + ... return [1, 1] >>> N = ciw.create_network( ... arrival_distributions=[ciw.dists.Exponential(rate=1)], ... service_distributions=[ciw.dists.Exponential(rate=2)], ... number_of_servers=[1], - ... routing=[repeating_route] + ... routing=ciw.routing.ProcessBased(repeating_route) ... ) Here, customers arrive at Node 1, and have service there and then repeat this two more times before exiting the system. @@ -41,15 +41,7 @@ Let's run this and look at the routes of those that have left the system. {(1, 1, 1)} Now we can see that all individuals who have left the system, that is they have completed their route, repeated service at Node 1 three times. - -Important Notice ----------------- -**How it works:** You can think of this as, when an individual arrives at their first node, based on their :code:`arrival_distributions`, it is assigned a route that should start at this Node. This will ensure that the first Node which an individual arrives at is the same as the first Node in their assigned route. - -If this is not the case then the error :code:`'Individual process route sent to wrong node'` will occur. - -*Make sure that the routing function for Node* :math:`i` *yields routes that begin with Node* :math:`i`. Further example --------------- @@ -58,32 +50,26 @@ The routing functions can be as complicated as necessary. They take in an indivi Lets make a network with three nodes with the following routes: -* For customers arriving at Node 1: - - * if individual has an even :code:`id_number`, repeat Node 1 twice, then exit. - - * otherwise route from Node 1 to Node 2, to Node 3, and then exit. - -* Arrivals at Node 2: - - * have 50% chance of routing to Node 3, and then exit. - - * have 50% chance of routing to Node 1, and then exit. - -* There are no arrivals at Node 3. ++ For customers arriving at Node 1: + + if individual has an even :code:`id_number`, repeat Node 1 twice, then exit. + + otherwise route from Node 1 to Node 2, to Node 3, and then exit. ++ Arrivals at Node 2: + + have 50% chance of routing to Node 3, and then exit. + + have 50% chance of routing to Node 1, and then exit. ++ There are no arrivals at Node 3. For this we will require two routing functions: :code:`routing_function_Node_1`, :code:`routing_function_Node_2`:: - >>> def routing_function_Node_1(ind): - ... if ind.id_number % 2 == 0: - ... return [1, 1, 1] - ... return [1, 2, 3] - >>> import random - >>> def routing_function_Node_2(ind): - ... if random.random() <= 0.5: - ... return [2, 3] - ... return [2, 1] + >>> def routing_function(ind, simulation): + ... if ind.starting_node == 1: + ... if ind.id_number % 2 == 0: + ... return [1, 1, 1] + ... return [1, 2, 3] + ... if ind.starting_node == 2: + ... if random.random() <= 0.5: + ... return [2, 3] + ... return [2, 1] As there are no arrivals at Node 3, no customer will need routing assigned here. However, we need to use the placeholder function :code:`ciw.no_routing` to account for this:: @@ -95,7 +81,5 @@ As there are no arrivals at Node 3, no customer will need routing assigned here. ... ciw.dists.Exponential(rate=2), ... ciw.dists.Exponential(rate=2)], ... number_of_servers=[1,1,1], - ... routing=[routing_function_Node_1, - ... routing_function_Node_2, - ... ciw.no_routing] + ... routing=ciw.routing.ProcessBased(routing_function) ... ) diff --git a/docs/Guides/reneging.rst b/docs/Guides/reneging.rst index 019bfd1..3d5730c 100644 --- a/docs/Guides/reneging.rst +++ b/docs/Guides/reneging.rst @@ -51,8 +51,8 @@ Consider a two node network, the first node is an M/M/1, :math:`\lambda = 5` and ... service_distributions=[ciw.dists.Exponential(2), ... ciw.dists.Exponential(4)], ... number_of_servers=[1, 1], - ... routing=[[0, 0], - ... [0, 0]], + ... routing=[[0.0, 0.0], + ... [0.0, 0.0]], ... reneging_time_distributions=[ciw.dists.Deterministic(6), ... None], ... reneging_destinations=[2, -1] From a4536aec172643e7b946ef302ebb3db815768864 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 19 Apr 2024 00:24:41 +0100 Subject: [PATCH 14/31] add Direct, Leave, JoinShortestQueue, and LoadBalancing routers --- ciw/routing/routing.py | 90 +++++++++++++++ ciw/tests/test_routing.py | 228 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index 672e0d9..d7abb02 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -104,6 +104,7 @@ def initialise(self, simulation, node): def error_check_at_initialise(self): pass + class Probabilistic(NodeRouting): """ A router that probabilistically chooses the next node. @@ -140,3 +141,92 @@ def next_node(self, ind): return self.simulation.nodes[node_index] +class Direct(NodeRouting): + """ + A router that sends the individual directly to another node. + """ + def __init__(self, to): + """ + Initialises the routing object. + + Takes: + - to: a the node index to send to. + """ + self.to = to + + def next_node(self, ind): + """ + Chooses the node 'to' with probability 1. + """ + return self.simulation.nodes[self.to] + + +class Leave(NodeRouting): + """ + A router that sends the individual directly to the exit node. + """ + def next_node(self, ind): + """ + Chooses the exit node with probability 1. + """ + return self.simulation.nodes[-1] + + +class JoinShortestQueue(NodeRouting): + """ + A router that sends the individual to the node + with the shortest queue from a list of destinations. + """ + def __init__(self, destinations, tie_break='random'): + """ + Initialises the routing object. + + Takes: + - destinations: a list of node indices + - tie_break: the method to deal with ties. + + "random" - randomly choose between ties + + "order" - prioritise nodes in the order given by the destinations + """ + self.destinations = destinations + self.tie_break = tie_break + + def error_check_at_initialise(self): + if not set(self.destinations).issubset(set([nd.id_number for nd in self.simulation.nodes[1:]])): + raise ValueError("Routing destinations should be a subset of the nodes in the network.") + + def get_queue_size(self, node_index): + """ + Gets the size of the queue at the node_index. + """ + return self.simulation.nodes[node_index].number_of_individuals - self.simulation.nodes[node_index].number_in_service + + def next_node(self, ind): + """ + Chooses the node from the destinations with the shortest queue. + """ + shortest_queues = [] + shortest_queue_size = float('inf') + for node_index in self.destinations: + queue_size = self.get_queue_size(node_index) + if queue_size == shortest_queue_size: + shortest_queues.append(node_index) + if queue_size < shortest_queue_size: + shortest_queues = [node_index] + shortest_queue_size = queue_size + if self.tie_break == 'random': + next_node_index = ciw.random_choice(shortest_queues) + return self.simulation.nodes[next_node_index] + if self.tie_break == 'order': + next_node_index = shortest_queues[0] + return self.simulation.nodes[next_node_index] + + +class LoadBalancing(JoinShortestQueue): + """ + A version of JoinShortestQueue that also counts customers in service. + """ + def get_queue_size(self, node_index): + """ + Gets the size of the queue at the node_index. + """ + return self.simulation.nodes[node_index].number_of_individuals diff --git a/ciw/tests/test_routing.py b/ciw/tests/test_routing.py index fcc8c1b..cd1a526 100644 --- a/ciw/tests/test_routing.py +++ b/ciw/tests/test_routing.py @@ -48,6 +48,7 @@ def test_probabilistic_routing(self): self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) + def test_network_routing(self): ciw.seed(0) Q = ciw.Simulation(N) @@ -65,6 +66,7 @@ def test_network_routing(self): self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) + def test_transition_matrix_router(self): ciw.seed(0) Q = ciw.Simulation(N) @@ -81,3 +83,229 @@ def test_transition_matrix_router(self): self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [5976, 3067, 957, 0]) self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 3019, 6981]) self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [3278, 3444, 3278, 0]) + + + def test_direct_routing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.Direct(to=3) + R2 = ciw.routing.Direct(to=1) + R3 = ciw.routing.Direct(to=2) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + samples_1 = [r.id_number for r in [R1.next_node(ind) for _ in range(1000)]] + samples_2 = [r.id_number for r in [R2.next_node(ind) for _ in range(1000)]] + samples_3 = [r.id_number for r in [R3.next_node(ind) for _ in range(1000)]] + self.assertTrue(all(r == 3 for r in samples_1)) + self.assertTrue(all(r == 1 for r in samples_2)) + self.assertTrue(all(r == 2 for r in samples_3)) + + + def test_leave_routing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R = ciw.routing.Leave() + R.initialise(Q, 1) + ind = ciw.Individual(1) + samples = [r.id_number for r in [R.next_node(ind) for _ in range(1000)]] + self.assertTrue(all(r == -1 for r in samples)) + + + def test_join_shortest_queue(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.JoinShortestQueue(destinations=[1, 2, 3]) + R2 = ciw.routing.JoinShortestQueue(destinations=[1, 3]) + R3 = ciw.routing.JoinShortestQueue(destinations=[2, 3]) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + Q.nodes[1].number_of_individuals = 10 + Q.nodes[1].number_in_service = 1 + Q.nodes[2].number_of_individuals = 20 + Q.nodes[2].number_in_service = 2 + Q.nodes[3].number_of_individuals = 40 + Q.nodes[3].number_in_service = 2 + samples_1 = [r.id_number for r in [R1.next_node(ind) for _ in range(1000)]] + samples_2 = [r.id_number for r in [R2.next_node(ind) for _ in range(1000)]] + samples_3 = [r.id_number for r in [R3.next_node(ind) for _ in range(1000)]] + self.assertTrue(all(r == 1 for r in samples_1)) + self.assertTrue(all(r == 1 for r in samples_2)) + self.assertTrue(all(r == 2 for r in samples_3)) + + def test_join_shortest_queue_tiebreaks(self): + """ + There is a tie between nodes 2 and 3 + """ + # Default tie-break (random) + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.JoinShortestQueue(destinations=[1, 2, 3]) + R2 = ciw.routing.JoinShortestQueue(destinations=[1, 3]) + R3 = ciw.routing.JoinShortestQueue(destinations=[3, 2]) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + Q.nodes[1].number_of_individuals = 60 + Q.nodes[1].number_in_service = 1 + Q.nodes[2].number_of_individuals = 20 + Q.nodes[2].number_in_service = 2 + Q.nodes[3].number_of_individuals = 20 + Q.nodes[3].number_in_service = 2 + samples_1 = Counter([r.id_number for r in [R1.next_node(ind) for _ in range(1000)]]) + samples_2 = Counter([r.id_number for r in [R2.next_node(ind) for _ in range(1000)]]) + samples_3 = Counter([r.id_number for r in [R3.next_node(ind) for _ in range(1000)]]) + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [0, 507, 493, 0]) + self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 1000, 0]) + self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [0, 516, 484, 0]) + + # Explicitly set random tie-break + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.JoinShortestQueue(destinations=[1, 2, 3], tie_break="random") + R2 = ciw.routing.JoinShortestQueue(destinations=[1, 3], tie_break="random") + R3 = ciw.routing.JoinShortestQueue(destinations=[3, 2], tie_break="random") + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + Q.nodes[1].number_of_individuals = 60 + Q.nodes[1].number_in_service = 1 + Q.nodes[2].number_of_individuals = 20 + Q.nodes[2].number_in_service = 2 + Q.nodes[3].number_of_individuals = 20 + Q.nodes[3].number_in_service = 2 + samples_1 = Counter([r.id_number for r in [R1.next_node(ind) for _ in range(1000)]]) + samples_2 = Counter([r.id_number for r in [R2.next_node(ind) for _ in range(1000)]]) + samples_3 = Counter([r.id_number for r in [R3.next_node(ind) for _ in range(1000)]]) + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [0, 507, 493, 0]) + self.assertEqual([samples_2[i] for i in [1, 2, 3, -1]], [0, 0, 1000, 0]) + self.assertEqual([samples_3[i] for i in [1, 2, 3, -1]], [0, 516, 484, 0]) + + # Explicitly set ordered tie-break + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.JoinShortestQueue(destinations=[1, 2, 3], tie_break="order") + R2 = ciw.routing.JoinShortestQueue(destinations=[1, 3], tie_break="order") + R3 = ciw.routing.JoinShortestQueue(destinations=[3, 2], tie_break="order") + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + Q.nodes[1].number_of_individuals = 60 + Q.nodes[1].number_in_service = 1 + Q.nodes[2].number_of_individuals = 20 + Q.nodes[2].number_in_service = 2 + Q.nodes[3].number_of_individuals = 20 + Q.nodes[3].number_in_service = 2 + samples_1 = Counter([r.id_number for r in [R1.next_node(ind) for _ in range(1000)]]) + samples_2 = Counter([r.id_number for r in [R2.next_node(ind) for _ in range(1000)]]) + samples_3 = Counter([r.id_number for r in [R3.next_node(ind) for _ in range(1000)]]) + self.assertTrue(all(r == 2 for r in samples_1)) + self.assertTrue(all(r == 3 for r in samples_2)) + self.assertTrue(all(r == 3 for r in samples_3)) + + + def test_jsq_raises_errors(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.JoinShortestQueue(destinations=[1, 2, 7]) + self.assertRaises(ValueError, R1.initialise, Q, 1) + + + def test_jsq_in_simulation(self): + """ + First consider a 2 node system, and one dummy node. The + dummy node is where all customers arrive, and routes customers + to the other nodes in a 50%-50% schema. + """ + N = ciw.create_network( + arrival_distributions=[ + ciw.dists.Exponential(rate=3.0), + None, + None, + ], + service_distributions=[ + ciw.dists.Deterministic(value=0.0), + ciw.dists.Exponential(rate=2.0), + ciw.dists.Exponential(rate=2.0), + ], + number_of_servers=[float('inf'), 1, 1], + routing=ciw.routing.NetworkRouting(routers=[ + ciw.routing.Probabilistic(destinations=[2, 3], probs=[0.5, 0.5]), + ciw.routing.Leave(), + ciw.routing.Leave() + ]) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(100) + recs = Q.get_all_records() + waits_2 = [r.waiting_time for r in recs if r.node == 2] + waits_3 = [r.waiting_time for r in recs if r.node == 3] + self.assertEqual(round(sum(waits_2) / len(waits_2), 6), 0.941912) + self.assertEqual(round(sum(waits_3) / len(waits_3), 6), 2.209535) + self.assertEqual(round(max(waits_2), 6), 4.588178) + self.assertEqual(round(max(waits_3), 6), 8.652946) + """ + Now consider a 2 node system, and one dummy node. The + dummy node is where all customers arrive, and routes customers + to the other nodes with JSQ. + The average, and maximum waitint times are both lowered. + """ + N = ciw.create_network( + arrival_distributions=[ + ciw.dists.Exponential(rate=3.0), + None, + None, + ], + service_distributions=[ + ciw.dists.Deterministic(value=0.0), + ciw.dists.Exponential(rate=2.0), + ciw.dists.Exponential(rate=2.0), + ], + number_of_servers=[float('inf'), 1, 1], + routing=ciw.routing.NetworkRouting(routers=[ + ciw.routing.JoinShortestQueue(destinations=[2, 3]), + ciw.routing.Leave(), + ciw.routing.Leave() + ]) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(100) + recs = Q.get_all_records() + waits_2 = [r.waiting_time for r in recs if r.node == 2] + waits_3 = [r.waiting_time for r in recs if r.node == 3] + self.assertEqual(round(sum(waits_2) / len(waits_2), 6), 0.536455) + self.assertEqual(round(sum(waits_3) / len(waits_3), 6), 0.531789) + self.assertEqual(round(max(waits_2), 6), 2.777136) + self.assertEqual(round(max(waits_3), 6), 2.507875) + + + def test_load_balancing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.LoadBalancing(destinations=[1, 2, 3]) + R2 = ciw.routing.LoadBalancing(destinations=[1, 3]) + R3 = ciw.routing.LoadBalancing(destinations=[2, 3]) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + Q.nodes[1].number_of_individuals = 3 + Q.nodes[1].number_in_service = 1 + Q.nodes[2].number_of_individuals = 3 + Q.nodes[2].number_in_service = 2 + Q.nodes[3].number_of_individuals = 10 + Q.nodes[3].number_in_service = 2 + samples_1 = Counter([r.id_number for r in [R1.next_node(ind) for _ in range(1000)]]) + samples_2 = [r.id_number for r in [R2.next_node(ind) for _ in range(1000)]] + samples_3 = [r.id_number for r in [R3.next_node(ind) for _ in range(1000)]] + self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [507, 493, 0, 0]) + self.assertTrue(all(r == 1 for r in samples_2)) + self.assertTrue(all(r == 2 for r in samples_3)) From f5be6b5179bf884f72d92b3579e0bcb4834bd395 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 19 Apr 2024 00:37:56 +0100 Subject: [PATCH 15/31] update ps_routing docs with LoadBalancing router --- docs/Guides/behaviour/ps_routing.rst | 55 ++++++++++++---------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/docs/Guides/behaviour/ps_routing.rst b/docs/Guides/behaviour/ps_routing.rst index 16f2447..e7c9f8c 100644 --- a/docs/Guides/behaviour/ps_routing.rst +++ b/docs/Guides/behaviour/ps_routing.rst @@ -1,10 +1,10 @@ .. _ps-routing: -================================================ -Join Shortest Queue in Processor Sharing Systems -================================================ +=========================================== +Load Balancing in Processor Sharing Systems +============================================ -In this example we will consider multiple parallel processor sharing queues, where customers are routed to the least busy node. This is calles a Join Shortest Queue, or JSQ system. +In this example we will consider multiple parallel processor sharing queues, where customers are routed to the least busy node. This is calles a Load Balancing, a type of Join Shortest Queue, or JSQ system. Consider three independent parallel processor sharing nodes. Customers arrive and are sent to the least busy node. This can be modelled as a 4 node system: the first node is a dummy node where customers arrive, and routes the customer to one of the thee remaining processor sharing nodes. @@ -24,32 +24,22 @@ If the arrival distribution is Poisson with rate 8, and required service times a ... float('inf'), ... float('inf'), ... float('inf')], - ... routing=[[0, 0, 0, 0], - ... [0, 0, 0, 0], - ... [0, 0, 0, 0], - ... [0, 0, 0, 0]] + ... routing=ciw.routing.NetworkRouting(routers=[ + ... ciw.routing.LoadBalancing(destinations=[2, 3, 4]), + ... ciw.routing.Leave(), + ... ciw.routing.Leave(), + ... ciw.routing.Leave() + ... ]) ... ) -For each of the three parallel processor sharing nodes, we can use the :code:`ciw.PSNode` class. -However, we now need a custom node class for the initial dummy node, to take care of the routing decisions. -We'll call this class :code:`RoutingDecision`:: +For each of the three parallel processor sharing nodes, we can use the :code:`ciw.PSNode` class. The routing decisions are derived from the routing objects, where the first node is given the :code:`LoadBalancing` object, that balances the load in nodes 2, 3 and 4; that is, it sends the individual one of these nodes, whichever currently has the least individuals. - >>> class RoutingDecision(ciw.Node): - ... def next_node(self, ind): - ... """ - ... Finds the next node by looking at nodes 2, 3, and 4, - ... seeing how busy they are, and routing to the least busy. - ... """ - ... busyness = {n: self.simulation.nodes[n].number_of_individuals for n in [2, 3, 4]} - ... chosen_n = sorted(busyness.keys(), key=lambda x: busyness[x])[0] - ... return self.simulation.nodes[chosen_n] - -Now let's build a simulation object, where the first node uses our custom :code:`RoutingDecision` class, and the others use the built-in :code:`ciw.PSNode` class. We'll also add a state tracker for analysis:: +Now let's build a simulation object, where the first node uses the usual :code:`ciw.Node` class, and the others use the built-in :code:`ciw.PSNode` class. We'll also add a state tracker for analysis:: >>> ciw.seed(0) >>> Q = ciw.Simulation( ... N, tracker=ciw.trackers.SystemPopulation(), - ... node_class=[RoutingDecision, ciw.PSNode, ciw.PSNode, ciw.PSNode]) + ... node_class=[ciw.Node, ciw.PSNode, ciw.PSNode, ciw.PSNode]) We'll run this for 100 time units:: @@ -57,11 +47,14 @@ We'll run this for 100 time units:: We can look at the state probabilities, that is, the proportion of time the system spent in each state, where a state represents the number of customers present in the system:: - >>> Q.statetracker.state_probabilities(observation_period=(10, 90)) # doctest:+SKIP - {0: 0.425095024227593, - 1: 0.35989517302304014, - 2: 0.14629711075255158, - 3: 0.054182634504608064, - 4: 0.01124224242623659, - 5: 0.002061285633093934, - 6: 0.0012265294328765105} + >>> state_probs = Q.statetracker.state_probabilities(observation_period=(10, 90)) + >>> for n in range(8): + ... print(n, round(state_probs[n], 5)) + 0 0.436 + 1 0.37895 + 2 0.13629 + 3 0.03238 + 4 0.01255 + 5 0.00224 + 6 0.00109 + 7 0.00051 From 5855d5afa5d5f3788af7e103003b2a6af136e59e Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 15:06:08 +0100 Subject: [PATCH 16/31] add routing docs, queue capacity docs, customer class docs, and restructure docs --- docs/Background/codestructure.rst | 36 - docs/Background/index.rst | 1 - docs/Background/mechanisms.rst | 19 +- docs/Background/references.rst | 3 +- docs/Background/simulationpractice.rst | 2 +- docs/Guides/{ => Arrivals}/batching.rst | 0 docs/Guides/Arrivals/index.rst | 9 + .../{ => CustomerBehaviour}/baulking.rst | 0 docs/Guides/CustomerBehaviour/index.rst | 10 + .../{ => CustomerBehaviour}/reneging.rst | 0 .../change-class-after-service.rst | 0 .../change-class-while-queueing.rst | 0 .../CustomerClasses/customer-classes.rst | 71 + docs/Guides/CustomerClasses/index.rst | 12 + .../Guides/{ => CustomerClasses}/priority.rst | 7 - .../Distributions/combining_distributions.rst | 65 + docs/Guides/Distributions/index.rst | 12 + docs/Guides/{ => Distributions}/phasetype.rst | 10 +- .../{ => Distributions}/set_distributions.rst | 57 - .../{ => Distributions}/time_dependent.rst | 0 docs/Guides/Queues/index.rst | 10 + docs/Guides/Queues/queue_capacities.rst | 74 + docs/Guides/{ => Queues}/system_capacity.rst | 2 +- docs/Guides/Routing/custom_routing.rst | 93 + docs/Guides/Routing/index.rst | 14 + docs/Guides/Routing/join_shortest_queue.rst | 181 + docs/Guides/{ => Routing}/process_based.rst | 0 docs/Guides/Routing/routing_objects.rst | 84 + docs/Guides/Routing/transition_matrix.rst | 83 + docs/Guides/Services/index.rst | 14 + docs/Guides/{ => Services}/preemption.rst | 0 .../{ => Services}/processor-sharing.rst | 2 +- .../Guides/{ => Services}/server_priority.rst | 0 .../Guides/{ => Services}/server_schedule.rst | 0 .../{ => Services}/service_disciplines.rst | 0 docs/Guides/{ => Services}/slotted.rst | 2 +- docs/Guides/{ => Simulation}/exact.rst | 0 docs/Guides/Simulation/index.rst | 15 + .../{ => Simulation}/parallel_process.rst | 4 +- .../Guides/{ => Simulation}/pause_restart.rst | 0 docs/Guides/{ => Simulation}/progressbar.rst | 4 +- docs/Guides/{ => Simulation}/seed.rst | 0 docs/Guides/Simulation/sim_maxtime.rst | 33 + docs/Guides/{ => Simulation}/sim_numcusts.rst | 0 .../behaviour/custom_arrivals.rst | 0 .../behaviour/custom_number_servers.rst | 4 +- docs/Guides/{ => System}/behaviour/hybrid.rst | 4 +- docs/Guides/{ => System}/behaviour/index.rst | 2 - .../behaviour/server_dependent_dist.rst | 4 +- docs/Guides/{ => System}/deadlock.rst | 2 +- docs/Guides/System/index.rst | 11 + docs/Guides/{ => System}/state_trackers.rst | 0 docs/Guides/behaviour/custom_routing.rst | 92 - docs/Guides/behaviour/ps_routing.rst | 60 - docs/Guides/dynamic_customerclasses.rst | 19 - docs/Guides/index.rst | 37 +- docs/Tutorial-I/index.rst | 14 - docs/Tutorial-II/index.rst | 14 - docs/Tutorial/GettingStarted/index.rst | 12 + .../GettingStarted/part_1.rst} | 8 +- .../GettingStarted/part_2.rst} | 10 +- .../GettingStarted/part_3.rst} | 14 +- .../GettingStarted/part_4.rst} | 8 +- docs/Tutorial/index.rst | 16 + .../tutorial_ii.rst} | 6 +- .../tutorial_iii.rst} | 6 +- .../tutorial_iv.rst} | 6 +- docs/_static/custom_routing_with.svg | 5434 ++++++++--------- docs/_static/custom_routing_without.svg | 5344 ++++++++-------- docs/index.rst | 5 +- docs/requirements.txt | 2 +- 71 files changed, 6241 insertions(+), 5822 deletions(-) delete mode 100644 docs/Background/codestructure.rst rename docs/Guides/{ => Arrivals}/batching.rst (100%) create mode 100644 docs/Guides/Arrivals/index.rst rename docs/Guides/{ => CustomerBehaviour}/baulking.rst (100%) create mode 100644 docs/Guides/CustomerBehaviour/index.rst rename docs/Guides/{ => CustomerBehaviour}/reneging.rst (100%) rename docs/Guides/{ => CustomerClasses}/change-class-after-service.rst (100%) rename docs/Guides/{ => CustomerClasses}/change-class-while-queueing.rst (100%) create mode 100644 docs/Guides/CustomerClasses/customer-classes.rst create mode 100644 docs/Guides/CustomerClasses/index.rst rename docs/Guides/{ => CustomerClasses}/priority.rst (97%) create mode 100644 docs/Guides/Distributions/combining_distributions.rst create mode 100644 docs/Guides/Distributions/index.rst rename docs/Guides/{ => Distributions}/phasetype.rst (97%) rename docs/Guides/{ => Distributions}/set_distributions.rst (70%) rename docs/Guides/{ => Distributions}/time_dependent.rst (100%) create mode 100644 docs/Guides/Queues/index.rst create mode 100644 docs/Guides/Queues/queue_capacities.rst rename docs/Guides/{ => Queues}/system_capacity.rst (73%) create mode 100644 docs/Guides/Routing/custom_routing.rst create mode 100644 docs/Guides/Routing/index.rst create mode 100644 docs/Guides/Routing/join_shortest_queue.rst rename docs/Guides/{ => Routing}/process_based.rst (100%) create mode 100644 docs/Guides/Routing/routing_objects.rst create mode 100644 docs/Guides/Routing/transition_matrix.rst create mode 100644 docs/Guides/Services/index.rst rename docs/Guides/{ => Services}/preemption.rst (100%) rename docs/Guides/{ => Services}/processor-sharing.rst (99%) rename docs/Guides/{ => Services}/server_priority.rst (100%) rename docs/Guides/{ => Services}/server_schedule.rst (100%) rename docs/Guides/{ => Services}/service_disciplines.rst (100%) rename docs/Guides/{ => Services}/slotted.rst (99%) rename docs/Guides/{ => Simulation}/exact.rst (100%) create mode 100644 docs/Guides/Simulation/index.rst rename docs/Guides/{ => Simulation}/parallel_process.rst (93%) rename docs/Guides/{ => Simulation}/pause_restart.rst (100%) rename docs/Guides/{ => Simulation}/progressbar.rst (91%) rename docs/Guides/{ => Simulation}/seed.rst (100%) create mode 100644 docs/Guides/Simulation/sim_maxtime.rst rename docs/Guides/{ => Simulation}/sim_numcusts.rst (100%) rename docs/Guides/{ => System}/behaviour/custom_arrivals.rst (100%) rename docs/Guides/{ => System}/behaviour/custom_number_servers.rst (96%) rename docs/Guides/{ => System}/behaviour/hybrid.rst (98%) rename docs/Guides/{ => System}/behaviour/index.rst (97%) rename docs/Guides/{ => System}/behaviour/server_dependent_dist.rst (97%) rename docs/Guides/{ => System}/deadlock.rst (98%) create mode 100644 docs/Guides/System/index.rst rename docs/Guides/{ => System}/state_trackers.rst (100%) delete mode 100644 docs/Guides/behaviour/custom_routing.rst delete mode 100644 docs/Guides/behaviour/ps_routing.rst delete mode 100644 docs/Guides/dynamic_customerclasses.rst delete mode 100644 docs/Tutorial-I/index.rst delete mode 100644 docs/Tutorial-II/index.rst create mode 100644 docs/Tutorial/GettingStarted/index.rst rename docs/{Tutorial-I/tutorial_i.rst => Tutorial/GettingStarted/part_1.rst} (92%) rename docs/{Tutorial-I/tutorial_ii.rst => Tutorial/GettingStarted/part_2.rst} (89%) rename docs/{Tutorial-I/tutorial_iii.rst => Tutorial/GettingStarted/part_3.rst} (84%) rename docs/{Tutorial-I/tutorial_iv.rst => Tutorial/GettingStarted/part_4.rst} (95%) create mode 100644 docs/Tutorial/index.rst rename docs/{Tutorial-II/tutorial_v.rst => Tutorial/tutorial_ii.rst} (97%) rename docs/{Tutorial-II/tutorial_vi.rst => Tutorial/tutorial_iii.rst} (98%) rename docs/{Tutorial-II/tutorial_vii.rst => Tutorial/tutorial_iv.rst} (97%) diff --git a/docs/Background/codestructure.rst b/docs/Background/codestructure.rst deleted file mode 100644 index 560b75c..0000000 --- a/docs/Background/codestructure.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. _code-structure: - -============== -Code Structure -============== - -Ciw is structured in an object orientated way: - - -.. image:: ../_static/codestructure.svg - :scale: 100 % - :alt: Code structure for Ciw. - :align: center - -Ciw consists of 3 types of objects, Core, Input, and Optional: - -Core: - -- Simulation -- Arrival Node -- Exit Node -- Node -- Server -- Individual - -Input: - -- Network -- Service Centre -- Customer Classe -- Distributions - -Optional: - -- State Tracker -- Deadlock Detector \ No newline at end of file diff --git a/docs/Background/index.rst b/docs/Background/index.rst index 57ff8b5..1c69887 100644 --- a/docs/Background/index.rst +++ b/docs/Background/index.rst @@ -9,6 +9,5 @@ Contents: simulationpractice.rst mechanisms.rst kendall.rst - codestructure.rst other.rst references.rst \ No newline at end of file diff --git a/docs/Background/mechanisms.rst b/docs/Background/mechanisms.rst index b4b53c4..3669dfc 100644 --- a/docs/Background/mechanisms.rst +++ b/docs/Background/mechanisms.rst @@ -7,7 +7,7 @@ Notes on Ciw's Mechanisms General ~~~~~~~ -Ciw uses the *event scheduling* approach [SW14]_ , similar to the three phase approach. +Ciw uses the *event scheduling* approach [SR14]_ , similar to the three phase approach. In the event scheduling approach, three types of event take place: **A Events** move the clock forward, **B Events** are prescheduled events, and **C Events** are events that arise because a **B Event** has happened. Here **A-events** correspond to moving the clock forward to the next **B-event**. @@ -24,23 +24,6 @@ In event scheduling the following process occurs: 6. Repeat (2.) - (5.) until a terminating criteria has been satisfied -Blocking Mechanism -~~~~~~~~~~~~~~~~~~ - -In Ciw, Type I blocking (blocking after service) is implemented for restricted networks. - -After service, a customer's next destination is sampled from the transition matrix. -If there is space at the destination node, that customer will join the queue there. -Else if the destination node's queueing capacity is full, then that customer will be blocked. -That customer remains at that node, with its server, until space becomes available at the destination. -This means the server that was serving that customer remains attached to that customer, being unable to serve anyone else until that customer is unblocked. - -At the time of blockage, information about this customer is added to the destination node's :code:`blocked_queue`, a virtual queue containing information about all the customers blocked to that node, and *the order in which they became blocked*. -Thus, the sequence of unblockages happen in the order which customers were blocked. - -Circular blockages can lead to :ref:`deadlock `. - - .. _simultaneous_events: diff --git a/docs/Background/references.rst b/docs/Background/references.rst index 101b47d..4caf221 100644 --- a/docs/Background/references.rst +++ b/docs/Background/references.rst @@ -4,8 +4,9 @@ Bibliography ============ -.. [SW14] S. Robinson. *Simulation: the practice of model development and use.* Palgrave Macmillan, 2014. +.. [SR14] S. Robinson. *Simulation: the practice of model development and use.* Palgrave Macmillan, 2014. .. [WS09] W. Stewart. *Probability, markov chains, queues, and simulation.* Princeton university press, 2009. +.. [JJ57] J. Jackson. *Networks of Waiting Lines.* Operations Research, 1957. .. [JZ09] J. Zhang. G. Dai, and B. Zwart. *Law of large number limits of limited processor-sharing queues.* Mathematics of Operations Research 34.4, pp937-970, 2009. .. [XL09] X. Li. *Radio Access Network Dimensioning for 3G UMTS.* PhD Thesis, 2009. .. [GP21] G. Palmer and Y. Tian. *Implementing hybrid simulations that integrate DES+SD in Python.* Journal of Simulation, 2021. \ No newline at end of file diff --git a/docs/Background/simulationpractice.rst b/docs/Background/simulationpractice.rst index 4f60bb0..7753139 100644 --- a/docs/Background/simulationpractice.rst +++ b/docs/Background/simulationpractice.rst @@ -6,7 +6,7 @@ Simulation Practice Ensuring good practice when simulation modelling is important to get meaningful analyses from the models. This is shown in :ref:`Tutorial IV `. -A recommended resource on the subject is [SW14]_. +A recommended resource on the subject is [SR14]_. This page will briefly summarise some important aspects of carrying out simulation model analysis. ------------------------------- diff --git a/docs/Guides/batching.rst b/docs/Guides/Arrivals/batching.rst similarity index 100% rename from docs/Guides/batching.rst rename to docs/Guides/Arrivals/batching.rst diff --git a/docs/Guides/Arrivals/index.rst b/docs/Guides/Arrivals/index.rst new file mode 100644 index 0000000..6a535b5 --- /dev/null +++ b/docs/Guides/Arrivals/index.rst @@ -0,0 +1,9 @@ +Arrivals +======== + +Contents: + +.. toctree:: + :maxdepth: 2 + + batching.rst diff --git a/docs/Guides/baulking.rst b/docs/Guides/CustomerBehaviour/baulking.rst similarity index 100% rename from docs/Guides/baulking.rst rename to docs/Guides/CustomerBehaviour/baulking.rst diff --git a/docs/Guides/CustomerBehaviour/index.rst b/docs/Guides/CustomerBehaviour/index.rst new file mode 100644 index 0000000..c1614a2 --- /dev/null +++ b/docs/Guides/CustomerBehaviour/index.rst @@ -0,0 +1,10 @@ +Customer Behaviour +======== + +Contents: + +.. toctree:: + :maxdepth: 2 + + baulking.rst + reneging.rst diff --git a/docs/Guides/reneging.rst b/docs/Guides/CustomerBehaviour/reneging.rst similarity index 100% rename from docs/Guides/reneging.rst rename to docs/Guides/CustomerBehaviour/reneging.rst diff --git a/docs/Guides/change-class-after-service.rst b/docs/Guides/CustomerClasses/change-class-after-service.rst similarity index 100% rename from docs/Guides/change-class-after-service.rst rename to docs/Guides/CustomerClasses/change-class-after-service.rst diff --git a/docs/Guides/change-class-while-queueing.rst b/docs/Guides/CustomerClasses/change-class-while-queueing.rst similarity index 100% rename from docs/Guides/change-class-while-queueing.rst rename to docs/Guides/CustomerClasses/change-class-while-queueing.rst diff --git a/docs/Guides/CustomerClasses/customer-classes.rst b/docs/Guides/CustomerClasses/customer-classes.rst new file mode 100644 index 0000000..5d5f45f --- /dev/null +++ b/docs/Guides/CustomerClasses/customer-classes.rst @@ -0,0 +1,71 @@ +.. _customer-classes: + +======================================= +How to Set Multiple Classes of Customer +======================================= + +Ciw allows us to define different system parameters for different sets of customers sharing the same system infrastructure. These sets of customers are called different customers classes. This is defined by inserting dictionaries of parameters for the keywords in :code:`ciw.create_network`; these dictionaries will have keys of strings representing customer class names, and values corresponding to the parameters themselves. + +For example, consider an M/M/3 queue:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=5)], + ... service_distributions=[ciw.dists.Exponential(rate=7)], + ... number_of_servers=[2] + ... ) + +Now imagine we had two different types of customer: 40% of customers are Adults, while 60% of customers are children. Children twice as long to process than adults, and so will have a different service time distribution. In this case, Children and Adults will also have different arrival distributions: remember that arrival distributions define the distribution of inter-arrival times, and the inter-arrival times are defined as the inter-arrival times between two customers of the same class. + +Hence we would get:: + + >>> N = ciw.create_network( + ... arrival_distributions={ + ... "Adult": [ciw.dists.Exponential(rate=5 * 0.4)], + ... "Child": [ciw.dists.Exponential(rate=5 * 0.6)] + ... }, + ... service_distributions={ + ... "Adult": [ciw.dists.Exponential(rate=7)], + ... "Child": [ciw.dists.Exponential(rate=3.5)] + ... }, + ... number_of_servers=[2] + ... ) + +The decomposition of the arrival rates is due to `thinning of Poisson processes <(https://galton.uchicago.edu/~lalley/Courses/312/PoissonProcesses.pdf>`_. However, in general, think of arrival distributions as the distribution of inter-arrival times between customers of the same class. + +It is important that the keys of these parameter dictionaries are the same throughout. If splitting one keyword by customer class, then *all* keywords that can be split by customer class need to be split by customer class. + +When collecting results, the class of the customer associated with each service is also recorded:: + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_time(50) + >>> recs = Q.get_all_records() + >>> recs[0].customer_class + 'Child' + + >>> from collections import Counter + >>> Counter([r.customer_class for r in recs]) + Counter({'Child': 138, 'Adult': 89}) + +Nearly all parameters of :code:`ciw.create_network` can be split by customer class, unless they describe the architecture of the network itself. Those that can and cannot be split by customer class are listed below:: + +Can be split by customer class: + + :code:`arrival_distributions`, + + :code:`baulking_functions`, + + :code:`class_change_matrices`, + + :code:`class_change_time_distributions`, + + :code:`service_distributions`, + + :code:`routing`, + + :code:`batching_distributions`, + + :code:`reneging_time_distributions`, + + :code:`reneging_destinations`, + +Cannot be split by customer class: + + :code:`number_of_servers`, + + :code:`priority_classes`, + + :code:`queue_capacities`, + + :code:`ps_thresholds`, + + :code:`server_priority_functions`, + + :code:`service_disciplines`, + + :code:`system_capacity` diff --git a/docs/Guides/CustomerClasses/index.rst b/docs/Guides/CustomerClasses/index.rst new file mode 100644 index 0000000..01ec0d4 --- /dev/null +++ b/docs/Guides/CustomerClasses/index.rst @@ -0,0 +1,12 @@ +Customer Classes +================ + +Contents: + +.. toctree:: + :maxdepth: 2 + + customer-classes.rst + priority.rst + change-class-after-service.rst + change-class-while-queueing.rst diff --git a/docs/Guides/priority.rst b/docs/Guides/CustomerClasses/priority.rst similarity index 97% rename from docs/Guides/priority.rst rename to docs/Guides/CustomerClasses/priority.rst index dd04f1c..b03921a 100644 --- a/docs/Guides/priority.rst +++ b/docs/Guides/CustomerClasses/priority.rst @@ -58,10 +58,3 @@ There are a number of options that can be used to pre-emptively replace lower pr + :ref:`Pre-emption options `. -.. toctree:: - :maxdepth: 1 - :hidden: - - preemption.rst - - diff --git a/docs/Guides/Distributions/combining_distributions.rst b/docs/Guides/Distributions/combining_distributions.rst new file mode 100644 index 0000000..57127d5 --- /dev/null +++ b/docs/Guides/Distributions/combining_distributions.rst @@ -0,0 +1,65 @@ +.. _combine-dists: + +How to Combine Distributions +============================ + +There are two ways to combine distributions in Ciw: arithmetically, and probabilistically. + + +Arithmetically Combining Distributions +-------------------------------------- + +As distribution objects inherit from the generic distirbution function, they can be *combined* using the operations :code:`+`, :code:`-`, :code:`*`, and :code:`/`. + +For example, let's combine an Exponential distribution with a Deterministic distribution in all four ways:: + + >>> import ciw + >>> Exp_add_Det = ciw.dists.Exponential(rate=0.05) + ciw.dists.Deterministic(value=3.0) + >>> Exp_sub_Det = ciw.dists.Exponential(rate=0.05) - ciw.dists.Deterministic(value=3.0) + >>> Exp_mul_Det = ciw.dists.Exponential(rate=0.05) * ciw.dists.Deterministic(value=3.0) + >>> Exp_div_Det = ciw.dists.Exponential(rate=0.05) / ciw.dists.Deterministic(value=3.0) + +These combined distributions return the combined sampled values: + + >>> ciw.seed(10) + >>> Ex = ciw.dists.Exponential(rate=0.05) + >>> Dt = ciw.dists.Deterministic(value=3.0) + >>> [round(Ex.sample(), 2) for _ in range(5)] + [16.94, 11.2, 17.26, 4.62, 33.57] + >>> [round(Dt.sample(), 2) for _ in range(5)] + [3.0, 3.0, 3.0, 3.0, 3.0] + + >>> # Addition + >>> ciw.seed(10) + >>> [round(Exp_add_Det.sample(), 2) for _ in range(5)] + [19.94, 14.2, 20.26, 7.62, 36.57] + + >>> # Subtraction + >>> ciw.seed(10) + >>> [round(Exp_sub_Det.sample(), 2) for _ in range(5)] + [13.94, 8.2, 14.26, 1.62, 30.57] + + >>> # Multiplication + >>> ciw.seed(10) + >>> [round(Exp_mul_Det.sample(), 2) for _ in range(5)] + [50.83, 33.61, 51.78, 13.85, 100.7] + + >>> # Division + >>> ciw.seed(10) + >>> [round(Exp_div_Det.sample(), 2) for _ in range(5)] + [5.65, 3.73, 5.75, 1.54, 11.19] + + +Probabilistically Combining Distributions +----------------------------------------- + +Distributions can also be combined probabilistically. A countable and finite mixture distriubtion probabilistically chooses to sample from one of a number of given distributions. This is given by the :code:`MixtureDistribution` object. Given a number of distributions each with PDF :math:`D_i(x)`, each with a probability :math:`p_i`, such that :math:`\sum_i p_i = 1`, then the Mixture distribution has PMF :math:`f(x) = \sum_i p_i D_i(x)` + +For example, say let's make a mixture distribution that samples from an Exponential distribution rate 1 with probability 0.5, another Exponential distribution rate 2 with probability 0.2, a Uniform distribution between 0.2 and 0.8 with probability 0.2, and returns a Deterministic value of 0.5 with probability 0.1. We can do this with: + + >>> Exp1 = ciw.dists.Exponential(rate=1) + >>> Exp2 = ciw.dists.Exponential(rate=2) + >>> Unif = ciw.dists.Uniform(lower=0.2, upper=0.8) + >>> Det5 = ciw.dists.Deterministic(0.5) + + >>> M = ciw.dists.MixtureDistribution(dists=[Exp1, Exp2, Unif, Det5], probs=[0.5, 0.2, 0.2, 0.1]) diff --git a/docs/Guides/Distributions/index.rst b/docs/Guides/Distributions/index.rst new file mode 100644 index 0000000..6375c0b --- /dev/null +++ b/docs/Guides/Distributions/index.rst @@ -0,0 +1,12 @@ +Distributions +============= + +Contents: + +.. toctree:: + :maxdepth: 2 + + set_distributions.rst + phasetype.rst + combining_distributions.rst + time_dependent.rst diff --git a/docs/Guides/phasetype.rst b/docs/Guides/Distributions/phasetype.rst similarity index 97% rename from docs/Guides/phasetype.rst rename to docs/Guides/Distributions/phasetype.rst index 50887cf..05934ee 100644 --- a/docs/Guides/phasetype.rst +++ b/docs/Guides/Distributions/phasetype.rst @@ -11,7 +11,7 @@ It is therefore a random sum of exponential variables. The diagram below gives a general representation of an absorbing Markov chain with four states, 1, 2, 3, and the absorbing state :math:`\star`: -.. image:: ../_static/phasetype.svg +.. image:: ../../_static/phasetype.svg :width: 100% :alt: Absorbing Markov chain of a general Phase-Type distribution. :align: center @@ -52,7 +52,7 @@ Erlang Distributions An Erlang distribution with parameters :math:`\lambda` and :math:`n` is the sum of :math:`n` Exponential distributions with parameter :math:`\lambda`. This can be equivalently defined as the Phase-Type distribution with the following structure: -.. image:: ../_static/erlang.svg +.. image:: ../../_static/erlang.svg :width: 100% :alt: Absorbing Markov chain of an Erlang distribution. :align: center @@ -84,7 +84,7 @@ HyperExponential Distributions An HyperExponential distribution is defined by a probability vector :math:`\mathbf{p}` and rate vector :math:`\mathbf{\lambda}`, and samples an Exponential distribution with parameter :math:`\lambda_i` with probability :math:`p_i`. This can be equivalently defined as the Phase-Type distribution with the following structure: -.. image:: ../_static/hyperexponential.svg +.. image:: ../../_static/hyperexponential.svg :width: 100% :alt: Absorbing Markov chain of a HyperExponential distribution. :align: center @@ -117,7 +117,7 @@ HyperErlang Distributions A HyperErlang distribution is defined by parameters :math:`\mathbf{\lambda}`, :math:`\mathbf{p}`, and :math:`\mathbf{n}`, and samples an Erlang distribution of size :math:`n_i` with parameter :math:`\lambda_i` with probability :math:`p_i`. This can be equivalently defined as the Phase-Type distribution with the following structure: -.. image:: ../_static/hypererlang.svg +.. image:: ../../_static/hypererlang.svg :width: 100% :alt: Absorbing Markov chain of a HyperErlang distribution. :align: center @@ -150,7 +150,7 @@ Coxian Distributions A Coxian distribution is defined by parameters :math:`\mathbf{\lambda}`, the rates of each phase, and :math:`\mathbf{p}`, the probability of going to the absorbing state after each phase. This can be equivalently defined as the Phase-Type distribution with the following structure: -.. image:: ../_static/coxian.svg +.. image:: ../../_static/coxian.svg :width: 100% :alt: Absorbing Markov chain of a general Coxian distribution. :align: center diff --git a/docs/Guides/set_distributions.rst b/docs/Guides/Distributions/set_distributions.rst similarity index 70% rename from docs/Guides/set_distributions.rst rename to docs/Guides/Distributions/set_distributions.rst index b33bc20..1c9db91 100644 --- a/docs/Guides/set_distributions.rst +++ b/docs/Guides/Distributions/set_distributions.rst @@ -113,60 +113,3 @@ Consider a distribution that samples the value `3.0` 50% of the time, and sample ... return random.random() This can then be implemented into a :code:`Network` object in the usual way. - - -Combined Distributions ----------------------- - -As distribution objects inherit from the generic distirbution function, they can be *combined* using the operations :code:`+`, :code:`-`, :code:`*`, and :code:`/`. - -For example, let's combine an Exponential distribution with a Deterministic distribution in all four ways:: - - >>> Exp_add_Det = ciw.dists.Exponential(rate=0.05) + ciw.dists.Deterministic(value=3.0) - >>> Exp_sub_Det = ciw.dists.Exponential(rate=0.05) - ciw.dists.Deterministic(value=3.0) - >>> Exp_mul_Det = ciw.dists.Exponential(rate=0.05) * ciw.dists.Deterministic(value=3.0) - >>> Exp_div_Det = ciw.dists.Exponential(rate=0.05) / ciw.dists.Deterministic(value=3.0) - -These combined distributions return the combined sampled values: - - >>> ciw.seed(10) - >>> Ex = ciw.dists.Exponential(rate=0.05) - >>> Dt = ciw.dists.Deterministic(value=3.0) - >>> [round(Ex.sample(), 2) for _ in range(5)] - [16.94, 11.2, 17.26, 4.62, 33.57] - >>> [round(Dt.sample(), 2) for _ in range(5)] - [3.0, 3.0, 3.0, 3.0, 3.0] - - >>> # Addition - >>> ciw.seed(10) - >>> [round(Exp_add_Det.sample(), 2) for _ in range(5)] - [19.94, 14.2, 20.26, 7.62, 36.57] - - >>> # Subtraction - >>> ciw.seed(10) - >>> [round(Exp_sub_Det.sample(), 2) for _ in range(5)] - [13.94, 8.2, 14.26, 1.62, 30.57] - - >>> # Multiplication - >>> ciw.seed(10) - >>> [round(Exp_mul_Det.sample(), 2) for _ in range(5)] - [50.83, 33.61, 51.78, 13.85, 100.7] - - >>> # Division - >>> ciw.seed(10) - >>> [round(Exp_div_Det.sample(), 2) for _ in range(5)] - [5.65, 3.73, 5.75, 1.54, 11.19] - - -Mixture Distributions ---------------------- -Distributions can also be combined probabilistically. A countable and finite mixture distriubtion probabilistically chooses to sample from one of a number of given distributions. Given a number of distributions each with PDF :math:`D_i(x)`, each with a probability :math:`p_i`, such that :math:`\sum_i p_i = 1`, then the Mixture distribution has PMF :math:`f(x) = \sum_i p_i D_i(x)` - -For example, say let's make a mixture distribution that samples from an Exponential distribution rate 1 with probability 0.5, another Exponential distribution rate 2 with probability 0.2, a Uniform distribution between 0.2 and 0.8 with probability 0.2, and returns a Deterministic value of 0.5 with probability 0.1. We can do this with: - - >>> Exp1 = ciw.dists.Exponential(rate=1) - >>> Exp2 = ciw.dists.Exponential(rate=2) - >>> Unif = ciw.dists.Uniform(lower=0.2, upper=0.8) - >>> Det5 = ciw.dists.Deterministic(0.5) - - >>> M = ciw.dists.MixtureDistribution(dists=[Exp1, Exp2, Unif, Det5], probs=[0.5, 0.2, 0.2, 0.1]) diff --git a/docs/Guides/time_dependent.rst b/docs/Guides/Distributions/time_dependent.rst similarity index 100% rename from docs/Guides/time_dependent.rst rename to docs/Guides/Distributions/time_dependent.rst diff --git a/docs/Guides/Queues/index.rst b/docs/Guides/Queues/index.rst new file mode 100644 index 0000000..b5f23c6 --- /dev/null +++ b/docs/Guides/Queues/index.rst @@ -0,0 +1,10 @@ +Queues +====== + +Contents: + +.. toctree:: + :maxdepth: 2 + + queue_capacities.rst + system_capacity.rst diff --git a/docs/Guides/Queues/queue_capacities.rst b/docs/Guides/Queues/queue_capacities.rst new file mode 100644 index 0000000..a13ef16 --- /dev/null +++ b/docs/Guides/Queues/queue_capacities.rst @@ -0,0 +1,74 @@ +.. _queue-capacities: + +==================================== +How to Set Maximium Queue Capacities +==================================== + +A maximum queueing capacity can be set for each node. This means that once the number of people waiting at that node reaches the capacity, then that node cannot receive any more customers until some individuals leave the node. This affects newly arriving customers and customers transitioning from another node differently: + ++ Newly arriving customers who wish to enter the node once capacity is reached are *rejected*. They instead leave the system immediately, and have a data record written that records this rejection (:ref:`see below`). ++ Customers wishing to transition to the node after finishing service at another node are blocked (:ref:`see below`). This means thet remain at their original node, with a server whi is unable to begin another customer's service, until space becomes available. + +In order to implement this, we use the :code:`queue_capacities` keyworks when creating the network:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=1)], + ... service_distributions=[ciw.dists.Exponential(rate=1), + ... ciw.dists.Exponential(rate=1)], + ... routing=[[0.0, 0.5], + ... [0.0, 0.0]], + ... number_of_servers=[3, 2], + ... queue_capacities=[float('inf'), 10] + ... ) + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_time(100) + +In the example above, the second node has 2 servers and a queueing capacity of 10, and so once there are 12 customers present, it will not accept any more customers. In the first node, there is infinite queueing capacity, and so no blocking or rejection will occur here. + +If the :code:`queue_capacities` keyword is omitted, then infinite capacities are assumed, that is an uncapacitated system. + +.. _rejection-records: + +Rejection Records +~~~~~~~~~~~~~~~~~ + +Any customer that is rejected and leaves the system will be recorded by producing a data record of the rejection. This will look like this:: + + >>> recs = Q.get_all_records(only=['rejection']) + >>> dr = recs[0] + >>> dr + Record(id_number=283, customer_class='Customer', original_customer_class='Customer', node=2, arrival_date=86.79600309018552, waiting_time=nan, service_start_date=nan, service_time=nan, service_end_date=nan, time_blocked=nan, exit_date=86.79600309018552, destination=nan, queue_size_at_arrival=12, queue_size_at_departure=nan, server_id=nan, record_type='rejection') + +These records will have field :code:`record_type` as the string :code:`'rejection'`; will have information about the customer such as their ID number, and customer class; will have the :code:`node` they were rejected from; they will have equal :code:`arrival_date` and :code:`exit_date` representing the date they arrived and got rejected; and they will have a `queue_size_at_arrival` showing the number of people at the queue when they got rejected. All other fields will have :code:`nan` values. + + +.. _blocking-mechanism: + +Blocking Mechanism +~~~~~~~~~~~~~~~~~~ + +In Ciw, Type I blocking (blocking after service) is implemented for restricted networks. + +After service, a customer's next destination is sampled from the transition matrix. +If there is space at the destination node, that customer will join the queue there. +Else if the destination node's queueing capacity is full, then that customer will be blocked. +That customer remains at that node, with its server, until space becomes available at the destination. +This means the server that was serving that customer remains attached to that customer, being unable to serve anyone else until that customer is unblocked. + +At the time of blockage, information about this customer is added to the destination node's :code:`blocked_queue`, a virtual queue containing information about all the customers blocked to that node, and *the order in which they became blocked*. Therefore, when space becomes available, the customer to be unblocked will be the customer who was blocked first. That is, the sequence of unblockages happen in the order which customers were blocked. + +Circular blockages can lead to :ref:`deadlock `. + +Information about blockages are visible in the service data records:: + + >>> recs = Q.get_all_records(only=['service']) + >>> dr = recs[381] + >>> dr + Record(id_number=281, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=86.47159563260503, waiting_time=0.23440800156484443, service_start_date=86.70600363416987, service_time=0.6080763379283525, service_end_date=87.31407997209823, time_blocked=0.7507016571852461, exit_date=88.06478162928347, destination=2, queue_size_at_arrival=4, queue_size_at_departure=2, server_id=3, record_type='service') + +In the case above, the customer ended service at date :code:`87.31407997209823`, but didn't exit until date :code:`88.06478162928347`, giving a :code:`time_blocked` of :code:`0.7507016571852461`. + diff --git a/docs/Guides/system_capacity.rst b/docs/Guides/Queues/system_capacity.rst similarity index 73% rename from docs/Guides/system_capacity.rst rename to docs/Guides/Queues/system_capacity.rst index d7eeea3..7948270 100644 --- a/docs/Guides/system_capacity.rst +++ b/docs/Guides/Queues/system_capacity.rst @@ -4,7 +4,7 @@ How to Set a Maximium Capacity for the Whole System =================================================== -We have seen that :ref:`node capacities<_tutorial-vi>` can define restricted queueing networks. Ciw also allows for a whole system capacity to be set. When a system capacity is set, when the total number of customers present in *all* the nodes of the system is equal to the system capacity, then newly arriving customers will be rejected. Once the total number of customers drops back below the system capacity, then customers will be accepted into the system again. +We have seen that :ref:`node capacities<_tutorial-iii>` can define restricted queueing networks. Ciw also allows for a whole system capacity to be set. When a system capacity is set, when the total number of customers present in *all* the nodes of the system is equal to the system capacity, then newly arriving customers will be rejected. Once the total number of customers drops back below the system capacity, then customers will be accepted into the system again. In order to implement this, we use the :code:`system_capacity` keyworks when creating the network:: diff --git a/docs/Guides/Routing/custom_routing.rst b/docs/Guides/Routing/custom_routing.rst new file mode 100644 index 0000000..f748583 --- /dev/null +++ b/docs/Guides/Routing/custom_routing.rst @@ -0,0 +1,93 @@ +.. _custom-routing: + +==================================== +How to Define Custom Logical Routing +==================================== + +In the guide on :ref:`routing objects` we saw that node routing objects determine the where customers are routed to the next node. Custom routing objects can be created and used in Ciw, in order to define any logical routing that we wish. + +In order to define a routing object, they must inherit from Ciw'r :code:`ciw/routing.NodeRouting` object. We then need to define the :code:`next_node` method, that takes in the individual to route, and returns a node of the network that is that individual's next destination. If required, we can also re-write the object's :code:`__init__` method. Note that we will have access to some useful attributes: + + + :code:`self.simulation`: the simulation object itself, with access to its nodes, and the current time. + + :code:`self.node`: the node associated with this routing object. + +As an example, the built-in :code:`ciw.routing.Direct` router would look like this:: + + >>> import ciw + >>> class Direct(ciw.routing.NodeRouting): + ... """ + ... A router that sends the individual directly to another node. + ... """ + ... def __init__(self, to): + ... """ + ... Initialises the routing object. + ... + ... Takes: + ... - to: a the node index to send to. + ... """ + ... self.to = to + ... + ... def next_node(self, ind): + ... """ + ... Chooses the node 'to' with probability 1. + ... """ + ... return self.simulation.nodes[self.to] + + +Example +------- + +Imagine we have a four node network. Nodes 2, 3, and 4 all route customers out of the system. Node 1 however has some logical routing - before time 50 it will route to node 2 if it is empty, and node 3 otherwise; after time 50 it always routes to node 4. This is defined by:: + + >>> class CustomRouting(ciw.routing.NodeRouting): + ... def next_node(self, ind): + ... """ + ... Chooses node 2 if it is empty and it is before date 50, + ... Chooses node 3 if node 2 is not empty and it is before date 50 + ... Chooses node 4 is the date is after 50. + ... """ + ... if self.node.now >= 50: + ... return self.simulation.nodes[4] + ... if self.simulation.nodes[2].number_of_individuals == 0: + ... return self.simulation.nodes[2] + ... return self.simulation.nodes[3] + +Now if the only arrivals are to node 1, and we run this for 100 time units, we should observe that: services at node 2 had no waiting time; services at node 4 occurred after date 50; services at nodes 2 and 3 occurred before date 50:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... ciw.dists.Exponential(rate=1), + ... None, + ... None, + ... None + ... ], + ... service_distributions=[ + ... ciw.dists.Deterministic(value=0.2), + ... ciw.dists.Deterministic(value=0.2), + ... ciw.dists.Deterministic(value=0.2), + ... ciw.dists.Deterministic(value=0.2) + ... ], + ... number_of_servers=[3, 1, 1, 1], + ... routing=ciw.routing.NetworkRouting( + ... routers=[ + ... CustomRouting(), + ... ciw.routing.Leave(), + ... ciw.routing.Leave(), + ... ciw.routing.Leave() + ... ] + ... ) + ... ) + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_time(100) + >>> recs = Q.get_all_records() + + >>> all(r.service_start_date >= 50 for r in recs if r.node == 4) + True + >>> all(r.service_start_date <= 50 for r in recs if r.node in [2, 3]) + True + >>> all(r.queue_size_at_arrival == 0 for r in recs if r.node == 2) + True + + diff --git a/docs/Guides/Routing/index.rst b/docs/Guides/Routing/index.rst new file mode 100644 index 0000000..f2622a8 --- /dev/null +++ b/docs/Guides/Routing/index.rst @@ -0,0 +1,14 @@ +Routing +======== + +Contents: + +.. toctree:: + :maxdepth: 2 + + transition_matrix.rst + routing_objects.rst + join_shortest_queue.rst + process_based.rst + custom_routing.rst + diff --git a/docs/Guides/Routing/join_shortest_queue.rst b/docs/Guides/Routing/join_shortest_queue.rst new file mode 100644 index 0000000..acbf244 --- /dev/null +++ b/docs/Guides/Routing/join_shortest_queue.rst @@ -0,0 +1,181 @@ +.. _join-shortest-queue: + +====================================== +How to Set Join-Shortest-Queue Routing +====================================== + +Ciw has two built-in routing objects that allow two different forms of join-shortest-queue routing logic. Both objects take in a :code:`destinations` argument, which outlines which nodes are possibilities to send the individual to. The forms are: + ++ **Join-Shortest-Queue** + + Usage: :code:`ciw.routing.JoinShortestQueue(destinations=[1, 3])` + + Individuals are sent to the node, out of it's destinations, with the shortest *queue*. That is, which node has the least number of people waiting for service. + ++ **Load Balancing** + + Usage: :code:`ciw.routing.LoadBalancing(destinations=[1, 3])` + + Individuals are sent to the node, out of it's destinations, that has the lowest load. That is, which node has the least number of customers present, regardless if the customers are waiting for service or already in service. This can be useful for infinite server systems. + + +Extensive examples are given below: + +- :ref:`example_jsq` +- :ref:`example_lb` + + +.. _example_jsq: + +----------------------------- +Example - Join Shortest Queue +----------------------------- + +In this example we will consider a network where customers are routed differently depending on the system state. We will look at a system without this behaviour first, and then the system with the desired behaviour, for comparison. + + +**Without desired behaviour** + +Consider the following three node network, where arrivals only occur at the first node, then customers are randomly routed to either the 2nd or 3rd node before leaving:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... ciw.dists.Exponential(rate=10), + ... None, + ... None], + ... service_distributions=[ + ... ciw.dists.Exponential(rate=25), + ... ciw.dists.Exponential(rate=6), + ... ciw.dists.Exponential(rate=8)], + ... routing=[[0.0, 0.5, 0.5], + ... [0.0, 0.0, 0.0], + ... [0.0, 0.0, 0.0]], + ... number_of_servers=[1, 1, 1] + ... ) + +Now we run the system for 80 time units using a state tracker to track the number of customers at Node 1 and Node 2:: + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N, tracker=ciw.trackers.NodePopulation()) + >>> Q.simulate_until_max_time(80) + >>> ts = [ts[0] for ts in Q.statetracker.history] + >>> n2 = [ts[1][1] for ts in Q.statetracker.history] + >>> n3 = [ts[1][2] for ts in Q.statetracker.history] + +Plotting `n2` and `n3` we see that the numbers of customers at each node can diverge greatly:: + + >>> import matplotlib.pyplot as plt # doctest:+SKIP + >>> plt.plot(ts, n2); # doctest:+SKIP + >>> plt.plot(ts, n3); # doctest:+SKIP + +.. image:: ../../_static/custom_routing_without.svg + :alt: Plot of node populations diverging. + :align: center + + +**With desired behaviour** + +We will now replace the transition matrix with a network routing object, where the first node routes to the shortest queue out of Nodes 2 and 3, while the other nodes route the customer out of the system:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... ciw.dists.Exponential(rate=10), + ... None, + ... None], + ... service_distributions=[ + ... ciw.dists.Exponential(rate=25), + ... ciw.dists.Exponential(rate=6), + ... ciw.dists.Exponential(rate=8)], + ... routing=ciw.routing.NetworkRouting( + ... routers=[ + ... ciw.routing.JoinShortestQueue(destinations=[2, 3]), + ... ciw.routing.Leave(), + ... ciw.routing.Leave() + ... ] + ... ), + ... number_of_servers=[1, 1, 1] + ... ) + +Now rerun the same system :: + + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N, tracker=ciw.trackers.NodePopulation()) + >>> Q.simulate_until_max_time(80) + >>> ts = [ts[0] for ts in Q.statetracker.history] + >>> n2 = [ts[1][1] for ts in Q.statetracker.history] + >>> n3 = [ts[1][2] for ts in Q.statetracker.history] + +and now plotting `n2` and `n3`, we see that the numbers of customers at each node can follow one another closely, as we are always 'evening out' the nodes' busyness by always filling up the least busy node:: + + >>> plt.plot(ts, n2); # doctest:+SKIP + >>> plt.plot(ts, n3); # doctest:+SKIP + +.. image:: ../../_static/custom_routing_with.svg + :alt: Plot of node populations closely following each other. + :align: center + + + +.. _example_lb: + +------------------------ +Example - Load Balancing +------------------------ + +In this example we will consider multiple parallel :ref:`processor sharing` queues, where customers are routed to the least busy node. Here, because in processor sharing customers do not generally wait, just add to the servers' loads, we will use the Load Balancing routing, rather than the Join Shortest Queue routing. + +Consider three independent parallel processor sharing nodes. Customers arrive and are sent to the least busy node. +This can be modelled as a 4 node system: the first node is a dummy node where customers arrive, and routes the customer to one of the thee remaining processor sharing nodes. +If the arrival distribution is Poisson with rate 8, and required service times are exponentially distributed with parameter 10, then our network is:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=8), + ... None, + ... None, + ... None], + ... service_distributions=[ciw.dists.Deterministic(value=0), + ... ciw.dists.Exponential(rate=10), + ... ciw.dists.Exponential(rate=10), + ... ciw.dists.Exponential(rate=10)], + ... number_of_servers=[float('inf'), + ... float('inf'), + ... float('inf'), + ... float('inf')], + ... routing=ciw.routing.NetworkRouting( + ... routers=[ + ... ciw.routing.LoadBalancing(destinations=[2, 3, 4]), + ... ciw.routing.Leave(), + ... ciw.routing.Leave(), + ... ciw.routing.Leave() + ... ] + ... ) + ... ) + +For each of the three parallel processor sharing nodes, we can use the :code:`ciw.PSNode` class. The routing decisions are derived from the routing objects, where the first node is given the :code:`LoadBalancing` object, that balances the load in nodes 2, 3 and 4; that is, it sends the individual one of these nodes, whichever currently has the least individuals. + +Now let's build a simulation object, where the first node uses the usual :code:`ciw.Node` class, and the others use the built-in :code:`ciw.PSNode` class. We'll also add a state tracker for analysis:: + + >>> ciw.seed(0) + >>> Q = ciw.Simulation( + ... N, tracker=ciw.trackers.SystemPopulation(), + ... node_class=[ciw.Node, ciw.PSNode, ciw.PSNode, ciw.PSNode]) + +We'll run this for 100 time units:: + + >>> Q.simulate_until_max_time(100) + +We can look at the state probabilities, that is, the proportion of time the system spent in each state, where a state represents the number of customers present in the system:: + + >>> state_probs = Q.statetracker.state_probabilities(observation_period=(10, 90)) + >>> for n in range(8): + ... print(n, round(state_probs[n], 5)) + 0 0.436 + 1 0.37895 + 2 0.13629 + 3 0.03238 + 4 0.01255 + 5 0.00224 + 6 0.00109 + 7 0.00051 diff --git a/docs/Guides/process_based.rst b/docs/Guides/Routing/process_based.rst similarity index 100% rename from docs/Guides/process_based.rst rename to docs/Guides/Routing/process_based.rst diff --git a/docs/Guides/Routing/routing_objects.rst b/docs/Guides/Routing/routing_objects.rst new file mode 100644 index 0000000..33b2dc1 --- /dev/null +++ b/docs/Guides/Routing/routing_objects.rst @@ -0,0 +1,84 @@ +.. _routing-objects: + +========================== +How to Use Routing Objects +========================== + +Routing objects in Ciw are objects that determine what an individual's next node will be. There are two types: + ++ **Network routing objects**: these determine routing for the entire network; ++ **Node routing objects**: these determine routing from a particular node. + +Ciw has a number of these built in, however their primary use is to be defined by the user. Examples of Network routing objects are: + ++ :code:`ciw.routing.TransitionMatrix`, allowing users to define :ref:`transition matrices`, that is a matrix of probabilities of being transferred to each node in the network after service at every other node. ++ :code:`ciw.routing.ProcessBased`, allowing pre-defined routes to be given to individuals when they arrive, that is :ref:`process-based routing`. + +However, the most flexible Network routing object is the generic :code:`ciw.routing.NetworkRouting`. This takes in a list of Node routing objects. Node routing objects are objects that determine routing out of a particular node. The following are built-in to Ciw, but importantly, they can be user defined: + ++ :code:`ciw.routing.Direct(to=2)`: Sends the individual directly to another node. For example here, a customer is always send to node 2. ++ :code:`ciw.routing.Leave()`: The individual leaves the system. ++ :code:`ciw.routing.Probabilistic(destinations=[1, 3], probs=[0.1, 0.4])`: Probabilistically sends the individual to either of the destination, according to their corresponding probabilities. In this case, they are send to node 1 with probability 0.1, node 3 with probability 0.4, and leave the system with the rest of the probability, 0.5. ++ :code:`ciw.routing.JoinShortestQueue` and :code:`ciw.routing.LoadBalancing` are forms of sending the individual to the shortest queue. More information on these is given :ref:`here`. + + +Example +~~~~~~~ + +Consider a four node system: + + At the node 1 customers are send directly to node 2; + + At node 2 customers can be send to either nodes 1, 3 or 4 with probabilities 0.1, 0.5, and 0.4; + + At node 3 customers can either repeat service there with probability 0.25, or leave the system; + + At node 4, all customers leave the system. + +We can construct a network routing object for this by choosing a set of node routing obejects from the list above. We place them in a list and use the :code:`routers` keyword to create the network object. In this case, the network object would be:: + + >>> import ciw + >>> R = ciw.routing.NetworkRouting( + ... routers=[ + ... ciw.routing.Direct(to=2), + ... ciw.routing.Probabilistic(destinations=[1, 3, 4], probs=[0.1, 0.5, 0.4]), + ... ciw.routing.Probabilistic(destinations=[3], probs=[0.25]), + ... ciw.routing.Leave() + ... ] + ... ) + +This network object can then be used to create a network:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=1), ciw.dists.Exponential(rate=1), ciw.dists.Exponential(rate=1), ciw.dists.Exponential(rate=1)], + ... service_distributions=[ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2)], + ... number_of_servers=[1, 2, 3, 1], + ... routing=R + ... ) + + +Notes +~~~~~ + ++ Note that a the :code:`routing` keywork when creating a network object requires a network routing object, *not* a node routing object. This is true even when there is only one node in the network. ++ Note also that, similar to the use of most other keywords when creating a network object, that routing can be customer class dependent. + +For example, a one node network with two customer classes, both classes having different routing:: + + >>> N = ciw.create_network( + ... arrival_distributions={ + ... "Class 0": [ciw.dists.Exponential(rate=1)], + ... "Class 1": [ciw.dists.Exponential(rate=3)] + ... }, + ... service_distributions={ + ... "Class 0": [ciw.dists.Exponential(rate=2)], + ... "Class 1": [ciw.dists.Exponential(rate=2)] + ... }, + ... number_of_servers=[2], + ... routing={ + ... "Class 0": ciw.routing.NetworkRouting(routers=[ciw.routing.Leave()]), + ... "Class 1": ciw.routing.TransitionMatrix(transition_matrix=[[0.3]]) + ... } + ... ) + + +Custom Routing Objects +~~~~~~~~~~~~~~~~~~~~~~ + +Ciw allows for custom built routing objects. These allow for both time and state dependent routing, allowing flexible and routing logic. A guide is given :ref:`here`. diff --git a/docs/Guides/Routing/transition_matrix.rst b/docs/Guides/Routing/transition_matrix.rst new file mode 100644 index 0000000..3755ff0 --- /dev/null +++ b/docs/Guides/Routing/transition_matrix.rst @@ -0,0 +1,83 @@ +.. _transition-matrix: + +============================================== +How to Define Routing with a Transition Matrix +============================================== + +There are a number of methods of defining how customers are routed from one node to another after service. This is done with the :code:`routing` keyword when creating a network. + +One common method is by defining a transition matrix, common when defining Jackson networks [JJ57]_. +A transition matrix is an :math:`n \times n` matrix (where :math:`n` is the number of nodes in the network) such that the :math:`(i,j)\text{th}` element corresponds to the probability of transitioning to node :math:`j` after service at node :math:`i`. + +For example, in a three node network, a transition matrix might look like: + +.. math:: + + \begin{pmatrix} + 0.0 & 0.3 & 0.7 \\ + 0.0 & 0.0 & 1.0 \\ + 0.0 & 0.0 & 0.2 \\ + \end{pmatrix} + +This represents that: + + customers finishing service at node 1 enter node 2 with probability 0.3, and envet node 3 with probability 0.7; + + customers finishing service at node 2 are certain to enter node 3; + + customers finishing service at node 3 re-enter node 3 with probability 0.2, and with probability 0.8 leave the system. + +In Ciw there are two ways to define this, with a list of lists, or a :ref:`routing object`. + + +List of Lists +~~~~~~~~~~~~~ + +To define this with a list of lists, we can use:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... ciw.dists.Exponential(1), + ... ciw.dists.Exponential(1), + ... ciw.dists.Exponential(1) + ... ], + ... service_distributions=[ + ... ciw.dists.Exponential(2), + ... ciw.dists.Exponential(2), + ... ciw.dists.Exponential(2) + ... ], + ... number_of_servers=[3, 3, 3], + ... routing=[ + ... [0.0, 0.3, 0.7], + ... [0.0, 0.0, 0.1], + ... [0.0, 0.0, 0.2] + ... ] + ... ) + + +Routing Object +~~~~~~~~~~~~~~ + +To define with with a routing object we can make use of the built-in :code:`TransitionMatrix` routing object, and give it the list of lists above:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... ciw.dists.Exponential(1), + ... ciw.dists.Exponential(1), + ... ciw.dists.Exponential(1) + ... ], + ... service_distributions=[ + ... ciw.dists.Exponential(2), + ... ciw.dists.Exponential(2), + ... ciw.dists.Exponential(2) + ... ], + ... number_of_servers=[3, 3, 3], + ... routing=ciw.routing.TransitionMatrix( + ... transition_matrix=[ + ... [0.0, 0.3, 0.7], + ... [0.0, 0.0, 0.1], + ... [0.0, 0.0, 0.2] + ... ] + ... ) + ... ) + +Routing objects can be much more flexible that this, allowing logic based routing in addition to probabilistic based routing. More information can be found :ref:`here`. + diff --git a/docs/Guides/Services/index.rst b/docs/Guides/Services/index.rst new file mode 100644 index 0000000..3258d5b --- /dev/null +++ b/docs/Guides/Services/index.rst @@ -0,0 +1,14 @@ +Services +======== + +Contents: + +.. toctree:: + :maxdepth: 2 + + service_disciplines.rst + server_priority.rst + server_schedule.rst + slotted.rst + preemption.rst + processor-sharing.rst diff --git a/docs/Guides/preemption.rst b/docs/Guides/Services/preemption.rst similarity index 100% rename from docs/Guides/preemption.rst rename to docs/Guides/Services/preemption.rst diff --git a/docs/Guides/processor-sharing.rst b/docs/Guides/Services/processor-sharing.rst similarity index 99% rename from docs/Guides/processor-sharing.rst rename to docs/Guides/Services/processor-sharing.rst index 8a7ff22..f970231 100644 --- a/docs/Guides/processor-sharing.rst +++ b/docs/Guides/Services/processor-sharing.rst @@ -137,7 +137,7 @@ where :math:`R` is the process sharing capacity, :math:`\rho = \frac{\lambda}{R\ Plotting these theoretical results against a single run of our simulation shows a good alignment: -.. image:: ../_static/ps_capacitated_verification.svg +.. image:: ../../_static/ps_capacitated_verification.svg :alt: Alignment of theoretical and simulation results for a capacitated PS queue. :align: center diff --git a/docs/Guides/server_priority.rst b/docs/Guides/Services/server_priority.rst similarity index 100% rename from docs/Guides/server_priority.rst rename to docs/Guides/Services/server_priority.rst diff --git a/docs/Guides/server_schedule.rst b/docs/Guides/Services/server_schedule.rst similarity index 100% rename from docs/Guides/server_schedule.rst rename to docs/Guides/Services/server_schedule.rst diff --git a/docs/Guides/service_disciplines.rst b/docs/Guides/Services/service_disciplines.rst similarity index 100% rename from docs/Guides/service_disciplines.rst rename to docs/Guides/Services/service_disciplines.rst diff --git a/docs/Guides/slotted.rst b/docs/Guides/Services/slotted.rst similarity index 99% rename from docs/Guides/slotted.rst rename to docs/Guides/Services/slotted.rst index dba8ed3..872b4ba 100644 --- a/docs/Guides/slotted.rst +++ b/docs/Guides/Services/slotted.rst @@ -90,7 +90,7 @@ Slots can be capacitated or non-capacitated. This effects their behaviour when s This is shown visually below: -.. image:: ../_static/slotted.svg +.. image:: ../../_static/slotted.svg :scale: 20 % :alt: Comparing capacitated and non-capacitated slotted schedules. :align: center diff --git a/docs/Guides/exact.rst b/docs/Guides/Simulation/exact.rst similarity index 100% rename from docs/Guides/exact.rst rename to docs/Guides/Simulation/exact.rst diff --git a/docs/Guides/Simulation/index.rst b/docs/Guides/Simulation/index.rst new file mode 100644 index 0000000..6b79935 --- /dev/null +++ b/docs/Guides/Simulation/index.rst @@ -0,0 +1,15 @@ +Simulation +========== + +Contents: + +.. toctree:: + :maxdepth: 2 + + seed.rst + progressbar.rst + sim_maxtime.rst + sim_numcusts.rst + pause_restart.rst + parallel_process.rst + exact.rst diff --git a/docs/Guides/parallel_process.rst b/docs/Guides/Simulation/parallel_process.rst similarity index 93% rename from docs/Guides/parallel_process.rst rename to docs/Guides/Simulation/parallel_process.rst index 5483736..18d415a 100644 --- a/docs/Guides/parallel_process.rst +++ b/docs/Guides/Simulation/parallel_process.rst @@ -44,10 +44,10 @@ repeated and the average taken:: To obtain the above by running 2 simulations at the same time (assuming that 2 cores are available), the :code:`multiprocessing` library can be used. In which case the following :download:`main.py -<../_static/script_for_parallel_processing/main.py>` script gives a working +<../../_static/script_for_parallel_processing/main.py>` script gives a working example: -.. literalinclude:: ../_static/script_for_parallel_processing/main.py +.. literalinclude:: ../../_static/script_for_parallel_processing/main.py It is possible to use :code:`multiprocessing.cpu_count()` to obtain the number of available cores. diff --git a/docs/Guides/pause_restart.rst b/docs/Guides/Simulation/pause_restart.rst similarity index 100% rename from docs/Guides/pause_restart.rst rename to docs/Guides/Simulation/pause_restart.rst diff --git a/docs/Guides/progressbar.rst b/docs/Guides/Simulation/progressbar.rst similarity index 91% rename from docs/Guides/progressbar.rst rename to docs/Guides/Simulation/progressbar.rst index 69b06d7..e4361dd 100644 --- a/docs/Guides/progressbar.rst +++ b/docs/Guides/Simulation/progressbar.rst @@ -15,7 +15,7 @@ An example when using the :code:`simulate_until_max_time` method:: The image below shows an example of the output: -.. image:: ../_static/progress_bar_time.png +.. image:: ../../_static/progress_bar_time.png :scale: 100 % :alt: Output of progress bar (simulate_until_max_time). :align: center @@ -26,7 +26,7 @@ An example when using the :code:`simulate_until_max_customers` method:: And the image below shows the output: -.. image:: ../_static/progress_bar_customers.png +.. image:: ../../_static/progress_bar_customers.png :scale: 100 % :alt: Output of progress bar (simulate_until_max_customers). :align: center \ No newline at end of file diff --git a/docs/Guides/seed.rst b/docs/Guides/Simulation/seed.rst similarity index 100% rename from docs/Guides/seed.rst rename to docs/Guides/Simulation/seed.rst diff --git a/docs/Guides/Simulation/sim_maxtime.rst b/docs/Guides/Simulation/sim_maxtime.rst new file mode 100644 index 0000000..0d7ea67 --- /dev/null +++ b/docs/Guides/Simulation/sim_maxtime.rst @@ -0,0 +1,33 @@ +.. _until-maxtime: + +============================================ +How to Simulate For a Certain Amount of Time +============================================ + +Once the simulation object has been created, it can be simulated unil a certain amount of simulation-time has passed. This is done with the :code:`simulate_until_max_time` method, which takes in the :code:`max_simulation_time` positional keyword:: + + >>> Q.simulate_until_max_time(max_simulation_time=100.0) # doctest:+SKIP + +Notes +~~~~~ + +Note that the simulation does not finish at exactly this time. It will finish as soon as the time of the next scheduled event is after the :code:`max_simulation_time`. The clock will reach this nect event date, but not carry out the event. E.g. consider the following system with sparce events:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Deterministic(value=7.0)], + ... service_distributions=[ciw.dists.Deterministic(value=2.0)], + ... number_of_servers=[1] + ... ) + + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_time(22.0) + +We will see there there have been three arrivals at dates 7, 14 and 21, but only two completed services at dates 9 and 16, with the next scheduled event at date 23:: + + >>> recs = Q.get_all_records() # Completed records + >>> [r.arrival_date for r in recs] + [7.0, 14.0] + >>> Q.current_time + 23.0 + diff --git a/docs/Guides/sim_numcusts.rst b/docs/Guides/Simulation/sim_numcusts.rst similarity index 100% rename from docs/Guides/sim_numcusts.rst rename to docs/Guides/Simulation/sim_numcusts.rst diff --git a/docs/Guides/behaviour/custom_arrivals.rst b/docs/Guides/System/behaviour/custom_arrivals.rst similarity index 100% rename from docs/Guides/behaviour/custom_arrivals.rst rename to docs/Guides/System/behaviour/custom_arrivals.rst diff --git a/docs/Guides/behaviour/custom_number_servers.rst b/docs/Guides/System/behaviour/custom_number_servers.rst similarity index 96% rename from docs/Guides/behaviour/custom_number_servers.rst rename to docs/Guides/System/behaviour/custom_number_servers.rst index 7db5b74..0a0793f 100644 --- a/docs/Guides/behaviour/custom_number_servers.rst +++ b/docs/Guides/System/behaviour/custom_number_servers.rst @@ -29,7 +29,7 @@ Now let's plot the system population over time:: ... [row[1] for row in Q.statetracker.history] ... ); # doctest:+SKIP -.. image:: ../../_static/custom_number_servers_without.svg +.. image:: ../../../_static/custom_number_servers_without.svg :alt: Plot of system population increasing over time. :align: center @@ -70,7 +70,7 @@ Now let's plot the system population over time:: ... [row[1] for row in Q.statetracker.history] ... ); # doctest:+SKIP -.. image:: ../../_static/custom_number_servers_with.svg +.. image:: ../../../_static/custom_number_servers_with.svg :alt: Plot of system population over time. :align: center diff --git a/docs/Guides/behaviour/hybrid.rst b/docs/Guides/System/behaviour/hybrid.rst similarity index 98% rename from docs/Guides/behaviour/hybrid.rst rename to docs/Guides/System/behaviour/hybrid.rst index 498b1e0..256d15f 100644 --- a/docs/Guides/behaviour/hybrid.rst +++ b/docs/Guides/System/behaviour/hybrid.rst @@ -5,7 +5,7 @@ In the paper [GP21]_ a DES+SD hybrid simulation is defined using Ciw and Scipy. Consider a supermarket modelled as an :math:`M/M/k` queue, whose customers are living in an SIMD disease model. This is show below: -.. image:: ../../_static/hybrid.png +.. image:: ../../../_static/hybrid.png :alt: Stock and Flow / Queueing diagram of the hybrid system. :align: center @@ -158,6 +158,6 @@ Now a function can be written that will run one trial of this hybrid simulation. Running these for one trial only (this is bad practice, :ref:`see here `), we can plot the stock levels over time. We see that changing the number of servers from :math:`k = 1` to :math:`k = 3`, a parameter associated with the discrete component, changes the stock levels, results associated with the continuous component. -.. image:: ../../_static/des+sd_hybrid.svg +.. image:: ../../../_static/des+sd_hybrid.svg :alt: Stock level plots of the hybrid simulation results. :align: center diff --git a/docs/Guides/behaviour/index.rst b/docs/Guides/System/behaviour/index.rst similarity index 97% rename from docs/Guides/behaviour/index.rst rename to docs/Guides/System/behaviour/index.rst index 5d0fc8e..318ed8d 100644 --- a/docs/Guides/behaviour/index.rst +++ b/docs/Guides/System/behaviour/index.rst @@ -25,9 +25,7 @@ Here's a library of examples of this functionality: .. toctree:: :maxdepth: 1 - custom_routing.rst custom_arrivals.rst custom_number_servers.rst - ps_routing.rst server_dependent_dist.rst hybrid.rst diff --git a/docs/Guides/behaviour/server_dependent_dist.rst b/docs/Guides/System/behaviour/server_dependent_dist.rst similarity index 97% rename from docs/Guides/behaviour/server_dependent_dist.rst rename to docs/Guides/System/behaviour/server_dependent_dist.rst index 7cc6611..6a64466 100644 --- a/docs/Guides/behaviour/server_dependent_dist.rst +++ b/docs/Guides/System/behaviour/server_dependent_dist.rst @@ -40,7 +40,7 @@ For each server we can plot their cumulative count of individuals served over ti ... label=f"Server {s.id_number}") # doctest:+SKIP >>> plt.legend() # doctest:+SKIP -.. image:: ../../_static/server_dependent_dist_without.svg +.. image:: ../../../_static/server_dependent_dist_without.svg :alt: Plot server commission over time, all with the same behaviour. :align: center @@ -94,7 +94,7 @@ Plotting the cumulative counts for each server population over time:: ... label=f"Server {s.id_number}") # doctest:+SKIP >>> plt.legend() # doctest:+SKIP -.. image:: ../../_static/server_dependent_dist_with.svg +.. image:: ../../../_static/server_dependent_dist_with.svg :alt: Plot server commission over time, all with the different behaviour. :align: center diff --git a/docs/Guides/deadlock.rst b/docs/Guides/System/deadlock.rst similarity index 98% rename from docs/Guides/deadlock.rst rename to docs/Guides/System/deadlock.rst index 372cc32..a367fa6 100644 --- a/docs/Guides/deadlock.rst +++ b/docs/Guides/System/deadlock.rst @@ -8,7 +8,7 @@ Deadlock is the phenomenon whereby all movement and customer flow in a restricte The diagram below shows an example, where the customer at the top node is blocked to the bottom node, and the customer at the bottom node is blocked to the top node. This circular blockage results is no more natural movement happening. -.. image:: ../_static/2nodesindeadlock.svg +.. image:: ../../_static/2nodesindeadlock.svg :alt: A 2 node queueing network in deadlock. :align: center diff --git a/docs/Guides/System/index.rst b/docs/Guides/System/index.rst new file mode 100644 index 0000000..a118ad5 --- /dev/null +++ b/docs/Guides/System/index.rst @@ -0,0 +1,11 @@ +System +====== + +Contents: + +.. toctree:: + :maxdepth: 2 + + state_trackers.rst + deadlock.rst + behaviour/index.rst diff --git a/docs/Guides/state_trackers.rst b/docs/Guides/System/state_trackers.rst similarity index 100% rename from docs/Guides/state_trackers.rst rename to docs/Guides/System/state_trackers.rst diff --git a/docs/Guides/behaviour/custom_routing.rst b/docs/Guides/behaviour/custom_routing.rst deleted file mode 100644 index 2fbb559..0000000 --- a/docs/Guides/behaviour/custom_routing.rst +++ /dev/null @@ -1,92 +0,0 @@ -State-dependent Routing -======================= - -In this example we will consider a network where customers are routed differently depending on the system state. We will look at a system without this behaviour first, and then the system with the desired behaviour, for comparison. - - -Without desired behaviour -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Consider the following three node network, where arrivals only occur at the first node, then customers are randomly routed to either the 2nd or 3rd node before leaving:: - - >>> import ciw - - >>> N = ciw.create_network( - ... arrival_distributions=[ - ... ciw.dists.Exponential(rate=10), - ... None, - ... None], - ... service_distributions=[ - ... ciw.dists.Exponential(rate=25), - ... ciw.dists.Exponential(rate=6), - ... ciw.dists.Exponential(rate=8)], - ... routing=[[0.0, 0.5, 0.5], - ... [0.0, 0.0, 0.0], - ... [0.0, 0.0, 0.0]], - ... number_of_servers=[1, 1, 1] - ... ) - -Now we run the system for 80 time units using a state tracker to track the number of customers at Node 1 and Node 2:: - - >>> ciw.seed(0) - >>> Q = ciw.Simulation(N, tracker=ciw.trackers.NodePopulation()) - >>> Q.simulate_until_max_time(80) - - >>> ts = [ts[0] for ts in Q.statetracker.history] - >>> n2 = [ts[1][1] for ts in Q.statetracker.history] - >>> n3 = [ts[1][2] for ts in Q.statetracker.history] - -Plotting `n2` and `n3` we see that the numbers of customers at each node can diverge greatly:: - - >>> import matplotlib.pyplot as plt # doctest:+SKIP - >>> plt.plot(ts, n2); # doctest:+SKIP - >>> plt.plot(ts, n3); # doctest:+SKIP - -.. image:: ../../_static/custom_routing_without.svg - :alt: Plot of node populations diverging. - :align: center - - -With desired behaviour -~~~~~~~~~~~~~~~~~~~~~~ - -We will now create a new :code:`CustomRouting` for Node 1, that will send its customers to the least busy of Nodes 2 and 3. -First create the :code:`CustomRouting` that inherits from :code:`ciw.Node`, and overwrites the :code:`next_node` method:: - - >>> class CustomRouting(ciw.Node): - ... """ - ... Chooses either Node 2 or Node 3 as the destination node, - ... whichever has the least customers. Chooses randomly in - ... the event of a tie. - ... """ - ... def next_node(self, ind): - ... n2 = self.simulation.nodes[2].number_of_individuals - ... n3 = self.simulation.nodes[3].number_of_individuals - ... if n2 < n3: - ... return self.simulation.nodes[2] - ... elif n3 < n2: - ... return self.simulation.nodes[3] - ... return ciw.random_choice([self.simulation.nodes[2], self.simulation.nodes[3]]) - -Now rerun the same system, using the same network object :code:`N` (notice the transition matrix will be unused now). -We tell Ciw which node class to use for each node of the network, by giving the :code:`node_class` argumument a list of classes. -We'll use the new :code:`CustomRouting` class for Node 1, and the regular :code:`ciw.Node` class for Nodes 2 and 3:: - - >>> ciw.seed(0) - >>> Q = ciw.Simulation( - ... N, tracker=ciw.trackers.NodePopulation(), - ... node_class=[CustomRouting, ciw.Node, ciw.Node]) - >>> Q.simulate_until_max_time(80) - - >>> ts = [ts[0] for ts in Q.statetracker.history] - >>> n2 = [ts[1][1] for ts in Q.statetracker.history] - >>> n3 = [ts[1][2] for ts in Q.statetracker.history] - -Plotting `n2` and `n3` now, we see that the numbers of customers at each node can follow one another closely, as we are always 'evening out' the nodes' busyness by always filling up the least busy node:: - - >>> plt.plot(ts, n2); # doctest:+SKIP - >>> plt.plot(ts, n3); # doctest:+SKIP - -.. image:: ../../_static/custom_routing_with.svg - :alt: Plot of node populations closely following each other. - :align: center diff --git a/docs/Guides/behaviour/ps_routing.rst b/docs/Guides/behaviour/ps_routing.rst deleted file mode 100644 index e7c9f8c..0000000 --- a/docs/Guides/behaviour/ps_routing.rst +++ /dev/null @@ -1,60 +0,0 @@ -.. _ps-routing: - -=========================================== -Load Balancing in Processor Sharing Systems -============================================ - -In this example we will consider multiple parallel processor sharing queues, where customers are routed to the least busy node. This is calles a Load Balancing, a type of Join Shortest Queue, or JSQ system. - -Consider three independent parallel processor sharing nodes. Customers arrive and are sent to the least busy node. -This can be modelled as a 4 node system: the first node is a dummy node where customers arrive, and routes the customer to one of the thee remaining processor sharing nodes. -If the arrival distribution is Poisson with rate 8, and required service times are exponentially distributed with parameter 10, then our network is:: - - >>> import ciw - >>> N = ciw.create_network( - ... arrival_distributions=[ciw.dists.Exponential(rate=8), - ... None, - ... None, - ... None], - ... service_distributions=[ciw.dists.Deterministic(value=0), - ... ciw.dists.Exponential(rate=10), - ... ciw.dists.Exponential(rate=10), - ... ciw.dists.Exponential(rate=10)], - ... number_of_servers=[float('inf'), - ... float('inf'), - ... float('inf'), - ... float('inf')], - ... routing=ciw.routing.NetworkRouting(routers=[ - ... ciw.routing.LoadBalancing(destinations=[2, 3, 4]), - ... ciw.routing.Leave(), - ... ciw.routing.Leave(), - ... ciw.routing.Leave() - ... ]) - ... ) - -For each of the three parallel processor sharing nodes, we can use the :code:`ciw.PSNode` class. The routing decisions are derived from the routing objects, where the first node is given the :code:`LoadBalancing` object, that balances the load in nodes 2, 3 and 4; that is, it sends the individual one of these nodes, whichever currently has the least individuals. - -Now let's build a simulation object, where the first node uses the usual :code:`ciw.Node` class, and the others use the built-in :code:`ciw.PSNode` class. We'll also add a state tracker for analysis:: - - >>> ciw.seed(0) - >>> Q = ciw.Simulation( - ... N, tracker=ciw.trackers.SystemPopulation(), - ... node_class=[ciw.Node, ciw.PSNode, ciw.PSNode, ciw.PSNode]) - -We'll run this for 100 time units:: - - >>> Q.simulate_until_max_time(100) - -We can look at the state probabilities, that is, the proportion of time the system spent in each state, where a state represents the number of customers present in the system:: - - >>> state_probs = Q.statetracker.state_probabilities(observation_period=(10, 90)) - >>> for n in range(8): - ... print(n, round(state_probs[n], 5)) - 0 0.436 - 1 0.37895 - 2 0.13629 - 3 0.03238 - 4 0.01255 - 5 0.00224 - 6 0.00109 - 7 0.00051 diff --git a/docs/Guides/dynamic_customerclasses.rst b/docs/Guides/dynamic_customerclasses.rst deleted file mode 100644 index 10d4c3e..0000000 --- a/docs/Guides/dynamic_customerclasses.rst +++ /dev/null @@ -1,19 +0,0 @@ -.. _dynamic-classes: - -=================================== -How to Set Dynamic Customer Classes -=================================== - -Ciw has two ways in which customers can change their customer class: - -+ :ref:`Probabilistically after service at a node `. -+ :ref:`At a randomly distributed time while queueing `. - -These can be used in conjunction to one another. - -.. toctree:: - :maxdepth: 1 - :hidden: - - change-class-after-service.rst - change-class-while-queueing.rst \ No newline at end of file diff --git a/docs/Guides/index.rst b/docs/Guides/index.rst index 2e0166c..633f6a2 100644 --- a/docs/Guides/index.rst +++ b/docs/Guides/index.rst @@ -1,34 +1,19 @@ Guides ====== -This selection of How-to guides will give a tour of some of the features that Ciw has to offer. +This selection of How-to guides will give a tour of some of the features that Ciw has to offer. It is organised by what part of the simulation is effected by the behaviour described. Contents: .. toctree:: :maxdepth: 2 - - seed.rst - progressbar.rst - exact.rst - sim_numcusts.rst - pause_restart.rst - set_distributions.rst - phasetype.rst - time_dependent.rst - priority.rst - service_disciplines.rst - processor-sharing.rst - batching.rst - baulking.rst - reneging.rst - server_schedule.rst - slotted.rst - server_priority.rst - dynamic_customerclasses.rst - system_capacity.rst - state_trackers.rst - deadlock.rst - process_based.rst - behaviour/index.rst - parallel_process.rst + + Simulation/index.rst + Distributions/index.rst + Arrivals/index.rst + Queues/index.rst + Services/index.rst + Routing/index.rst + CustomerBehaviour/index.rst + CustomerClasses/index.rst + System/index.rst diff --git a/docs/Tutorial-I/index.rst b/docs/Tutorial-I/index.rst deleted file mode 100644 index a0f189b..0000000 --- a/docs/Tutorial-I/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -Tutorials - Part 1 -================== - -Part 1 of the Ciw tutorial will cover what is simulation and why we simulate. - -Contents: - -.. toctree:: - :maxdepth: 2 - - tutorial_i.rst - tutorial_ii.rst - tutorial_iii.rst - tutorial_iv.rst \ No newline at end of file diff --git a/docs/Tutorial-II/index.rst b/docs/Tutorial-II/index.rst deleted file mode 100644 index 6021b50..0000000 --- a/docs/Tutorial-II/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -Tutorials - Part 2 -================== - -Part 2 of the Ciw tutorial will cover some of the other core features of the library. -It covers networks of queues, the routing matrix, blocking, and multiple classes of customer. - -Contents: - -.. toctree:: - :maxdepth: 2 - - tutorial_v.rst - tutorial_vi.rst - tutorial_vii.rst \ No newline at end of file diff --git a/docs/Tutorial/GettingStarted/index.rst b/docs/Tutorial/GettingStarted/index.rst new file mode 100644 index 0000000..b0c48fc --- /dev/null +++ b/docs/Tutorial/GettingStarted/index.rst @@ -0,0 +1,12 @@ +Tutorial I - Getting Started +============================ + +Contents: + +.. toctree:: + :maxdepth: 2 + + part_1.rst + part_2.rst + part_3.rst + part_4.rst \ No newline at end of file diff --git a/docs/Tutorial-I/tutorial_i.rst b/docs/Tutorial/GettingStarted/part_1.rst similarity index 92% rename from docs/Tutorial-I/tutorial_i.rst rename to docs/Tutorial/GettingStarted/part_1.rst index eb35653..7a33133 100644 --- a/docs/Tutorial-I/tutorial_i.rst +++ b/docs/Tutorial/GettingStarted/part_1.rst @@ -1,8 +1,8 @@ .. _tutorial-i: -=========================================== -Tutorial I: Defining & Running a Simulation -=========================================== +==================================================== +Tutorial I: Part 1 - Defining & Running a Simulation +==================================================== Assume you are a bank manager and would like to know how long customers wait in your bank. Customers arrive randomly, roughly 12 per hour, regardless of the time of day. @@ -64,4 +64,4 @@ As we parametrised the system with time units of minutes, a day will be 1440 tim >>> Q.simulate_until_max_time(1440) Well done! We've now defined and simulated the bank for a day. -In the next tutorial, we will explore the :code:`Simulation` object some more. \ No newline at end of file +In the next part, we will explore the :code:`Simulation` object some more. \ No newline at end of file diff --git a/docs/Tutorial-I/tutorial_ii.rst b/docs/Tutorial/GettingStarted/part_2.rst similarity index 89% rename from docs/Tutorial-I/tutorial_ii.rst rename to docs/Tutorial/GettingStarted/part_2.rst index 62a2232..bec889f 100644 --- a/docs/Tutorial-I/tutorial_ii.rst +++ b/docs/Tutorial/GettingStarted/part_2.rst @@ -1,10 +1,10 @@ .. _tutorial-ii: -============================================ -Tutorial II: Exploring the Simulation Object -============================================ +==================================================== +Tutorial I: Part 2 - Exploring the Simulation Object +==================================================== -In the previous tutorial, we defined and simulated our bank for a week:: +In the previous part, we defined and simulated our bank for a week:: >>> import ciw >>> N = ciw.create_network( @@ -76,4 +76,4 @@ Individuals carry data records, that contain information such as arrival date, w 10.88093... This isn't a very efficient way to look at results. -In the next tutorial we will look at generating lists of records to gain some summary statistics. \ No newline at end of file +In the next part we will look at generating lists of records to gain some summary statistics. \ No newline at end of file diff --git a/docs/Tutorial-I/tutorial_iii.rst b/docs/Tutorial/GettingStarted/part_3.rst similarity index 84% rename from docs/Tutorial-I/tutorial_iii.rst rename to docs/Tutorial/GettingStarted/part_3.rst index 88a5031..91c5e54 100644 --- a/docs/Tutorial-I/tutorial_iii.rst +++ b/docs/Tutorial/GettingStarted/part_3.rst @@ -1,10 +1,10 @@ .. _tutorial-iii: -================================ -Tutorial III: Collecting Results -================================ +======================================= +Tutorial I: Part 3 - Collecting Results +======================================= -In the previous tutorials, we defined and simulated our bank for a week, and saw how to access parts of the simulation engine:: +In the previous parts, we defined and simulated our bank for a week, and saw how to access parts of the simulation engine:: >>> import ciw >>> N = ciw.create_network( @@ -63,7 +63,7 @@ Now we can get summary statistics simply by manipulating these lists:: 4.230543... We now know the mean waiting time of the customers! -In next tutorial we will show how to get more representative results (as we have only simulated one given day here). +In next part we will show how to get more representative results (as we have only simulated one given day here). Further summary statistics can be obtained using external libraries. We recommend `numpy `_, `pandas `_ and `matplotlib `_. @@ -73,7 +73,7 @@ The histogram of waits below was created using matplotlib, using the following c >>> import matplotlib.pyplot as plt # doctest:+SKIP >>> plt.hist(waits); # doctest:+SKIP -.. image:: ../_static/tutorial_iii_waitshist.svg +.. image:: ../../_static/tutorial_iii_waitshist.svg :alt: Histogram of waits for Tutorial III. :align: center @@ -85,4 +85,4 @@ This is the average utilisation of each server, which is the amount of time a se Thus in our bank, on average the servers were busy 75.3% of the time. -The next tutorial will show how to use Ciw to get trustworthy results, and finally find out the average waiting time at the bank. +The next part will show how to use Ciw to get trustworthy results, and finally find out the average waiting time at the bank. diff --git a/docs/Tutorial-I/tutorial_iv.rst b/docs/Tutorial/GettingStarted/part_4.rst similarity index 95% rename from docs/Tutorial-I/tutorial_iv.rst rename to docs/Tutorial/GettingStarted/part_4.rst index 13a75d8..ea25b13 100644 --- a/docs/Tutorial-I/tutorial_iv.rst +++ b/docs/Tutorial/GettingStarted/part_4.rst @@ -1,10 +1,10 @@ .. _tutorial-iv: -======================================== -Tutorial IV: Trials, Warm-up & Cool-down -======================================== +================================================ +Tutorial I: Part 4 - Trials, Warm-up & Cool-down +================================================ -In Tutorials I-III we investigated one run of a simulation of a bank. +In parts 1-3 we investigated one run of a simulation of a bank. Before we draw any conclusions about the behaviour of the bank, there are three things we should consider: 1. Our original description of the bank described it as open 24/7. This means the bank would never start from an empty state, as our simulation does. diff --git a/docs/Tutorial/index.rst b/docs/Tutorial/index.rst new file mode 100644 index 0000000..c16e66a --- /dev/null +++ b/docs/Tutorial/index.rst @@ -0,0 +1,16 @@ +Tutorials +========= + +Tutorial I is all about Getting Started, and will cover what is simulation and why we simulate. +The other tutorials are shorter and will cover some of the other core features of the library. +It covers networks of queues, the routing matrix, blocking, and multiple classes of customer. + +Contents: + +.. toctree:: + :maxdepth: 2 + + GettingStarted/index.rst + tutorial_ii.rst + tutorial_iii.rst + tutorial_iv.rst \ No newline at end of file diff --git a/docs/Tutorial-II/tutorial_v.rst b/docs/Tutorial/tutorial_ii.rst similarity index 97% rename from docs/Tutorial-II/tutorial_v.rst rename to docs/Tutorial/tutorial_ii.rst index aedfc6b..3ca5f37 100644 --- a/docs/Tutorial-II/tutorial_v.rst +++ b/docs/Tutorial/tutorial_ii.rst @@ -1,8 +1,8 @@ .. _tutorial-v: -=============================== -Tutorial V: A Network of Queues -=============================== +================================ +Tutorial II: A Network of Queues +================================ Ciw's real power comes when modelling networks of queues. That is many service nodes, such that when customers finish service, there is a probability of joining another node, rejoining the current node, or leaving the system. diff --git a/docs/Tutorial-II/tutorial_vi.rst b/docs/Tutorial/tutorial_iii.rst similarity index 98% rename from docs/Tutorial-II/tutorial_vi.rst rename to docs/Tutorial/tutorial_iii.rst index 0a429ad..1160c9e 100644 --- a/docs/Tutorial-II/tutorial_vi.rst +++ b/docs/Tutorial/tutorial_iii.rst @@ -1,8 +1,8 @@ .. _tutorial-vi: -================================ -Tutorial VI: Restricted Networks -================================ +================================= +Tutorial III: Restricted Networks +================================= Imagine a manufacturing plant that produces stools: diff --git a/docs/Tutorial-II/tutorial_vii.rst b/docs/Tutorial/tutorial_iv.rst similarity index 97% rename from docs/Tutorial-II/tutorial_vii.rst rename to docs/Tutorial/tutorial_iv.rst index 06e9611..0b1591b 100644 --- a/docs/Tutorial-II/tutorial_vii.rst +++ b/docs/Tutorial/tutorial_iv.rst @@ -1,8 +1,8 @@ .. _tutorial-vii: -========================================== -Tutorial VII: Multiple Classes of Customer -========================================== +========================================= +Tutorial IV: Multiple Classes of Customer +========================================= Imagine a 24 hour paediatricians clinic: diff --git a/docs/_static/custom_routing_with.svg b/docs/_static/custom_routing_with.svg index 2befc9c..2daf64a 100644 --- a/docs/_static/custom_routing_with.svg +++ b/docs/_static/custom_routing_with.svg @@ -1,2918 +1,2862 @@ - - + + + + + + 2024-04-26T13:29:41.985021 + image/svg+xml + + + Matplotlib v3.8.2, https://matplotlib.org/ + + + + + - + - +" style="fill: #ffffff"/> - +" style="fill: #ffffff"/> + + + - +" style="stroke: #333333; stroke-width: 0.8"/> - + - - + + - - - +" transform="scale(0.015625)"/> + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - +" style="stroke: #333333; stroke-width: 0.8"/> - + - - + + - + + + + - + - - - + + + - + + + + - + - - - + + + - + + + + - + - - - + + + - + + + + - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - + - - - - - - - + - - + + diff --git a/docs/_static/custom_routing_without.svg b/docs/_static/custom_routing_without.svg index e6c2207..5b3c80a 100644 --- a/docs/_static/custom_routing_without.svg +++ b/docs/_static/custom_routing_without.svg @@ -1,2863 +1,2853 @@ - - + + + + + + 2024-04-26T13:29:06.791084 + image/svg+xml + + + Matplotlib v3.8.2, https://matplotlib.org/ + + + + + - + - +" style="fill: #ffffff"/> - +" style="fill: #ffffff"/> + + + - +" style="stroke: #333333; stroke-width: 0.8"/> - + - - + + - - - +" transform="scale(0.015625)"/> + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - + - - + + - - - - +" transform="scale(0.015625)"/> + + + - + + + + - +" style="stroke: #333333; stroke-width: 0.8"/> - + - - + + - + + + + - + - - + + - + + + + - + - - - + + + - + + + + - + - - - + + + - + + + + - + - - - + + + - + + + + - + - - - + + + - - + + - - + + - + - - - - - - - + - - + + diff --git a/docs/index.rst b/docs/index.rst index 0e687a2..eb3cd2a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,12 +17,11 @@ Ciw is currently supported for and regularly tested on Python versions 3.7, Contents: .. toctree:: - :maxdepth: 2 + :maxdepth: 3 :titlesonly: installation.rst - Tutorial-I/index.rst - Tutorial-II/index.rst + Tutorial/index.rst Guides/index.rst Reference/index.rst Background/index.rst diff --git a/docs/requirements.txt b/docs/requirements.txt index 33078b8..0c2796b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -sphinx>=5.0.0 +sphinx>=7.3.7 pandas \ No newline at end of file From 73cf1f674f81e5672eebb04453a48fc1507e6172 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 15:45:48 +0100 Subject: [PATCH 17/31] add docs page for collecting records --- docs/Guides/Simulation/index.rst | 3 +- docs/Guides/Simulation/results.rst | 80 +++++++++++++++++++++++++++++ docs/Reference/results.rst | 8 +-- docs/_static/recs_pandas.png | Bin 0 -> 269108 bytes 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 docs/Guides/Simulation/results.rst create mode 100644 docs/_static/recs_pandas.png diff --git a/docs/Guides/Simulation/index.rst b/docs/Guides/Simulation/index.rst index 6b79935..3088eb9 100644 --- a/docs/Guides/Simulation/index.rst +++ b/docs/Guides/Simulation/index.rst @@ -7,9 +7,10 @@ Contents: :maxdepth: 2 seed.rst - progressbar.rst sim_maxtime.rst sim_numcusts.rst pause_restart.rst + results.rst + progressbar.rst parallel_process.rst exact.rst diff --git a/docs/Guides/Simulation/results.rst b/docs/Guides/Simulation/results.rst new file mode 100644 index 0000000..34de9e3 --- /dev/null +++ b/docs/Guides/Simulation/results.rst @@ -0,0 +1,80 @@ +.. _collect-results: + +====================== +How to Collect Results +====================== + +Once a simulation has been run, results can be collected. Results take the form of a data record. Certain events in a simulation's run cause data records to be created that describe those events. Those results are: + ++ Services ++ :ref:`Pre-empted services` ++ :ref:`Baulking customers` ++ :ref:`Reneging customers` ++ :ref:`Rejected customers` + +In order to collect all data records, we use the :code:`get_all_records()` method of the Simulation object. + +For example, in an M/M/3 queue:: + + >>> import ciw + >>> N = ciw.create_network( + ... arrival_distributions=[ciw.dists.Exponential(rate=1)], + ... service_distributions=[ciw.dists.Exponential(rate=2)], + ... number_of_servers=[1] + ... ) + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_time(100) + +To collect a list of all data records:: + + >>> recs = Q.get_all_records() + +This gives a list of :code:`DataRecord` objects, which are named tuples with a number of fields with useful information about the event in question:: + + >>> r = recs[14] + >>> r + Record(id_number=15, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=16.58266884119802, waiting_time=0.0, service_start_date=16.58266884119802, service_time=1.6996950244974869, service_end_date=18.28236386569551, time_blocked=0.0, exit_date=18.28236386569551, destination=-1, queue_size_at_arrival=0, queue_size_at_departure=1, server_id=1, record_type='service') + +These data records have a number of useful fields, set out in detail :ref:`here`. Importantly, fields can be accessed as attributes:: + + >>> r.service_start_date + 16.58266884119802 + +And so relevant data can be gathered using list comprehension:: + + >>> waiting_times = [r.waiting_time for r in recs] + >>> sum(waiting_times) / len(waiting_times) + 0.3989747357976479 + +For easier manipulation, use in conjuction with `Pandas `_ is recommended, allowing for easier filtering, grouping, and summary statistics calculations. Lists of data records convert to Pandas data frames smoothly: + + >>> import pandas as pd + >>> recs_pd = pd.DataFrame(recs) + >>> recs_pd # doctest: +SKIP + +.. image:: ../../_static/recs_pandas.png + :scale: 30 % + :alt: A Pandas data frame of Ciw data records. + :align: center + + + +Types of Records +~~~~~~~~~~~~~~~~ + +One particular field of note is the :code:`record_type` field, which indicates which of the five events caused that data record to be created. + ++ Services: gives :code:`record_type="service"` ++ Pre-empted services gives :code:`record_type="interrupted service"` ++ Baulking customers gives :code:`record_type="baulk"` ++ Reneging customers gives :code:`record_type="renege"` ++ Rejected customers gives :code:`record_type="rejection"` + +It is only by understanding the record types that we can understand the other data record fields. More information is given on the relevant Guide pages for each feature. + +When a simulation can produce multiple types of data record, it is sometimes useful to be able to only collect data records of a give type or types. we can do this with the optional keyword argument :code:`only`, which takes a list of the record types we are interested in:: + + >>> lost_customer_recs = Q.get_all_records(only=["rejection", "baulk", "renege"]) + >>> service_recs = Q.get_all_records(only=["service"]) + diff --git a/docs/Reference/results.rst b/docs/Reference/results.rst index 6d4f491..db19340 100644 --- a/docs/Reference/results.rst +++ b/docs/Reference/results.rst @@ -16,7 +16,7 @@ Data records are Named Tuples with the following attributes: * - :code:`id_number` - The unique identification number for that customer. * - :code:`customer_class` - - The number of that customer's customer class. If dynamic customer classes are used, this is the customer's class that they were when they recieved service at that node. + - The number of that customer's customer class. If dynamic customer classes are used, this is the customer's class that they were when they received service at that node. * - :code:`original_customer_class` - The number of the customer's class when they arrived at the node. * - :code:`node` @@ -34,7 +34,7 @@ Data records are Named Tuples with the following attributes: * - :code:`time_blocked` - The amount of time spent blocked at that node. That is the time between finishing service at exiting the node. * - :code:`exit_date` - - The date which the customer exited the node. This may be immediatly after service if no blocking occured, or after some period of being blocked. + - The date which the customer exited the node. This may be immediately after service if no blocking occured, or after some period of being blocked. * - :code:`destination` - The number of the customer's destination, that is the next node the customer will join after leaving the current node. If the customer leaves the system, this will be -1. * - :code:`queue_size_at_arrival` @@ -49,7 +49,7 @@ Data records are Named Tuples with the following attributes: The attribute :code:`record_type` can be one of: - :code:`"service"`: a completed service - - :code:`"interrupted service"`: an interupted service + - :code:`"interrupted service"`: an interrupted service - :code:`"renege"`: the waiting time while a customer waited before reneging - :code:`"baulk"`: the record of a customer baulking - :code:`"rejection"`: the record of a customer being rejected due to the queue being full @@ -60,7 +60,7 @@ You may access these records as a list of named tuples, using the Simulation's : >>> recs = Q.get_all_records() # doctest:+SKIP -By default, all record types are included. However, we may only want some of the record types, and these can be filtered by passing a list of desired record types to the :code:`only` keyword argument. For example, to only recieve service and reneging customers, we can use:: +By default, all record types are included. However, we may only want some of the record types, and these can be filtered by passing a list of desired record types to the :code:`only` keyword argument. For example, to only receive service and reneging customers, we can use:: >>> recs = Q.get_all_records(only=["service", "renege"]) # doctest:+SKIP diff --git a/docs/_static/recs_pandas.png b/docs/_static/recs_pandas.png new file mode 100644 index 0000000000000000000000000000000000000000..2eac768d6fcf80a359629e0341a2a47766a38dd9 GIT binary patch literal 269108 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%0AosFqo068 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
  • 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 z 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

    hd9Ff0P1_x{rffMQXD)D9@mDR zXZXw+W^Oi8KF7=Hi53absyutp%@fbK*>JU;*Z8*YIeaUSrB`QuUo+2{drdOhj~}S*#5B^E^dQ&dem!%^O!(wNGe;O@Go@s-0cQ>8ShgeU=$87wXYEZZ5uOU%o3YWH*=U(tL} za+gmxZ;_lC$rpGAUS0X5W;!`b9xHM2;zOsm<8c9Uwunj;0vhS8o|T;-Pd(~7CT|cV zG=e?jKND{)3W^ipw_jew{W_p+IZ|7kTRrIlSr}CQcrf2L(_#@N?YEZbKO8c8@iGQvd)#@m#?}n7K`BZ zEMY&>)zp8y`r0ZJyY{mB3mqeiVO{|oLpsI>3WRWh6>AR>ZBAOTFp=bkry~s!u9qiK z3wrI{A9ws#_C&?<@N8T)zrJBkB{t_ zQ=281qjBhnC&>vgCg~rg1O`r|rjeK_n{0PMpSaC?sW$K&(d=HJl_9u-AQ#4qPQBw} z{S>i))oa>?n~2RxlX7hFTGLIs?FEs_6=RpY6n1a1b-H7CT&lr-z4<1ZN&bhX$^J)+ z^j50)R;XEI3^`gz-L>xt_oFtrl5)KN#DbP6lOVyo0d>tY@P|LcRV}$rOjxGHVD_)NLM%S8>8D*G|qMfja)oiYa_i3lFGp{ zWx+`X0SeQ1tr^D^|D_;r?ELU1dG70+H+6WNwX@)H*?E+3|K%VDOhO!F z?9{!bD_6K8v{aQ%0XO-7Gm$gv3^`uFLA{rteHy$bYao9(D#EUv{bA4}XibHM56;O5 zBp~4?kds*35u>%{j(5HLEpli%#`0 zy7!1jRx!XaRXjb=#X+M6LVkU~9iZ%mR_&OfJR=GW%Xj3;xp|&QLGnPKeac#Tm@ocIIER&*&w* z^%X_?c}>mc!&voS)y3B>fbfv}bhPFSFaD1Z5BWY#$b;jC;( z2?l==!{$fPy%(j)i9u2b)PD~>Lxoa9TWhof5Q`=~3nRg;+UtYF91_tq>JbyUh5Cqk zYw3OQ#Ji5Z{yMr9yBBV$X|?yR1UE``in}%0zWuO<~E3b~XEgycX+eoQ}*F zYnKM5*fB!^zhA|(6!|xFj7@EqQgq;2L@tQx#hR4;dI+c3lc3oIVHxw7u$o!|=Cmm8 zIi^I@Ub)|h%wGwPmk-NQa6`J^%6#spyurlctkoDsnW_8*OvlaC6NpiUodsuR96~Gv zgErbD6n^`oJ12->203FEL{^Ns@p2IP`0?>TXowT_=N`WDuZ{Sdme-gbN90k_Mo{;w z`n$m)QD7XTa||!2Pk975O(@sBXm0Z0B9AVJq6&#JRMG)`MuofaZ?H`6<;0r&&k&Ld z+X=a@Cq>|*C$1*?NKuz}MyCHEN3{P2+gh%$CrKC^CvOh~q=~Xz z770n57~<4hCH9V&($q$9|_st3UN4 zc06QVd+Irq|7G7>YY#CI!^}OsDz_@_R5i8zS5rEvvFIWMbQt%m01xK7f}fP;G$lSO z+lc_3qHPj@E@h;H&H3%LK#5 zW~+3c(?-jOcWe2d)ywn~4=5|iZb{m*2@&aQSr*WAu0|b>=2g0SYxM6ne(R;P6A`cp z8s?C)FFJ|zkkX4ht#t)}6mP9YU9Rp48t(K3L3wwHPpc_yc2|^eUA_;}o zq~??wA`vK{s;VEBVF*|4J~Hq-Pb3bphkwe+qy9Qys=c;E9wNr=&1Xo&2X3`Kg29Xt zr)9V@%z8F}dU9Vjv6!kfAk36E5Y)=u;sN8CL2q2!{9jTt&Y;1lZc6Dc0*fZGu6j3} zAQ9%w48$ejd`s+P-hW}+r6_`T%*0N^Qv{_75AUc@Um30|%ss9c7+-0IB=w*(3cO4_ zjA(hm6^^<*Tai|64h+~;*sp~Z8n7HraA3rXLC@WA&-u`BZYp%XT;^3L7Kb&G|8DfxwKzGUPv4?-Dq7^_R^*y;X8}Ra8>~H%4O;MPbmnHJ@Wu~b*J@EqP>Jj*q^6Qb9;C=_uq#;^j zlGX4f*mFBbNU=@<6?)zEs?~Z$GCqh08!j|?kxV!z$E+TMMTKeqICD^ei#fCe<(=jf zn&s#yAejc{fap^nO9N>2+(=mybp2VqTt9rGA7qHgnfk2IcJn03tX;eAT$SWC>{h)R zD>&_!t&x2SR24ptdn+hzf+?tEdP2?uwrf16%5jlHwnXU$y(&xr=wz1DX_3Cp4(xd0xW035b$D!J$vt&a3SMQnoel4{q&)|2U<*fDTP1QkhrD&aQZf%~PcF7m< z|MDx==|9LK=o|B3Hfo_e?aU;N?$@qt01Cdk^(y+rV*LR3AT&c9knFwPZ!!d$LBVes z%70)Q9ksU}D6e#f3+?vRg<1?3>Gk~Bga(YYlMk}<^RzFG5zsANzv%T~?k$6y2v0Bw zlhAm228mv(`b2sWTV%433@Od&UAu478PM;WLSo7ozxW`6_O=6|jSlNa?({0&p4(o?9-Pb>9nV(8F`OJ>&wpN)FhzK>qF) zWM3fjmBL3~Y9YcF+9Ap$u2Jin;y&|pW~EM8PJ?nrBReOh?K&Z<2QF`OmcD zafsuU411v`G&$rWM(xjBcZuq&G*${to!d^%2LR)iNP-8xNdjRI& zmM>@o3GmvQbViaCrinL306wTe7%|)JKZ?uRO3Rr9$_9pzM4RC%+UBL}rD(lOXE-H0nmwY{TsTJ5s_m~61)F(NTxvO?T zI#x2Isw&^FhP6iA_&ieYCbbHgiQ;#@Nc84Vcn8T@g4+xpFS%{{qJo0H#6&a*QSgKNjC{Siv!A`h!?#8WjfD68V`R@%FIZD8Zu|vae&y%esZ0S?RsbI ziBOq5358m7dXwIWyAC*M>Jvv$`#Rq>q5e4b>qAk^ej`ug7O#@<-eVosT#W0fLZO$T zGRWpoBI>X~z5 z-%+=DX5~2Iaf`i0w%J$0r6HtJKfU~nV|Kc_ziLbD&42D`o%VonP4FizCrhaE$8H}x z`m{}7FYS?l;?KB+{PM9AybKa&7(_k?FaRvib$=%xFNO*rP@lsViIq7xDsjEF=6D#KmaaLrWLBkCA9G6UlH_3DtRGv3M zn>Ue(@>sVFdis~PSz5h($44kd1uQi{h8Fc^St0gF$tg*vJg+cUt#_VP!gBuU3H9WcKc3F;#3LT~`2#7l7 zaRqt$0f86V;0Jh7riX#N=+6Z?`4T7D4Bh9fX@r@-pW1yDgJx(biDnbz&1Qi-H zyD$OVR3_wFf&@v-Joh98if$ypAhzb7Th&O{D5>tsK*}mlX@61o!k{6#;B?vH0 zH?baYHrU;r<1Orc+mpf`)HeFG?B^VW$+qUhV8TjSeA@O=EG^bO+I7`iDN%QqKj>aF zeAWV$e`a6$lFS}<WU8ybq78U6arFw-9md@jeD$6%xZ7cj0c(5pqHoPy1wkg>q5@O3ul zr5s+z_`0sa6VsISq@NgWPs-m;MeNl}u_W8sKTqfyexuQ}3w`Z+XW%@Fzde1xyo&t+TbqS9J#b{_fj~pk443@fQ9KbD`|>r>1{I3Wc&~ zSw1+*vk?gWB1L0=PX*H9eXIr=%v-aUwZxC#)0aPvZ$6YXQI8>pJuxKfBsHPJF*Dn( z3GIn?+>`A1IKAOk%s3m>Z zHTv@t%tV%@q>Mv)c!ci-@#VFKLCw0#G8onWcDd`TPd7oDx*SkULPx8MOH?Aa3g)!t zPb*$k?<;OdlVXn&`lC<;SRL&2-zx zVuWDz^2DU$leWXtK=q3FK|EwbUMY{r-5;cLVVGd_li)w+zZHF}V*fad_LX4gHi8yL z5o%$2Tu)x6em+MRk`&jd56wDxl~_M*g?o#_gZZ) z0du`(1XLao70|5UyuUtMZtkB**rrE)ja^~Jv-hS$M7u=|alZ~+juLFZl;o&ovOO)5 z#Vq}KpeUjaAV|G`KmE5nO@Zm)r!K9mP6B3Rg}L7kpSb5V|CCICjwEWBlRPP`*4p`} z^O~ngvWmmhCm`)9pE1C>BInkx_~$I&v(nm%UOD2HC7ksa#X?fxys)%GNzaB-H0;)B zUug^q`@La7$YQA$W@aKhj;hR<#V5GA?HhFEgA0-lRLbD`&_g<244$mv!xlU`-|tav zV5|G*Tny-yv7OA6<_)l(>cMvNcU$j;_E312@C1i+hWeBipN!5Sd5d7#6zhVLzVv1O z?dn-<5svZI#uU7q>v$T zC_;QyXIH1}UWA4gRc&|-DFaFy%HG749EZMvi4rGGk6VhCy+c=vHUoops95P~GD8=6 z5=XQAe4H0Qj;@1+Lsx>TZTy_<#e?z!*)L1BVMvjn|1=Z2`<6r@b zHM<;CUD9UftU15 z^_LcYjDbx<1-OVmQb4*x;WRs?VT1H~N>1umlM-T(Y*vvB{ow4KKb@w9n{WSH=_26Q5?lh{a>lXskqQVj3 z-4q&(L(9In>PdipPOH88rGd&s1=CPnZ)cK;7n|O+#96Nr9t^v^13E{zzH>PCb$%(< z@_qEQ47z?al_U2uA^d5YL=0wvl>CUQN#oORiAqmCiH``rjBMRZ=gN8oE$KA$IY zLOsI1uW>l`T0pj7+{G?a?C=6Iv0n8=7v~?%WzfLs`}xeis(_R|ilnkq47wUeA)z4B z)0y!#X7bncTyDSMsx)xNqofi8WZ7a`6)Gk5VcL!Ezs+sdnxLMit%mk$`SG>sT2S*lVO4~ zXC?Zc4jqN3K}s=Qqv}U!6*{TZ?;rH`UFc+Z@R_^~%atvEVef#f2cp@IQn8=7Nq44+ zdW;Gr1nQSwzSGatmN(@qGs^gMo#gG>i&cc*)ctl(XjKzE8%^O}R^w`dD#e5^BQ-`& zg;IAKOs^Er9s)M$!>3htjPKYT_qyu+F@s6^aH5rpUWEcRYidoLalTA#8>_T(k-dZMx;__uIiY zAt|mB_V%Sa!9kJLeUG5f0~$jyUNm}@zXHUd!=Ncf&S7k$UsD?<*^D2CpZqFp(R#Cs zZi+OQzs$VoN0&v7XUb#uahlP= zSDc?G6z#~%fLyRRpj2{Q8-*IlRML;sTOU5ppP)n3E9i90zP_r{ZE6N4+mvASg8JI) zbRRh#q(SE$t7H{CbQBWY$EjZkzxgHt^*dm!{JuD)u&lzYZ*T)(B)9C=vZ$PheeoRe z68;_c1MhC#l;&&51Lhin)!T>n%p>uDK>OpLi&HXrBD8*|Th+Xfs`qZDc5WN|ph6a` zlLl{g4c2c}_mXaB_p1Zi0$$8S10yiW#N2YJM-f zU1M?_&-pp%UOlK=d%a0YH4LR*d#mU^wkF6>r(n+|pPS6F&UmUpvF_#RFKx#nLMG3&e^ zu!igBc7A>E}W%LNO{C2k<3dB+crr8NFt&HLbL7U~-f zX%$NJe`Im~RLqB#B7f+Bhq4O<+dp}nQ{c{`5jK$OXZ7kzWTzB-Y~Su}6csHx6^xwu zLi4jeqrcBR{`@9Up~3sHorQ|5a^PyFwsVd5kQ%MHdaTwip$23B2IDM90Tn3os?&Mj zD?udyDK(vV?)sfWIDRpUoc`Soq&)gh!xkRGk`7)}O5^3D|9b(`RR_aI%UCj?7a^U= zKfg71Gu;x=z~sLUO#Y9-<4KK#Lwx~1f1-!D`e%sB??6X71F?@~BPuv+(vjirFTq{A zBj4}XCr?cEQFWC4pT|ec#mh$u?c~8WcYer?zckVw1u9N!8Jx}^w0fI^C<}IFmbJv8 zW|5uz(8=#5g72)Fm}E)y97)FfrvE@bT?Z>_G^(NAYQ@20w2i!G^Y`Co?MM)q9B*0Y zpyj5p5XtR10(eng^9Eue08C zi4gsSqg5$P(+&B<&J*gT_!$DJjn1C*d%YT||B!wAdCV+yy956@LyrV5oNp=xDhEx2 z(0B7R=VO$f>Ql_g%UJVx9nVsT`vBU_4S%u+C(Smhk3p|M0}9JKRci%tG+>Hi;gpi@ z`?9z_OP`l20IB%qN2}(GzyZ~xm4kDQ68GsC#eSR0gkrAs=6$gOz5LxhfAT3^h&fCS zn8Wxg&xWb@5BB%Y6vCk@62%YU*<0QMFgCMIZ-^4>f-&|%;uSO}W6v5&^mZE|@n@n2 zvH~Yx@qQgbU`l8|Ea66zbeef-9jTKiB|Yp$g0dI!TSzs+ffm25Y}K}}Nw&w=2Be>1V4VWHv7ZcEW*Wi+!H_qPpuXp$NY@1GU_NJw9AU;Wmn z<0j9u1RQ^j<9kcN!N7W~El<147bxJZVHuI~Y%MA)#~l4gzu+@$RkYKGY;)}ZOarop zg)`g11kvp1hO!OphPO(g0@|Kki+YV}#Qa#nXqx@|tAG9WzyDxfvvpzr;SD1~=x_h~Py3%g z`p>`cpQrS1PS=0V(*OLJ{QE=lpQrSnr*!$Q{=Y}$|Hr%4n(lXN2*xg97F;4%wAA!f JD{fhb{XamTd5Zu5 literal 0 HcmV?d00001 From 61798ce1ab0e7c6baa124d4b90a6c9603f957bc3 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 15:50:56 +0100 Subject: [PATCH 18/31] fix tutorial labelling --- docs/Background/simulationpractice.rst | 2 +- docs/Tutorial/GettingStarted/part_1.rst | 2 +- docs/Tutorial/GettingStarted/part_2.rst | 4 ++-- docs/Tutorial/GettingStarted/part_3.rst | 2 +- docs/Tutorial/GettingStarted/part_4.rst | 2 +- docs/Tutorial/tutorial_ii.rst | 2 +- docs/Tutorial/tutorial_iii.rst | 2 +- docs/Tutorial/tutorial_iv.rst | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/Background/simulationpractice.rst b/docs/Background/simulationpractice.rst index 7753139..afeb4d1 100644 --- a/docs/Background/simulationpractice.rst +++ b/docs/Background/simulationpractice.rst @@ -5,7 +5,7 @@ Simulation Practice =================== Ensuring good practice when simulation modelling is important to get meaningful analyses from the models. -This is shown in :ref:`Tutorial IV `. +This is shown in :ref:`Part 4 of Tutorial I `. A recommended resource on the subject is [SR14]_. This page will briefly summarise some important aspects of carrying out simulation model analysis. diff --git a/docs/Tutorial/GettingStarted/part_1.rst b/docs/Tutorial/GettingStarted/part_1.rst index 7a33133..8a4541f 100644 --- a/docs/Tutorial/GettingStarted/part_1.rst +++ b/docs/Tutorial/GettingStarted/part_1.rst @@ -1,4 +1,4 @@ -.. _tutorial-i: +.. _tutorial-i-p1: ==================================================== Tutorial I: Part 1 - Defining & Running a Simulation diff --git a/docs/Tutorial/GettingStarted/part_2.rst b/docs/Tutorial/GettingStarted/part_2.rst index bec889f..230c189 100644 --- a/docs/Tutorial/GettingStarted/part_2.rst +++ b/docs/Tutorial/GettingStarted/part_2.rst @@ -1,4 +1,4 @@ -.. _tutorial-ii: +.. _tutorial-i-p2: ==================================================== Tutorial I: Part 2 - Exploring the Simulation Object @@ -23,7 +23,7 @@ Although our queueing system consisted of one node (the bank), the object :code: [Arrival Node, Node 1, Exit Node] + **The Arrival Node:** - This is where customers are created. They are spawned here, and can :ref:`baulk `, :ref:`be rejected ` or sent to a service node. Accessed using:: + This is where customers are created. They are spawned here, and can :ref:`baulk `, :ref:`be rejected ` or sent to a service node. Accessed using:: >>> Q.nodes[0] Arrival Node diff --git a/docs/Tutorial/GettingStarted/part_3.rst b/docs/Tutorial/GettingStarted/part_3.rst index 91c5e54..433d2ec 100644 --- a/docs/Tutorial/GettingStarted/part_3.rst +++ b/docs/Tutorial/GettingStarted/part_3.rst @@ -1,4 +1,4 @@ -.. _tutorial-iii: +.. _tutorial-i-p3: ======================================= Tutorial I: Part 3 - Collecting Results diff --git a/docs/Tutorial/GettingStarted/part_4.rst b/docs/Tutorial/GettingStarted/part_4.rst index ea25b13..2381ad7 100644 --- a/docs/Tutorial/GettingStarted/part_4.rst +++ b/docs/Tutorial/GettingStarted/part_4.rst @@ -1,4 +1,4 @@ -.. _tutorial-iv: +.. _tutorial-i-p4: ================================================ Tutorial I: Part 4 - Trials, Warm-up & Cool-down diff --git a/docs/Tutorial/tutorial_ii.rst b/docs/Tutorial/tutorial_ii.rst index 3ca5f37..fdaa40b 100644 --- a/docs/Tutorial/tutorial_ii.rst +++ b/docs/Tutorial/tutorial_ii.rst @@ -1,4 +1,4 @@ -.. _tutorial-v: +.. _tutorial-ii: ================================ Tutorial II: A Network of Queues diff --git a/docs/Tutorial/tutorial_iii.rst b/docs/Tutorial/tutorial_iii.rst index 1160c9e..45a6a9d 100644 --- a/docs/Tutorial/tutorial_iii.rst +++ b/docs/Tutorial/tutorial_iii.rst @@ -1,4 +1,4 @@ -.. _tutorial-vi: +.. _tutorial-iii: ================================= Tutorial III: Restricted Networks diff --git a/docs/Tutorial/tutorial_iv.rst b/docs/Tutorial/tutorial_iv.rst index 0b1591b..71e08dc 100644 --- a/docs/Tutorial/tutorial_iv.rst +++ b/docs/Tutorial/tutorial_iv.rst @@ -1,4 +1,4 @@ -.. _tutorial-vii: +.. _tutorial-iv: ========================================= Tutorial IV: Multiple Classes of Customer From 39656a41ae68a0a779ac64a2d6552020c1171ddb Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 15:54:24 +0100 Subject: [PATCH 19/31] revert to sphinx 5.0.0 for CI --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0c2796b..33078b8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -sphinx>=7.3.7 +sphinx>=5.0.0 pandas \ No newline at end of file From 7459c185432711ccd15cb618be4490a8df4f3592 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 16:02:30 +0100 Subject: [PATCH 20/31] drop support for python 3.7, include python 3.12 in tests --- .github/workflows/tests.yml | 2 +- README.rst | 2 +- docs/index.rst | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b9b1433..e33662d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v2 diff --git a/README.rst b/README.rst index f21fe75..4de86d6 100644 --- a/README.rst +++ b/README.rst @@ -32,11 +32,11 @@ Install with :code:`pip install ciw`. Current supported version of Python: -- Python 3.7 - Python 3.8 - Python 3.9 - Python 3.10 - Python 3.11 +- Python 3.12 Usage ----- diff --git a/docs/index.rst b/docs/index.rst index eb3cd2a..29c2f2b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,8 +11,8 @@ disciplines, dynamic customer classes, and deadlock detection. The name **Ciw** is the Welsh word for a queue. -Ciw is currently supported for and regularly tested on Python versions 3.7, -3.8, 3.9, 3.10 and 3.11. +Ciw is currently supported for and regularly tested on Python versions 3.8, +3.9, 3.10, 3.11 and 3.12. Contents: From c594983959c9c529e52775abb54932f5177427dd Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 16:09:22 +0100 Subject: [PATCH 21/31] add setuptools to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 490908e..e83c891 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +setuptools>=69 networkx>=2.3 tqdm>=4.64.0 numpy>=1.21.6 \ No newline at end of file From 828cf6f64df4bee5a92d2f587cd2d94cecd91d1e Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 16:30:10 +0100 Subject: [PATCH 22/31] add ellipses to docs for doctests --- docs/Guides/CustomerBehaviour/baulking.rst | 2 +- docs/Guides/Queues/queue_capacities.rst | 4 ++-- docs/Guides/Queues/system_capacity.rst | 4 ++-- docs/Guides/Routing/join_shortest_queue.rst | 12 ++---------- docs/Guides/Services/processor-sharing.rst | 2 +- docs/Guides/Services/server_priority.rst | 8 ++++---- docs/Guides/Services/service_disciplines.rst | 2 +- docs/Guides/Simulation/results.rst | 4 ++-- 8 files changed, 15 insertions(+), 23 deletions(-) diff --git a/docs/Guides/CustomerBehaviour/baulking.rst b/docs/Guides/CustomerBehaviour/baulking.rst index 5c0c13d..91cd8a1 100644 --- a/docs/Guides/CustomerBehaviour/baulking.rst +++ b/docs/Guides/CustomerBehaviour/baulking.rst @@ -46,7 +46,7 @@ When the system is simulated, the baulked customers are recorded as data records >>> baulked_recs = [r for r in recs if r.record_type=="baulk"] >>> r = baulked_recs[0] >>> (r.id_number, r.customer_class, r.node, r.arrival_date) - (44, 'Class 0', 1, 9.45892050639243) + (44, 'Class 0', 1, 9.45892050639...) Note that baulking works and behaves differently to simply setting a queue capacity. Filling a queue's capacity results in arriving customers being *rejected* (and recorded as data records of type :code:`"rejection"`), and transitioning customers to be blocked. diff --git a/docs/Guides/Queues/queue_capacities.rst b/docs/Guides/Queues/queue_capacities.rst index a13ef16..6fb7e53 100644 --- a/docs/Guides/Queues/queue_capacities.rst +++ b/docs/Guides/Queues/queue_capacities.rst @@ -68,7 +68,7 @@ Information about blockages are visible in the service data records:: >>> recs = Q.get_all_records(only=['service']) >>> dr = recs[381] >>> dr - Record(id_number=281, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=86.47159563260503, waiting_time=0.23440800156484443, service_start_date=86.70600363416987, service_time=0.6080763379283525, service_end_date=87.31407997209823, time_blocked=0.7507016571852461, exit_date=88.06478162928347, destination=2, queue_size_at_arrival=4, queue_size_at_departure=2, server_id=3, record_type='service') + Record(id_number=281, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=86.47159..., waiting_time=0.23440..., service_start_date=86.70600..., service_time=0.60807..., service_end_date=87.31407..., time_blocked=0.75070..., exit_date=88.06478..., destination=2, queue_size_at_arrival=4, queue_size_at_departure=2, server_id=3, record_type='service') -In the case above, the customer ended service at date :code:`87.31407997209823`, but didn't exit until date :code:`88.06478162928347`, giving a :code:`time_blocked` of :code:`0.7507016571852461`. +In the case above, the customer ended service at date :code:`87.31407...`, but didn't exit until date :code:`88.06478...`, giving a :code:`time_blocked` of :code:`0.75070...`. diff --git a/docs/Guides/Queues/system_capacity.rst b/docs/Guides/Queues/system_capacity.rst index 7948270..df97d1e 100644 --- a/docs/Guides/Queues/system_capacity.rst +++ b/docs/Guides/Queues/system_capacity.rst @@ -4,7 +4,7 @@ How to Set a Maximium Capacity for the Whole System =================================================== -We have seen that :ref:`node capacities<_tutorial-iii>` can define restricted queueing networks. Ciw also allows for a whole system capacity to be set. When a system capacity is set, when the total number of customers present in *all* the nodes of the system is equal to the system capacity, then newly arriving customers will be rejected. Once the total number of customers drops back below the system capacity, then customers will be accepted into the system again. +We have seen that :ref:`node capacities` can define restricted queueing networks. Ciw also allows for a whole system capacity to be set. When a system capacity is set, when the total number of customers present in *all* the nodes of the system is equal to the system capacity, then newly arriving customers will be rejected. Once the total number of customers drops back below the system capacity, then customers will be accepted into the system again. In order to implement this, we use the :code:`system_capacity` keyworks when creating the network:: @@ -27,5 +27,5 @@ In this case, the total capacity of nodes 1 and 2 is 4, and the system will neve >>> Q.simulate_until_max_time(100) >>> state_probs = Q.statetracker.state_probabilities() >>> state_probs - {0: 0.03369655546653017, 1: 0.1592711312247873, 2: 0.18950832844418355, 3: 0.2983478656854591, 4: 0.31917611917904} + {0: 0.03369..., 1: 0.15927..., 2: 0.18950..., 3: 0.29834..., 4: 0.31917...} diff --git a/docs/Guides/Routing/join_shortest_queue.rst b/docs/Guides/Routing/join_shortest_queue.rst index acbf244..2a07d84 100644 --- a/docs/Guides/Routing/join_shortest_queue.rst +++ b/docs/Guides/Routing/join_shortest_queue.rst @@ -169,13 +169,5 @@ We'll run this for 100 time units:: We can look at the state probabilities, that is, the proportion of time the system spent in each state, where a state represents the number of customers present in the system:: >>> state_probs = Q.statetracker.state_probabilities(observation_period=(10, 90)) - >>> for n in range(8): - ... print(n, round(state_probs[n], 5)) - 0 0.436 - 1 0.37895 - 2 0.13629 - 3 0.03238 - 4 0.01255 - 5 0.00224 - 6 0.00109 - 7 0.00051 + >>> state_probs + {1: 0.37895..., 2: 0.13628..., 3: 0.03237..., 0: 0.43600..., 4: 0.01254..., 5: 0.00224..., 6: 0.00108..., 7: 0.00050...} diff --git a/docs/Guides/Services/processor-sharing.rst b/docs/Guides/Services/processor-sharing.rst index f970231..6eff1a1 100644 --- a/docs/Guides/Services/processor-sharing.rst +++ b/docs/Guides/Services/processor-sharing.rst @@ -23,7 +23,7 @@ Now we create a simulation object using :code:`ciw.PSNode` rather than :code:`ci >>> Q = ciw.Simulation(N, node_class=ciw.PSNode) Note that this applies the process sharing node to every node of the network. -Alternatively we could provide a list of different node classes, for use on each different node of the network (see :ref:`this example ` for an in depth example of this):: +Alternatively we could provide a list of different node classes, for use on each different node of the network (see :ref:`this example ` for an in depth example of this):: >>> ciw.seed(0) >>> Q = ciw.Simulation(N, node_class=[ciw.PSNode]) diff --git a/docs/Guides/Services/server_priority.rst b/docs/Guides/Services/server_priority.rst index 1cc28fd..c1a6ec0 100644 --- a/docs/Guides/Services/server_priority.rst +++ b/docs/Guides/Services/server_priority.rst @@ -27,7 +27,7 @@ Observing the utilisation of each server we can see that server 1 is far more bu >>> Q.simulate_until_max_time(1000) >>> [srv.utilisation for srv in Q.nodes[1].servers] - [0.3184942440259139, 0.1437617661984246, 0.04196395909329539] + [0.31849424..., 0.14376176..., 0.04196395...] Ciw allows servers to be prioritised according to a custom server priority function. @@ -52,7 +52,7 @@ The :code:`server_priority_functions` keyword takes a list of server priority fu >>> Q.simulate_until_max_time(1000) >>> [srv.utilisation for srv in Q.nodes[1].servers] - [0.16784616228588892, 0.16882711899030475, 0.1675466880414403] + [0.16784616..., 0.16882711..., 0.16754668...] @@ -95,7 +95,7 @@ Now let's see this in action when we have equal numbers of individuals of class >>> Q = ciw.Simulation(N) >>> Q.simulate_until_max_time(1000) >>> [srv.utilisation for srv in Q.nodes[1].servers] - [0.36132860028585134, 0.3667939476202799, 0.2580202674603771] + [0.36132860..., 0.36679394..., 0.25802026...] Utilisation is fairly even between the first two servers, with the third server picking up any slack. Now let's see what happens when there is three times as many individuals of class 0 entering the system as there are of class 1:: @@ -116,7 +116,7 @@ Utilisation is fairly even between the first two servers, with the third server >>> Q = ciw.Simulation(N) >>> Q.simulate_until_max_time(1000) >>> [srv.utilisation for srv in Q.nodes[1].servers] - [0.447650059165907, 0.2678754897968868, 0.29112382084389343] + [0.44765005..., 0.26787548..., 0.29112382...] Now the first server is much busier than the others. diff --git a/docs/Guides/Services/service_disciplines.rst b/docs/Guides/Services/service_disciplines.rst index 8aaec70..40a9766 100644 --- a/docs/Guides/Services/service_disciplines.rst +++ b/docs/Guides/Services/service_disciplines.rst @@ -40,7 +40,7 @@ Custom Disciplines Other service disciplines can also be implemented by writing a custom service discipline function. These functions take in a list of individuals, and the current time, and returns an individual from that list that represents the next individual to be served. As this is a list of individuals, we can access the individuals' attributes when making the service discipline decision. -For example, say we wish to implement a service discipline that chooses the customers randomly, but with probability proportional to their arrival order, we could write: +For example, say we wish to implement a service discipline that chooses the customers randomly, but with probability proportional to their arrival order, we could write:: >>> def SIRO_proportional(individuals, t): ... n = len(inds) diff --git a/docs/Guides/Simulation/results.rst b/docs/Guides/Simulation/results.rst index 34de9e3..3c6be79 100644 --- a/docs/Guides/Simulation/results.rst +++ b/docs/Guides/Simulation/results.rst @@ -34,7 +34,7 @@ This gives a list of :code:`DataRecord` objects, which are named tuples with a n >>> r = recs[14] >>> r - Record(id_number=15, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=16.58266884119802, waiting_time=0.0, service_start_date=16.58266884119802, service_time=1.6996950244974869, service_end_date=18.28236386569551, time_blocked=0.0, exit_date=18.28236386569551, destination=-1, queue_size_at_arrival=0, queue_size_at_departure=1, server_id=1, record_type='service') + Record(id_number=15, customer_class='Customer', original_customer_class='Customer', node=1, arrival_date=16.58266..., waiting_time=0.0, service_start_date=16.58266..., service_time=1.69969..., service_end_date=18.28236..., time_blocked=0.0, exit_date=18.28236..., destination=-1, queue_size_at_arrival=0, queue_size_at_departure=1, server_id=1, record_type='service') These data records have a number of useful fields, set out in detail :ref:`here`. Importantly, fields can be accessed as attributes:: @@ -45,7 +45,7 @@ And so relevant data can be gathered using list comprehension:: >>> waiting_times = [r.waiting_time for r in recs] >>> sum(waiting_times) / len(waiting_times) - 0.3989747357976479 + 0.3989747... For easier manipulation, use in conjuction with `Pandas `_ is recommended, allowing for easier filtering, grouping, and summary statistics calculations. Lists of data records convert to Pandas data frames smoothly: From 9ecf2e068b2983dc81fec8a8754d61e0c55a44af Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Fri, 26 Apr 2024 16:32:26 +0100 Subject: [PATCH 23/31] add in missed ellipsis in tutorial docs --- docs/Tutorial/GettingStarted/part_4.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Tutorial/GettingStarted/part_4.rst b/docs/Tutorial/GettingStarted/part_4.rst index 2381ad7..f35523b 100644 --- a/docs/Tutorial/GettingStarted/part_4.rst +++ b/docs/Tutorial/GettingStarted/part_4.rst @@ -45,7 +45,7 @@ The list :code:`average_waits` will now contain ten numbers denoting the mean wa Notice that we set a different seed every time, so each trial will yield different results:: >>> average_waits - [3.91950..., 4.34163..., 4.61779..., 5.33537..., 5.06224..., 2.90274..., 4.93209..., 17.95093128538666, 4.06136..., 3.14126...] + [3.91950..., 4.34163..., 4.61779..., 5.33537..., 5.06224..., 2.90274..., 4.93209..., 17.95093..., 4.06136..., 3.14126...] We can see that the range of waits are quite high, between 1.6 and 7.5. This shows that running a single trial wouldn't have given us a very confident answer. From 126a14382d2426eb97e271f8ca3724ef24510f5e Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Mon, 29 Apr 2024 22:15:14 +0100 Subject: [PATCH 24/31] implement rerouting as a preemptive interruption option --- ciw/node.py | 72 ++-- ciw/routing/routing.py | 18 + ciw/schedules.py | 4 +- ciw/tests/test_simulation.py | 310 ++++++++++++++++++ docs/Guides/CustomerBehaviour/index.rst | 2 +- .../CustomerClasses/customer-classes.rst | 2 +- docs/Guides/Routing/custom_routing.rst | 25 ++ docs/Guides/Services/preemption.rst | 22 +- 8 files changed, 419 insertions(+), 36 deletions(-) diff --git a/ciw/node.py b/ciw/node.py index b36b4c0..3bfd6dd 100644 --- a/ciw/node.py +++ b/ciw/node.py @@ -529,25 +529,31 @@ def kill_server(self, srvr): def next_node(self, ind): """ Finds the next node according the routing method: - - if not process-based then sample from transition matrix - - if process-based then take the next value from the predefined route, - removing the current node from the route """ return self.simulation.routers[ind.customer_class].next_node(ind, self.id_number) + def next_node_for_rerouting(self, ind): + """ + Finds the next node (for rerouting) according the routing method: + """ + return self.simulation.routers[ind.customer_class].next_node_for_rerouting(ind, self.id_number) + def preempt(self, individual_to_preempt, next_individual): """ Removes individual_to_preempt from service and replaces them with next_individual """ server = individual_to_preempt.server individual_to_preempt.original_service_time = individual_to_preempt.service_time - self.write_interruption_record(individual_to_preempt) - individual_to_preempt.service_start_date = False - individual_to_preempt.time_left = individual_to_preempt.service_end_date - self.now - individual_to_preempt.service_time = self.priority_preempt - individual_to_preempt.service_end_date = False - self.detatch_server(server, individual_to_preempt) - self.decide_class_change(individual_to_preempt) + if self.priority_preempt == 'reroute': + self.reroute(individual_to_preempt) + else: + self.write_interruption_record(individual_to_preempt) + individual_to_preempt.service_start_date = False + individual_to_preempt.time_left = individual_to_preempt.service_end_date - self.now + individual_to_preempt.service_time = self.priority_preempt + individual_to_preempt.service_end_date = False + self.detatch_server(server, individual_to_preempt) + self.decide_class_change(individual_to_preempt) self.attach_server(server, next_individual) next_individual.service_start_date = self.now next_individual.service_time = self.get_service_time(next_individual) @@ -555,7 +561,7 @@ def preempt(self, individual_to_preempt, next_individual): self.reset_class_change(next_individual) server.next_end_service_date = next_individual.service_end_date - def release(self, next_individual, next_node): + def release(self, next_individual, next_node, reroute=False): """ Update node when an individual is released: - find the individual to release @@ -573,7 +579,8 @@ def release(self, next_individual, next_node): self.number_in_service -= 1 next_individual.queue_size_at_departure = self.number_of_individuals next_individual.exit_date = self.now - self.write_individual_record(next_individual) + if not reroute: + self.write_individual_record(next_individual) newly_free_server = None if not isinf(self.c) and not self.slotted: newly_free_server = next_individual.server @@ -584,9 +591,11 @@ def release(self, next_individual, next_node): self.simulation.statetracker.change_state_release( self, next_node, next_individual, next_individual.is_blocked ) - self.begin_service_if_possible_release(next_individual, newly_free_server) + if not reroute: + self.begin_service_if_possible_release(next_individual, newly_free_server) next_node.accept(next_individual) - self.release_blocked_individual() + if not reroute: + self.release_blocked_individual() def release_blocked_individual(self): """ @@ -687,17 +696,20 @@ def interrupt_service(self, individual): Interrupts the service of an individual and places them in an interrupted queue, and writes an interruption record for them. """ - self.interrupted_individuals.append(individual) - individual.interrupted = True - self.number_interrupted_individuals += 1 individual.original_service_time = individual.service_time - self.write_interruption_record(individual) - individual.original_service_start_date = individual.service_start_date - individual.service_start_date = False - individual.time_left = individual.service_end_date - self.now - individual.service_time = self.schedule.preemption - individual.service_end_date = False - self.number_in_service -= 1 + if self.schedule.preemption == 'reroute': + self.reroute(individual) + else: + self.interrupted_individuals.append(individual) + individual.interrupted = True + self.number_interrupted_individuals += 1 + self.write_interruption_record(individual) + individual.original_service_start_date = individual.service_start_date + individual.service_start_date = False + individual.time_left = individual.service_end_date - self.now + individual.service_time = self.schedule.preemption + individual.service_end_date = False + self.number_in_service -= 1 def sort_interrupted_individuals(self): """ @@ -705,6 +717,14 @@ def sort_interrupted_individuals(self): """ self.interrupted_individuals.sort(key=lambda x: (x.priority_class, x.arrival_date)) + def reroute(self, individual): + """ + Rerouts a preempted individual + """ + next_node = self.next_node_for_rerouting(individual) + self.write_interruption_record(individual, destination=next_node.id_number) + self.release(individual, next_node, reroute=True) + def update_next_end_service_without_server(self): """ Updates the next end of a slotted service in the `possible_next_events` dictionary. @@ -848,7 +868,7 @@ def write_individual_record(self, individual): ) individual.data_records.append(record) - def write_interruption_record(self, individual): + def write_interruption_record(self, individual, destination=nan): """ Write a data record for an individual when being interrupted. """ @@ -869,7 +889,7 @@ def write_interruption_record(self, individual): service_end_date=nan, time_blocked=nan, exit_date=self.now, - destination=nan, + destination=destination, queue_size_at_arrival=individual.queue_size_at_arrival, queue_size_at_departure=individual.queue_size_at_departure, server_id=server_id, diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index d7abb02..5423442 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -30,6 +30,12 @@ def next_node(self, ind, node_id): """ return self.routers[node_id - 1].next_node(ind) + def next_node_for_rerouting(self, ind, node_id): + """ + Chooses the next node when rerouting preempted customer. + """ + return self.routers[node_id - 1].next_node_for_rerouting(ind) + class TransitionMatrix(NetworkRouting): """ @@ -88,6 +94,12 @@ def next_node(self, ind, node_id): node_index = ind.route.pop(0) return self.simulation.nodes[node_index] + def next_node_for_rerouting(self, ind, node_id): + """ + Chooses the next node when rerouting preempted customer. + """ + return self.next_node(ind, node_id) + class NodeRouting: """ @@ -104,6 +116,12 @@ def initialise(self, simulation, node): def error_check_at_initialise(self): pass + def next_node_for_rerouting(self, ind): + """ + By default, the next node for rerouting uses the same method as next_node. + """ + return self.next_node(ind) + class Probabilistic(NodeRouting): """ diff --git a/ciw/schedules.py b/ciw/schedules.py index 7e4820a..855948c 100644 --- a/ciw/schedules.py +++ b/ciw/schedules.py @@ -52,8 +52,8 @@ def __init__(self, numbers_of_servers: List[int], shift_end_dates: List[float], 'resample', or False. Default is False. """ - if preemption not in [False, 'resume', 'restart', 'resample']: - raise ValueError("Pre-emption options should be either 'resume', 'restart', 'resample', or False.") + if preemption not in [False, 'resume', 'restart', 'resample', 'reroute']: + raise ValueError("Pre-emption options should be either 'resume', 'restart', 'resample', 'reroute', or False.") if not isinstance(offset, float): raise ValueError("Offset should be a positive float.") if offset < 0.0: diff --git a/ciw/tests/test_simulation.py b/ciw/tests/test_simulation.py index 2459be2..29c8b72 100644 --- a/ciw/tests/test_simulation.py +++ b/ciw/tests/test_simulation.py @@ -8,6 +8,7 @@ import csv from itertools import cycle import types +import math N_params = ciw.create_network( arrival_distributions={ @@ -1198,6 +1199,315 @@ def test_system_capacity(self): self.assertEqual([round(r.service_end_date, 2) for r in recs_service], expected_seds) self.assertEqual([round(r.arrival_date, 2) for r in recs_reject][:10], expected_first_10_rejection_times) + def test_reroute_preemption_classpriorities(self): + """ + Class 0 arrive to Node 1 every 0.7, service lasts 0.2 + Class 1 arrive to Node 1 every 1.0, service lasts 0.2 + Class 0 have priority over class 1 + Class 1 rerouted to Node 2 upon preemption + + Ind arr clss end + 1 0.7 0 0.9 + 2 1.0 1 1.2 + 3 1.4 0 1.6 + 4 2.0 1 interrupted (goes to Node 2 for 0.5) + 5 2.1 0 2.3 + """ + class Reroute(ciw.routing.NodeRouting): + def next_node(self, ind): + """ + Chooses the exit node with probability 1. + """ + return self.simulation.nodes[-1] + + def next_node_for_rerouting(self, ind): + """ + Chooses Node 2 + """ + return self.simulation.nodes[2] + + N = ciw.create_network( + arrival_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.7), None], + 'Class 1': [ciw.dists.Deterministic(value=1.0), None] + }, + service_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.5)], + 'Class 1': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.5)] + }, + number_of_servers=[1, 1], + priority_classes=({'Class 0': 0, 'Class 1': 1}, ['reroute', False]), + routing={ + 'Class 0': ciw.routing.NetworkRouting(routers=[ciw.routing.Leave(), ciw.routing.Leave()]), + 'Class 1': ciw.routing.NetworkRouting(routers=[Reroute(), ciw.routing.Leave()]) + } + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(2.7) + recs = Q.get_all_records() + + nd_1 = sorted([r for r in recs if r.node == 1], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_1], [0.7, 1.0, 1.4, 2.0, 2.1]) + self.assertEqual([round(r.service_time, 5) for r in nd_1], [0.2, 0.2, 0.2, 0.2, 0.2]) + self.assertEqual([round(r.exit_date, 5) for r in nd_1], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([r.destination for r in nd_1], [-1, -1, -1, 2, -1]) + self.assertEqual([r.record_type for r in nd_1], ['service', 'service', 'service', 'interrupted service', 'service']) + + nd_2 = sorted([r for r in recs if r.node == 2], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_2], [2.1]) + self.assertEqual([round(r.service_time, 5) for r in nd_2], [0.5]) + self.assertEqual([round(r.exit_date, 5) for r in nd_2], [2.6]) + self.assertEqual([r.destination for r in nd_2], [-1]) + self.assertEqual([r.record_type for r in nd_2], ['service']) + + """ + Class 0 arrive to Node 1 every 0.7, service lasts 0.2 + Class 1 arrive to Node 1 every 1.0, service lasts 0.2 + Class 0 have priority over class 1 + All classes go Node 1 then Node 2 + + Node 1 + Ind arr clss end + 1 0.7 0 0.9 + 2 1.0 1 1.2 + 3 1.4 0 1.6 + 4 2.0 1 interrupted + 5 2.1 0 2.3 + + Node 2 + Ind arr clss end + 1 0.9 0 1.0 + 2 1.2 1 1.3 + 3 1.6 0 1.7 + 4 2.1 1 2.2 + 5 2.3 0 2.4 + """ + N = ciw.create_network( + arrival_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.7), None], + 'Class 1': [ciw.dists.Deterministic(value=1.0), None] + }, + service_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.1)], + 'Class 1': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.1)] + }, + number_of_servers=[1, 1], + priority_classes=({'Class 0': 0, 'Class 1': 1}, ['reroute', False]), + routing={ + 'Class 0': ciw.routing.TransitionMatrix(transition_matrix=[[0.0, 1.0], [0.0, 0.0]]), + 'Class 1': ciw.routing.TransitionMatrix(transition_matrix=[[0.0, 1.0], [0.0, 0.0]]) + } + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(2.7) + recs = Q.get_all_records() + + nd_1 = sorted([r for r in recs if r.node == 1], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_1], [0.7, 1.0, 1.4, 2.0, 2.1]) + self.assertEqual([round(r.service_time, 5) for r in nd_1], [0.2, 0.2, 0.2, 0.2, 0.2]) + self.assertEqual([round(r.exit_date, 5) for r in nd_1], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([r.destination for r in nd_1], [2, 2, 2, 2, 2]) + self.assertEqual([r.record_type for r in nd_1], ['service', 'service', 'service', 'interrupted service', 'service']) + + nd_2 = sorted([r for r in recs if r.node == 2], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_2], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([round(r.service_time, 5) for r in nd_2], [0.1, 0.1, 0.1, 0.1, 0.1]) + self.assertEqual([round(r.exit_date, 5) for r in nd_2], [1.0, 1.3, 1.7, 2.2, 2.4]) + self.assertEqual([r.destination for r in nd_2], [-1, -1, -1, -1, -1]) + self.assertEqual([r.record_type for r in nd_2], ['service', 'service', 'service', 'service', 'service']) + + def test_reroute_preemption_classpriorities_process_based(self): + """ + Class 0 arrive to Node 1 every 0.7, service lasts 0.2 + Class 1 arrive to Node 1 every 1.0, service lasts 0.2 + Class 0 have priority over class 1 + All classes go Node 1 then Node 2 + + Node 1 + Ind arr clss end + 1 0.7 0 0.9 + 2 1.0 1 1.2 + 3 1.4 0 1.6 + 4 2.0 1 interrupted + 5 2.1 0 2.3 + + Node 2 + Ind arr clss end + 1 0.9 0 1.0 + 2 1.2 1 1.3 + 3 1.6 0 1.7 + 4 2.1 1 2.2 + 5 2.3 0 2.4 + """ + def from_1_to_2(ind, simulation): + return [2] + + N = ciw.create_network( + arrival_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.7), None], + 'Class 1': [ciw.dists.Deterministic(value=1.0), None] + }, + service_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.1)], + 'Class 1': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.1)] + }, + number_of_servers=[1, 1], + priority_classes=({'Class 0': 0, 'Class 1': 1}, ['reroute', False]), + routing={ + 'Class 0': ciw.routing.ProcessBased(from_1_to_2), + 'Class 1': ciw.routing.ProcessBased(from_1_to_2) + } + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(2.7) + recs = Q.get_all_records() + + nd_1 = sorted([r for r in recs if r.node == 1], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_1], [0.7, 1.0, 1.4, 2.0, 2.1]) + self.assertEqual([round(r.service_time, 5) for r in nd_1], [0.2, 0.2, 0.2, 0.2, 0.2]) + self.assertEqual([round(r.exit_date, 5) for r in nd_1], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([r.destination for r in nd_1], [2, 2, 2, 2, 2]) + self.assertEqual([r.record_type for r in nd_1], ['service', 'service', 'service', 'interrupted service', 'service']) + + nd_2 = sorted([r for r in recs if r.node == 2], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_2], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([round(r.service_time, 5) for r in nd_2], [0.1, 0.1, 0.1, 0.1, 0.1]) + self.assertEqual([round(r.exit_date, 5) for r in nd_2], [1.0, 1.3, 1.7, 2.2, 2.4]) + self.assertEqual([r.destination for r in nd_2], [-1, -1, -1, -1, -1]) + self.assertEqual([r.record_type for r in nd_2], ['service', 'service', 'service', 'service', 'service']) + + def test_rerouting_ignores_queue_capacities(self): + """ + Class 0 arrive to Node 1 every 0.7, service lasts 0.2 + Class 1 arrive to Node 1 every 1.0, service lasts 0.2 + Class 0 have priority over class 1 + Class 1 rerouted to Node 2 upon preemption + Class 2 arrive at Node 2 every 0.6 + + Node 1 + Ind arr clss end + 2 0.7 0 0.9 + 3 1.0 1 1.2 + 5 1.4 0 1.6 + 6 2.0 1 interrupted (goes to Node 2 for 0.5) + 7 2.1 0 2.3 + + Node 2 + Ind arr clss end + 1 0.6 2 1.3 + 4 1.2 2 rejected + 3 1.8 2 2.5 + 6 2.1 1 (2.5 + 0.7 =) 3.2 (individual is accepted even though a queue capacity of 0) + 8 2.4 2 rejected + """ + class Reroute(ciw.routing.NodeRouting): + def next_node(self, ind): + """ + Chooses the exit node with probability 1. + """ + return self.simulation.nodes[-1] + + def next_node_for_rerouting(self, ind): + """ + Chooses Node 2 + """ + return self.simulation.nodes[2] + + N = ciw.create_network( + arrival_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.7), None], + 'Class 1': [ciw.dists.Deterministic(value=1.0), None], + 'Class 2': [None, ciw.dists.Deterministic(value=0.6)] + }, + service_distributions={ + 'Class 0': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.7)], + 'Class 1': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.7)], + 'Class 2': [ciw.dists.Deterministic(value=0.2), ciw.dists.Deterministic(value=0.7)] + }, + number_of_servers=[1, 1], + queue_capacities=[float('inf'), 0], + priority_classes=({'Class 0': 0, 'Class 1': 1, 'Class 2': 1}, ['reroute', False]), + routing={ + 'Class 0': ciw.routing.NetworkRouting(routers=[ciw.routing.Leave(), ciw.routing.Leave()]), + 'Class 1': ciw.routing.NetworkRouting(routers=[Reroute(), ciw.routing.Leave()]), + 'Class 2': ciw.routing.NetworkRouting(routers=[ciw.routing.Leave(), ciw.routing.Leave()]) + } + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(3.3) + recs = Q.get_all_records() + + nd_1 = sorted([r for r in recs if r.node == 1], key=lambda r: r.arrival_date)[:5] + self.assertEqual([round(r.arrival_date, 5) for r in nd_1], [0.7, 1.0, 1.4, 2.0, 2.1]) + self.assertEqual([round(r.service_time, 5) for r in nd_1], [0.2, 0.2, 0.2, 0.2, 0.2]) + self.assertEqual([round(r.exit_date, 5) for r in nd_1], [0.9, 1.2, 1.6, 2.1, 2.3]) + self.assertEqual([r.destination for r in nd_1], [-1, -1, -1, 2, -1]) + self.assertEqual([r.record_type for r in nd_1], ['service', 'service', 'service', 'interrupted service', 'service']) + + nd_2 = sorted([r for r in recs if r.node == 2], key=lambda r: r.arrival_date)[:5] + self.assertEqual([round(r.arrival_date, 5) for r in nd_2], [0.6, 1.2, 1.8, 2.1, 2.4]) + self.assertEqual([str(round(r.service_time, 5)) for r in nd_2], ['0.7', 'nan', '0.7', '0.7', 'nan']) + self.assertEqual([round(r.exit_date, 5) for r in nd_2], [1.3, 1.2, 2.5, 3.2, 2.4]) + self.assertEqual([str(r.destination) for r in nd_2], ['-1', 'nan', '-1', '-1', 'nan']) + self.assertEqual([r.record_type for r in nd_2], ['service', 'rejection', 'service', 'service', 'rejection']) + + def test_reroute_at_shift_change(self): + """ + Two nodes: arrivals to Node 1 every 1 time unit; service lasts 0.4 time units. + One server on duty from - to 4.2; 0 from 4.2 to 6.1; 1 from 6.1 to 100. + Interrupted individuals go to node 2, have service time 1. + + Node 1 + Ind arr exit + 1 1.0 1.4 + 2 2.0 2.4 + 3 3.0 4.4 + 4 4.0 4.2 (interrupted) + 5 5.0 6.5 (service started at 6.1 when server back on duty) + 6 6.0 6.9 (service starts after ind 5 finishes service) + 7 7.0 7.4 + + Node 2 + Ind arr exit + 4 4.2 5.2 + """ + class Reroute(ciw.routing.NodeRouting): + def next_node(self, ind): + """ + Chooses the exit node with probability 1. + """ + return self.simulation.nodes[-1] + + def next_node_for_rerouting(self, ind): + """ + Chooses Node 2 + """ + return self.simulation.nodes[2] + + N = ciw.create_network( + arrival_distributions=[ciw.dists.Deterministic(value=1.0), None], + service_distributions=[ciw.dists.Deterministic(value=0.4), ciw.dists.Deterministic(value=1.0)], + number_of_servers=[ciw.Schedule(numbers_of_servers=[1, 0, 1], shift_end_dates=[4.2, 6.1, 100], preemption="reroute"), 1], + routing=ciw.routing.NetworkRouting(routers=[Reroute(), ciw.routing.Leave()]) + ) + Q = ciw.Simulation(N) + Q.simulate_until_max_time(7.5) + + recs = Q.get_all_records() + nd_1 = sorted([r for r in recs if r.node == 1], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_1], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) + self.assertEqual([round(r.service_time, 5) for r in nd_1], [0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4]) + self.assertEqual([round(r.exit_date, 5) for r in nd_1], [1.4, 2.4, 3.4, 4.2, 6.5, 6.9, 7.4]) + self.assertEqual([r.destination for r in nd_1], [-1, -1, -1, 2, -1, -1, -1]) + self.assertEqual([r.record_type for r in nd_1], ['service', 'service', 'service', 'interrupted service', 'service', 'service', 'service']) + + nd_2 = sorted([r for r in recs if r.node == 2], key=lambda r: r.arrival_date) + self.assertEqual([round(r.arrival_date, 5) for r in nd_2], [4.2]) + self.assertEqual([round(r.service_time, 5) for r in nd_2], [1.0]) + self.assertEqual([round(r.exit_date, 5) for r in nd_2], [5.2]) + self.assertEqual([r.destination for r in nd_2], [-1]) + self.assertEqual([r.record_type for r in nd_2], ['service']) + class TestServiceDisciplines(unittest.TestCase): def test_first_in_first_out(self): diff --git a/docs/Guides/CustomerBehaviour/index.rst b/docs/Guides/CustomerBehaviour/index.rst index c1614a2..3078951 100644 --- a/docs/Guides/CustomerBehaviour/index.rst +++ b/docs/Guides/CustomerBehaviour/index.rst @@ -1,5 +1,5 @@ Customer Behaviour -======== +================== Contents: diff --git a/docs/Guides/CustomerClasses/customer-classes.rst b/docs/Guides/CustomerClasses/customer-classes.rst index 5d5f45f..9e980b6 100644 --- a/docs/Guides/CustomerClasses/customer-classes.rst +++ b/docs/Guides/CustomerClasses/customer-classes.rst @@ -48,7 +48,7 @@ When collecting results, the class of the customer associated with each service >>> Counter([r.customer_class for r in recs]) Counter({'Child': 138, 'Adult': 89}) -Nearly all parameters of :code:`ciw.create_network` can be split by customer class, unless they describe the architecture of the network itself. Those that can and cannot be split by customer class are listed below:: +Nearly all parameters of :code:`ciw.create_network` can be split by customer class, unless they describe the architecture of the network itself. Those that can and cannot be split by customer class are listed below: Can be split by customer class: + :code:`arrival_distributions`, diff --git a/docs/Guides/Routing/custom_routing.rst b/docs/Guides/Routing/custom_routing.rst index f748583..adcfa3f 100644 --- a/docs/Guides/Routing/custom_routing.rst +++ b/docs/Guides/Routing/custom_routing.rst @@ -91,3 +91,28 @@ Now if the only arrivals are to node 1, and we run this for 100 time units, we s True + +.. _custom-rerouting: + +Custom Pre-emptive Re-routing +----------------------------- + +Custom routing objects can be used to use different routing logic for when a customer finishes service, to when a customer has a service interrupted and must be re-routed. In order to do this, we need to create a custom routing object, and re-write the :code:`next_node_for_rerouting` method, which is called when deciding which node the customer will be re-routed to after pre-emption. By default, this calls the object's :code:`next_node` method, and so identical logic occurs. But we can rewrite this to use different logic when rerouting customers. + +Consider, for example, a two node system where customers always arrive to Node 1, and immediately leave the system. However, if they have service interrupted at Node 1, they are re-routed to Node 2:: + + >>> class CustomRerouting(ciw.routing.NodeRouting): + ... def next_node(self, ind): + ... """ + ... Always leaves the system. + ... """ + ... return self.simulation.nodes[-1] + ... + ... def next_node_for_rerouting(self, ind): + ... """ + ... Always sends to Node 2. + ... """ + ... return self.simulation.nodes[2] + + +**Note that re-routing customers ignores queue capacities.** That means that interrupted customers can be re-routed to nodes that already have full queues, nodes that would otherwise reject or block other arriving individuals; and so that node would be temporarily over-capacity. diff --git a/docs/Guides/Services/preemption.rst b/docs/Guides/Services/preemption.rst index 3e7bdcd..670dc07 100644 --- a/docs/Guides/Services/preemption.rst +++ b/docs/Guides/Services/preemption.rst @@ -13,7 +13,8 @@ In Ciw they can either: + Have their service resampled (:code:`"resample"`); + Restart the exact same service (:code:`"restart"`); -+ Continue the original service from where they left off (:code:`"resume"`). ++ Continue the original service from where they left off (:code:`"resume"`); ++ Be re-routed to another node (:code:`"reroute"`). @@ -24,7 +25,7 @@ During non-pre-emptive priorities, customers cannot be interrupted. Therefore th In order to implement pre-emptive or non-pre-emptive priorities, put the priority class mapping in a tuple with a list of the chosen pre-emption options for each node in the network. For example:: - priority_classes=({'Class 0': 0, 'Class 1': 1}, [False, "resample", "restart", "resume"]) + priority_classes=({'Class 0': 0, 'Class 1': 1}, [False, "resample", "restart", "resume", "reroute"]) This indicates that non-pre-emptive priorities will be used at the first node, and pre-emptive priorities will be used at the second, third and fourth nodes. Interrupted individuals will have their services resampled at the second node, they will restart their original service time at the third node, and they will continue where they left off at the fourth node. @@ -46,10 +47,11 @@ During a pre-emptive schedule, that server will immediately stop service and lea In order to implement pre-emptive or non-pre-emptive schedules, the :code:`ciw.Schedule` object takes in a keywords argument :code:`preemption` the chosen pre-emption option. For example:: number_of_servers=[ - ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption=False) # non-preemptive - ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resample") # preemptive and resamples service time - ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="restart") # preemptive and restarts origional service time - ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resume") # preemptive continutes services where left off + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption=False), # non-preemptive + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resample"), # preemptive and resamples service time + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="restart"), # preemptive and restarts origional service time + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="resume"), # preemptive and continues services where left off + ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100], preemption="reroute") # preemptive and sends the individual to another node ] Ciw defaults to non-pre-emptive schedules, and so the following code implies a non-pre-emptive schedule:: @@ -57,6 +59,14 @@ Ciw defaults to non-pre-emptive schedules, and so the following code implies a n number_of_servers=[ciw.Schedule(numbers_of_servers=[2, 0, 1], shift_end_dates=[10, 30, 100])] # non-preemptive +Re-routing Customers +-------------------- + +If the :code:`"reroute"` pre-emptive option is chosen, then interrupted customers have their service cut short at the current node, and are then sent to another node. Ordinarily the next node is chosen in same way as if the customer had completed service, using the transition matrices, process based routes, or routing objects. However, it may be useful to have separate routing logic for preemptive reroutes, and a description of how to do that is given :ref:`here`. + +**Note that re-routing customers ignores queue capacities.** That means that interrupted customers can be re-routed to nodes that already have full queues, nodes that would otherwise reject or block other arriving individuals; and so that node would be temporarily over-capacity. + + Records of Interrupted Services ------------------------------- From 8f13e51ac1ec7ccb49d83c0c6d383619090ecbba Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 30 Apr 2024 15:17:28 +0100 Subject: [PATCH 25/31] add routers reference page --- docs/Reference/index.rst | 1 + docs/Reference/routers.rst | 138 +++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/Reference/routers.rst diff --git a/docs/Reference/index.rst b/docs/Reference/index.rst index 8bf137e..2d29a91 100644 --- a/docs/Reference/index.rst +++ b/docs/Reference/index.rst @@ -9,6 +9,7 @@ Contents: parameters.rst results.rst distributions.rst + routers.rst state_trackers.rst citation.rst contributing.rst diff --git a/docs/Reference/routers.rst b/docs/Reference/routers.rst new file mode 100644 index 0000000..0c3ccfd --- /dev/null +++ b/docs/Reference/routers.rst @@ -0,0 +1,138 @@ +.. _refs-routing: + +======================= +List of Routing Objects +======================= + +Ciw allows a number network router objects and node router objects. +The following network routers are currently supported: + +- :ref:`network_router` +- :ref:`trans_mat` +- :ref:`pb_route` + +The following node routers are currently supported: + +- :ref:`leave` +- :ref:`direct` +- :ref:`probabilistic` +- :ref:`jsq` +- :ref:`load_balancing` + + + +Network Routing Objects +======================= + +.. _network_router: + +-------------------------- +The General Network Router +-------------------------- + +The general network router allows us to define separate node routing objects for each node. For a two node network:: + + ciw.routing.NetworkRouting( + routers=[ + ciw.routing.NodeRouting(), + ciw.routing.NodeRouting(), + ciw.routing.NodeRouting() + ] + ) + + +.. _trans_mat: + +---------------------------- +The Transition Matrix Router +---------------------------- + +The :ref:`transition matrix` router takes in a transition matrix, a list of lists, with elements :math:`R_{ij}` representing the probability of entering node :math:`j` after service at node :math:`i`. For a two node network:: + + ciw.routing.TransitionMatrix( + transition_matrix=[ + [0.2, 0.8], + [0.0, 0.3] + ] + ) + + +.. _pb_route: + +------------------------ +The Process Based Router +------------------------ + +The :ref:`process based` router takes in a function that returns a pre-defined list of destinations that a customer will follow in order. E.g. all routing where all customers go to Node 2, then Node 1, then Node 1 again:: + + ciw.routing.ProcessBased( + routing_function=lambda ind, simulation: [2, 1, 1] + ) + + + + +Node Routing Objects +==================== + +.. _leave: + +----- +Leave +----- + +Leaves the system after service at the node:: + + ciw.routing.Leave() + + +.. _direct: + +------ +Direct +------ + +The customer goes direct to another node after service at the node. E.g. going to Node 2 direct form the node:: + + ciw.routing.Direct(to=2) + + + +.. _probabilistic: + +------------- +Probabilistic +------------- + +The customer is routed to another node probabilistically after service at the node. E.g. going to Node 1 with probability 0.4, and Node 3 with probability 0.1:: + + ciw.routing.Probabilistic( + destinations=[1, 3], + probs=[0.4, 0.1] + ) + + +.. _jsq: + +------------------- +Join Shortest Queue +------------------- + +The customer goes :ref:`joins the shortest queue` out of a subset of destinations:: + + ciw.routing.JoinShortestQueue(destinations=[1, 3], tie_break='random') + +The :code:`tie_break` argument is optional, and can take one of two strings: :code:`'random'` or :code:`'order'`. When there is a tie between the nodes with the shortest queue, tie breaks are either dealt with by choosing randomly between the ties (:code:`'random'`), or take precedence by the order listed in the :code:`destinations` list (:code:`'order'`). If omitted, random tie-breaking is used. + +.. _load_balancing: + + +-------------- +Load Balancing +-------------- + +The customer goes :ref:`joins the node with the least amount of customers present` out of a subset of destinations:: + + ciw.routing.LoadBalancing(destinations=[1, 3], tie_break='random') + +The :code:`tie_break` argument is optional, and can take one of two strings: :code:`'random'` or :code:`'order'`. When there is a tie between the nodes with the least amount of customers present, tie breaks are either dealt with by choosing randomly between the ties (:code:`'random'`), or take precedence by the order listed in the :code:`destinations` list (:code:`'order'`). If omitted, random tie-breaking is used. From 17407f2adfed3f7bdfca8adb285a78ffadd72bd1 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 30 Apr 2024 22:17:48 +0100 Subject: [PATCH 26/31] properly link to router reference page --- docs/Guides/Routing/routing_objects.rst | 3 +-- docs/Reference/routers.rst | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/Guides/Routing/routing_objects.rst b/docs/Guides/Routing/routing_objects.rst index 33b2dc1..4fbc630 100644 --- a/docs/Guides/Routing/routing_objects.rst +++ b/docs/Guides/Routing/routing_objects.rst @@ -14,12 +14,11 @@ Ciw has a number of these built in, however their primary use is to be defined b + :code:`ciw.routing.TransitionMatrix`, allowing users to define :ref:`transition matrices`, that is a matrix of probabilities of being transferred to each node in the network after service at every other node. + :code:`ciw.routing.ProcessBased`, allowing pre-defined routes to be given to individuals when they arrive, that is :ref:`process-based routing`. -However, the most flexible Network routing object is the generic :code:`ciw.routing.NetworkRouting`. This takes in a list of Node routing objects. Node routing objects are objects that determine routing out of a particular node. The following are built-in to Ciw, but importantly, they can be user defined: +However, the most flexible Network routing object is the generic :code:`ciw.routing.NetworkRouting`. This takes in a list of Node routing objects. Node routing objects are objects that determine routing out of a particular node. A :ref:`full list is given` in the References section. The following are some of the most basic built-in routers available in Ciw, but importantly, they can also be user defined: + :code:`ciw.routing.Direct(to=2)`: Sends the individual directly to another node. For example here, a customer is always send to node 2. + :code:`ciw.routing.Leave()`: The individual leaves the system. + :code:`ciw.routing.Probabilistic(destinations=[1, 3], probs=[0.1, 0.4])`: Probabilistically sends the individual to either of the destination, according to their corresponding probabilities. In this case, they are send to node 1 with probability 0.1, node 3 with probability 0.4, and leave the system with the rest of the probability, 0.5. -+ :code:`ciw.routing.JoinShortestQueue` and :code:`ciw.routing.LoadBalancing` are forms of sending the individual to the shortest queue. More information on these is given :ref:`here`. Example diff --git a/docs/Reference/routers.rst b/docs/Reference/routers.rst index 0c3ccfd..822889e 100644 --- a/docs/Reference/routers.rst +++ b/docs/Reference/routers.rst @@ -34,9 +34,9 @@ The general network router allows us to define separate node routing objects for ciw.routing.NetworkRouting( routers=[ - ciw.routing.NodeRouting(), - ciw.routing.NodeRouting(), - ciw.routing.NodeRouting() + ciw.routing.NodeRouting(), # should be replaced with one of the node routers below + ciw.routing.NodeRouting(), # should be replaced with one of the node routers below + ciw.routing.NodeRouting() # should be replaced with one of the node routers below ] ) From 9259c22b0131d65fac868e64669b48147cd5c854 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 30 Apr 2024 22:37:00 +0100 Subject: [PATCH 27/31] add cycle routing object --- ciw/routing/routing.py | 27 +++++++++++++++++++++++++++ ciw/tests/test_routing.py | 24 ++++++++++++++++++++++++ docs/Reference/routers.rst | 14 +++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index 5423442..5ef9d0e 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -1,4 +1,5 @@ import ciw +import itertools class NetworkRouting: """ @@ -248,3 +249,29 @@ def get_queue_size(self, node_index): Gets the size of the queue at the node_index. """ return self.simulation.nodes[node_index].number_of_individuals + + +class Cycle(NodeRouting): + """ + A router that cycles through destinations, repeating the cycle once ended. + """ + def __init__(self, cycle): + """ + Initialises the routing object. + + Takes: + - cycle: an ordered sequence of nodes. + """ + self.cycle = cycle + self.generator = itertools.cycle(self.cycle) + + def error_check_at_initialise(self): + if not set(self.cycle).issubset(set([nd.id_number for nd in self.simulation.nodes[1:]])): + raise ValueError("Routing destinations should be a subset of the nodes in the network.") + + def next_node(self, ind): + """ + Chooses the node 'to' with probability 1. + """ + next_node_index = next(self.generator) + return self.simulation.nodes[next_node_index] diff --git a/ciw/tests/test_routing.py b/ciw/tests/test_routing.py index cd1a526..4abacad 100644 --- a/ciw/tests/test_routing.py +++ b/ciw/tests/test_routing.py @@ -309,3 +309,27 @@ def test_load_balancing(self): self.assertEqual([samples_1[i] for i in [1, 2, 3, -1]], [507, 493, 0, 0]) self.assertTrue(all(r == 1 for r in samples_2)) self.assertTrue(all(r == 2 for r in samples_3)) + + + def test_cycle_routing(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.Cycle(cycle=[2, 3, 3]) + R2 = ciw.routing.Cycle(cycle=[1, 2]) + R3 = ciw.routing.Cycle(cycle=[1, -1, 2, -1]) + R1.initialise(Q, 1) + R2.initialise(Q, 2) + R3.initialise(Q, 3) + ind = ciw.Individual(1) + samples_1 = [r.id_number for r in [R1.next_node(ind) for _ in range(20)]] + samples_2 = [r.id_number for r in [R2.next_node(ind) for _ in range(20)]] + samples_3 = [r.id_number for r in [R3.next_node(ind) for _ in range(20)]] + self.assertEqual([2, 3, 3, 2, 3, 3, 2, 3, 3, 2, 3, 3, 2, 3, 3, 2, 3, 3, 2, 3], samples_1) + self.assertEqual([1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2], samples_2) + self.assertEqual([1, -1, 2, -1, 1, -1, 2, -1, 1, -1, 2, -1, 1, -1, 2, -1, 1, -1, 2, -1], samples_3) + + def test_cycle_routing_raises_errors(self): + ciw.seed(0) + Q = ciw.Simulation(N) + R1 = ciw.routing.Cycle(cycle=[1, 2, 7]) + self.assertRaises(ValueError, R1.initialise, Q, 1) diff --git a/docs/Reference/routers.rst b/docs/Reference/routers.rst index 822889e..430f8b3 100644 --- a/docs/Reference/routers.rst +++ b/docs/Reference/routers.rst @@ -18,6 +18,7 @@ The following node routers are currently supported: - :ref:`probabilistic` - :ref:`jsq` - :ref:`load_balancing` +- :ref:`cycle` @@ -126,7 +127,6 @@ The :code:`tie_break` argument is optional, and can take one of two strings: :co .. _load_balancing: - -------------- Load Balancing -------------- @@ -136,3 +136,15 @@ The customer goes :ref:`joins the node with the least amount of customers presen ciw.routing.LoadBalancing(destinations=[1, 3], tie_break='random') The :code:`tie_break` argument is optional, and can take one of two strings: :code:`'random'` or :code:`'order'`. When there is a tie between the nodes with the least amount of customers present, tie breaks are either dealt with by choosing randomly between the ties (:code:`'random'`), or take precedence by the order listed in the :code:`destinations` list (:code:`'order'`). If omitted, random tie-breaking is used. + + +.. _cycle: + +----- +Cycle +----- + +The customers' destinations out of the node cycles through a given list. For example, the first customer is routed to Node 1, the second to Node 1, the third to Node 3, the fourth to Node 2, then henceforth the order cycles, so the fifth is routed to Node 1, the sixth to Node 1, and so on:: + + ciw.routing.Cycle(cycle=[1, 1, 3, 2]) + From 33bee5af260f88816b52dc5bac4af6afedbe4b88 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Wed, 1 May 2024 18:11:23 +0100 Subject: [PATCH 28/31] add flexible process based routing --- ciw/routing/routing.py | 61 +++++++++++++++++++ ciw/tests/test_process_based.py | 103 +++++++++++++++++++++++++++++++- docs/Reference/routers.rst | 24 ++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/ciw/routing/routing.py b/ciw/routing/routing.py index 5ef9d0e..15a19ac 100644 --- a/ciw/routing/routing.py +++ b/ciw/routing/routing.py @@ -101,6 +101,67 @@ def next_node_for_rerouting(self, ind, node_id): """ return self.next_node(ind, node_id) +class FlexibleProcessBased(ProcessBased): + """ + A class to route an individual based on a pre-defined process. + """ + def __init__(self, route_function, rule, choice): + """ + Initialises the routing object. + + Takes: + - route_function: a function that returns a pre-defined route + """ + self.route_function = route_function + if rule not in ['any', 'all']: + raise ValueError("Flexible routing rules must be one of 'any' or 'all'.") + if choice not in ['random', 'jsq', 'lb']: + raise ValueError("Flexible routing choices must be one of 'random', 'jsq', or 'lb'.") + self.rule = rule + self.choice = choice + + def find_next_node_from_subset(self, subset, ind): + """ + Finds the next node within a subset of nodes + according to the 'choice' parameter + """ + if self.choice == 'random': + return ciw.random_choice(subset) + if self.choice == 'jsq': + temp_router = JoinShortestQueue(destinations=subset) + temp_router.initialise(self.simulation, None) + nd = temp_router.next_node(ind) + return nd.id_number + if self.choice == 'lb': + temp_router = LoadBalancing(destinations=subset) + temp_router.initialise(self.simulation, None) + nd = temp_router.next_node(ind) + return nd.id_number + + def update_individual_route(self, ind, next_node_id): + """ + Updates the individual route by removing chosen nodes + along the route, according to the 'rule' parameter + """ + if self.rule == 'any': + ind.route = ind.route[1:] + if self.rule == 'all': + ind.route[0].remove(next_node_id) + if len(ind.route[0]) == 0: + ind.route = ind.route[1:] + + def next_node(self, ind, node_id): + """ + Chooses the next node from the process-based pre-defined route. + """ + if len(ind.route) == 0: + node_index = -1 + else: + node_index = self.find_next_node_from_subset(ind.route[0], ind) + self.update_individual_route(ind, node_index) + return self.simulation.nodes[node_index] + + class NodeRouting: """ diff --git a/ciw/tests/test_process_based.py b/ciw/tests/test_process_based.py index 7b8ef53..63f37f1 100644 --- a/ciw/tests/test_process_based.py +++ b/ciw/tests/test_process_based.py @@ -3,7 +3,6 @@ import random from collections import Counter - def generator_function_1(ind, simulation): return [1, 1] @@ -29,6 +28,8 @@ def __init__(self, n): def generator_method(self, ind, simulation): return [1, 1] +def flexible_generator_1(ind, simulation): + return [[2], [3, 4, 5], [6]] class TestProcessBased(unittest.TestCase): def test_network_takes_routing_function(self): @@ -152,3 +153,103 @@ def test_process_based_takes_methods(self): inds = Q.nodes[1].all_individuals for ind in inds: self.assertEqual(ind.route, [1, 1]) + + def test_flexible_process_based(self): + ## First test 'any-random': + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(rate=1), None, None, None, None, None], + service_distributions=[ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2)], + number_of_servers=[3, 3, 3, 3, 3, 3], + routing=ciw.routing.FlexibleProcessBased( + route_function=flexible_generator_1, + rule='any', + choice='random' + ) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_customers(1000) + inds = Q.nodes[-1].all_individuals + routes_counter = Counter( + [tuple(dr.node for dr in ind.data_records) for ind in inds] + ) + self.assertEqual( + routes_counter, + Counter({(1, 2, 4, 6): 350, (1, 2, 5, 6): 333, (1, 2, 3, 6): 317}), # all evenly spread + ) + + ## Now test 'any-jsq' (flooding Node 4 so no customers ever go here): + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(rate=1), None, None, None, None, None], + service_distributions=[ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Deterministic(value=200000), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2)], + number_of_servers=[3, 3, 3, 3, 3, 3], + routing=ciw.routing.FlexibleProcessBased( + route_function=flexible_generator_1, + rule='any', + choice='jsq' + ) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_customers(1000) + inds = Q.nodes[-1].all_individuals + routes_counter = Counter( + [tuple(dr.node for dr in ind.data_records) for ind in inds] + ) + self.assertEqual( + routes_counter, + Counter({(1, 2, 5, 6): 503, (1, 2, 3, 6): 497}), # evenly spread between the two unflooded nodes + ) + + ## Now test 'any-lb' (flooding Node 4 so no customers ever go here): + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(rate=1), None, None, None, None, None], + service_distributions=[ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Deterministic(value=200000), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2)], + number_of_servers=[3, 3, 3, 3, 3, 3], + routing=ciw.routing.FlexibleProcessBased( + route_function=flexible_generator_1, + rule='any', + choice='lb' + ) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_customers(1000) + inds = Q.nodes[-1].all_individuals + routes_counter = Counter( + [tuple(dr.node for dr in ind.data_records) for ind in inds] + ) + self.assertEqual( + routes_counter, + Counter({(1, 2, 5, 6): 508, (1, 2, 3, 6): 492}), # evenly spread between the two unflooded nodes + ) + + + ## Now test 'all-random': + N = ciw.create_network( + arrival_distributions=[ciw.dists.Exponential(rate=1), None, None, None, None, None], + service_distributions=[ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2), ciw.dists.Exponential(rate=2)], + number_of_servers=[3, 3, 3, 3, 3, 3], + routing=ciw.routing.FlexibleProcessBased( + route_function=flexible_generator_1, + rule='all', + choice='random' + ) + ) + ciw.seed(0) + Q = ciw.Simulation(N) + Q.simulate_until_max_customers(1000) + inds = Q.nodes[-1].all_individuals + routes_counter = Counter( + [tuple(dr.node for dr in ind.data_records) for ind in inds] + ) + self.assertEqual(routes_counter[(1, 2, 3, 4, 5, 6)], 169) + self.assertEqual(routes_counter[(1, 2, 3, 5, 4, 6)], 139) + self.assertEqual(routes_counter[(1, 2, 4, 3, 5, 6)], 185) + self.assertEqual(routes_counter[(1, 2, 4, 5, 3, 6)], 166) + self.assertEqual(routes_counter[(1, 2, 5, 3, 4, 6)], 186) + self.assertEqual(routes_counter[(1, 2, 5, 4, 3, 6)], 155) + + def test_flexible_process_based_error_raising(self): + self.assertRaises(ValueError, ciw.routing.FlexibleProcessBased, flexible_generator_1, 'something', 'random') + self.assertRaises(ValueError, ciw.routing.FlexibleProcessBased, flexible_generator_1, 'all', 'something') diff --git a/docs/Reference/routers.rst b/docs/Reference/routers.rst index 430f8b3..157de79 100644 --- a/docs/Reference/routers.rst +++ b/docs/Reference/routers.rst @@ -10,6 +10,7 @@ The following network routers are currently supported: - :ref:`network_router` - :ref:`trans_mat` - :ref:`pb_route` +- :ref:`pb_flex` The following node routers are currently supported: @@ -71,6 +72,28 @@ The :ref:`process based` router takes in a function that returns ) +.. _pb_flex: + +--------------------------------- +The Flexible Process Based Routed +--------------------------------- + +The flexible process based router is an extension of the above process based router, where the routing function gives a list of sets (defined by lists). For example:: + + ciw.routing.FlexibleProcessBased( + routing_function=lambda ind, simulation: [[2, 1], [1], [1, 2, 3]], + rule='any', + choice='jsq' + ) + +Arguments: + - The :code:`rule` argument can take one of two strings: :code:`'any'` or :code:`'all'`. For each set of nodes in the pre-defined route, using :code:`'any'` means that the customer must visit only one of the nodes in the set; while using :code:`'all'` means that the customer should visit all the nodes in that set, but not necessarily in that order. + - The :code:`choice` argument can take one of a number of strings. When using the :code:`'any'` rule, it determines how a node is chosen from the set. When using the :code:`'all'` rule, it determines at each instance, the choice of next node out of the set. The current options are: + - :code:`'random'`: randomly chooses a node from the set. + - :code:`'jsq'`: chooses the node with the smallest queue from the set (like the :ref:`join-shortest-queue` router). + - :code:`'lb'`: chooses the node with the least number of customers present from the set (like the :ref:`load-balancing` router). + + Node Routing Objects @@ -125,6 +148,7 @@ The customer goes :ref:`joins the shortest queue` out of a The :code:`tie_break` argument is optional, and can take one of two strings: :code:`'random'` or :code:`'order'`. When there is a tie between the nodes with the shortest queue, tie breaks are either dealt with by choosing randomly between the ties (:code:`'random'`), or take precedence by the order listed in the :code:`destinations` list (:code:`'order'`). If omitted, random tie-breaking is used. + .. _load_balancing: -------------- From b0f6fbce135dd2c50c27c1e0390aa446a2626b77 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 2 May 2024 00:19:19 +0100 Subject: [PATCH 29/31] add flexible routing processor sharing docs --- docs/Guides/Routing/process_based.rst | 113 ++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 6 deletions(-) diff --git a/docs/Guides/Routing/process_based.rst b/docs/Guides/Routing/process_based.rst index d53d3d8..77421e8 100644 --- a/docs/Guides/Routing/process_based.rst +++ b/docs/Guides/Routing/process_based.rst @@ -4,7 +4,7 @@ How to Define Process-Based Routing =================================== -Ciw has the capability to run simulations with process-based routing. This means a customer's entire route is determined at the start and not determined probablistically as they progress through the network. +Ciw has the capability to run simulations with process-based routing. This means a customer's entire route is determined at the start and not determined probabilistically as they progress through the network. This allows routes to account for an individuals history, for example, repeating nodes a certain number of times. A customer's entire route is determined at the start, generated from a routing function, that takes in an individual and returns a route, which is a list containing the order of the nodes they are to visit. The function should also take in the simulation itself, allowing time and state-dependent routing. For example:: @@ -36,7 +36,7 @@ Let's run this and look at the routes of those that have left the system. >>> Q = ciw.Simulation(N) >>> Q.simulate_until_max_time(100.0) - >>> inds = Q.nodes[-1].all_individuals # Get's all individuals from exit node + >>> inds = Q.nodes[-1].all_individuals # Gets all individuals from exit node >>> set([tuple(dr.node for dr in ind.data_records) for ind in inds]) # Get's all unique routes of completed individuals {(1, 1, 1)} @@ -58,7 +58,7 @@ Lets make a network with three nodes with the following routes: + have 50% chance of routing to Node 1, and then exit. + There are no arrivals at Node 3. -For this we will require two routing functions: :code:`routing_function_Node_1`, :code:`routing_function_Node_2`:: +For this we will require a routing function that returns different things depending on the individual's starting node:: >>> import random >>> def routing_function(ind, simulation): @@ -71,8 +71,6 @@ For this we will require two routing functions: :code:`routing_function_Node_1`, ... return [2, 3] ... return [2, 1] -As there are no arrivals at Node 3, no customer will need routing assigned here. However, we need to use the placeholder function :code:`ciw.no_routing` to account for this:: - >>> N = ciw.create_network( ... arrival_distributions=[ciw.dists.Exponential(rate=1), ... ciw.dists.Deterministic(value=1), @@ -80,6 +78,109 @@ As there are no arrivals at Node 3, no customer will need routing assigned here. ... service_distributions=[ciw.dists.Exponential(rate=2), ... ciw.dists.Exponential(rate=2), ... ciw.dists.Exponential(rate=2)], - ... number_of_servers=[1,1,1], + ... number_of_servers=[1, 1, 1], ... routing=ciw.routing.ProcessBased(routing_function) ... ) + + + +Flexible Process Based Routing +------------------------------ + +In the examples above, once a route was sampled, the customer's entire journey was set out before them. However, with a :code:`FlexibleProcessBased` object, we can define sequences of sets of nodes that must be visited in order. Within a set of nodes, either the individual must visit at least one node here, or must visit all nodes here, but the order is irrelevant. This is defined with the :code:`rule` keyword. + +Consider for example the following sequence of sets of destinations:: + + [[1, 2, 3], [4], [5, 6]] + +There are three sets of nodes in the sequence, the set :code:`[1, 2, 3]`, followed by the set :code:`[4]`, followed by the set :code:`[5, 6]`. Routes are then determined by the :code:`rule` keyword: + ++ :code:`rule='any'`: this means that at just one node from each set should be visited, in the order of the sets. The choice of which node is chosen from each set is set with the :code:`choice` keyword. Valid routes include (1, 4, 5), (2, 4, 5), and (3, 4, 6), amongst others. ++ :code:`rule='all'`: this means that every node in a set must be visited before moving on to the next set. The order at which a node is visited in a set is set with the :code:`choice` keyword. Valid routes include (1, 2, 3, 4, 5, 6), (3, 2, 1, 4, 6, 5), and (3, 1, 2, 4, 5, 6), amongst others. + +The current options for choices are: + - :code:`'random'`: randomly chooses a node from the set. + - :code:`'jsq'`: chooses the node with the smallest queue from the set (like the :ref:`join-shortest-queue` router). + - :code:`'lb'`: chooses the node with the least number of customers present from the set (like the :ref:`load-balancing` router). + +When all nodes in a set must be visited, these rules apply to choosing the next node from the set minus the nodes already visited, applied at the current time when the choice is made. + +Example:: + + >>> def routing_function(ind, simulation): + ... return [[1, 2], [3], [1, 2]] + +A route where the first and third sets include nodes 1 and 2, and the second set only includes node 3. All customers arrive to node 4. Let's compare the :code:`'any'` and :code:`'all'` rules. First with :code:`'any'`:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... None, + ... None, + ... None, + ... ciw.dists.Exponential(rate=1) + ... ], + ... service_distributions=[ + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ], + ... number_of_servers=[1, 1, 1, 1], + ... routing=ciw.routing.FlexibleProcessBased( + ... route_function=routing_function, + ... rule='any', + ... choice='random' + ... ) + ... ) + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_customers(6) + >>> routes = [[dr.node for dr in ind.data_records] for ind in Q.nodes[-1].all_individuals] + >>> for route in routes: + ... print(route) + [4, 2, 3, 2] + [4, 1, 3, 1] + [4, 1, 3, 1] + [4, 1, 3, 1] + [4, 2, 3, 1] + [4, 2, 3, 1] + +We see that all customers that completed their journey arrived at node 4, took either node 1 or 2 first, then node 3, then either node 1 or 2. + +Now compare with :code:`'all'`:: + + >>> N = ciw.create_network( + ... arrival_distributions=[ + ... None, + ... None, + ... None, + ... ciw.dists.Exponential(rate=1) + ... ], + ... service_distributions=[ + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ciw.dists.Exponential(rate=2), + ... ], + ... number_of_servers=[1, 1, 1, 1], + ... routing=ciw.routing.FlexibleProcessBased( + ... route_function=routing_function, + ... rule='all', + ... choice='random' + ... ) + ... ) + >>> ciw.seed(0) + >>> Q = ciw.Simulation(N) + >>> Q.simulate_until_max_customers(6) + >>> routes = [[dr.node for dr in ind.data_records] for ind in Q.nodes[-1].all_individuals] + >>> for route in routes: + ... print(route) + [4, 2, 1, 3, 2, 1] + [4, 1, 2, 3, 2, 1] + [4, 2, 1, 3, 2, 1] + [4, 1, 2, 3, 2, 1] + [4, 1, 2, 3, 1, 2] + [4, 1, 2, 3, 2, 1] + +We see that all customers that completed their journey arrived at node 4, took both node 1 or 2 in either order, then node 3, then both node 1 or 2 in either order. + From c1287fcd7875dd54139c3856fb64d22ffd38ba86 Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Thu, 2 May 2024 13:26:05 +0100 Subject: [PATCH 30/31] update readme --- README.rst | 38 ++++++++++++++------------- docs/Guides/Routing/process_based.rst | 2 +- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index 4de86d6..df75f93 100644 --- a/README.rst +++ b/README.rst @@ -75,23 +75,25 @@ Features A number of other features are also implemented, including: -+ `Type I blocking `_ ++ `Type I blocking `_ + `A large range of sampling distributions `_ -+ `Phase-Type distributions `_ -+ `Time-dependent and state-dependent distributions `_ -+ `Batch arrivals `_ -+ `Baulking customers `_ -+ `Reneging customers `_ -+ `Processor sharing `_ -+ `Multiple customer classes `_ -+ `Priorities `_ -+ `Server priorities `_ -+ `Service disciplines `_ -+ `Customers changing classes `_ -+ `Server schedules `_ -+ `Slotted services `_ -+ `State tracking `_ -+ `Stopping the simulation after a certain amount of customers `_ -+ `Process-based routing `_ -+ `Deadlock detection `_ ++ `Phase-Type distributions `_ ++ `Time-dependent and state-dependent distributions `_ ++ `Batch arrivals `_ ++ `Baulking customers `_ ++ `Reneging customers `_ ++ `Processor sharing `_ ++ `Multiple customer classes `_ ++ `Priorities `_ ++ `Server priorities `_ ++ `Service disciplines `_ ++ `Customers changing classes while queueing `_ ++ `Customers changing classes after service `_ ++ `Server schedules `_ ++ `Slotted services `_ ++ `State tracking `_ ++ `Stopping the simulation after a certain amount of customers `_ ++ `Process-based routing `_ ++ `Logical routing `_ ++ `Deadlock detection `_ diff --git a/docs/Guides/Routing/process_based.rst b/docs/Guides/Routing/process_based.rst index 77421e8..4abb10b 100644 --- a/docs/Guides/Routing/process_based.rst +++ b/docs/Guides/Routing/process_based.rst @@ -37,7 +37,7 @@ Let's run this and look at the routes of those that have left the system. >>> Q.simulate_until_max_time(100.0) >>> inds = Q.nodes[-1].all_individuals # Gets all individuals from exit node - >>> set([tuple(dr.node for dr in ind.data_records) for ind in inds]) # Get's all unique routes of completed individuals + >>> set([tuple(dr.node for dr in ind.data_records) for ind in inds]) # Gets all unique routes of completed individuals {(1, 1, 1)} Now we can see that all individuals who have left the system, that is they have completed their route, repeated service at Node 1 three times. From 8155d5eefc1ecf633a0bb73fdaa2e19c4074d32e Mon Sep 17 00:00:00 2001 From: Geraint Palmer Date: Tue, 7 May 2024 14:39:22 +0100 Subject: [PATCH 31/31] update list_of_parameters reference in docs --- docs/Guides/Queues/system_capacity.rst | 2 +- docs/Reference/parameters.rst | 133 ++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/docs/Guides/Queues/system_capacity.rst b/docs/Guides/Queues/system_capacity.rst index df97d1e..44fba04 100644 --- a/docs/Guides/Queues/system_capacity.rst +++ b/docs/Guides/Queues/system_capacity.rst @@ -1,4 +1,4 @@ -.. system-capacity: +.. _system-capacity: =================================================== How to Set a Maximium Capacity for the Whole System diff --git a/docs/Reference/parameters.rst b/docs/Reference/parameters.rst index 7e526e4..128c676 100644 --- a/docs/Reference/parameters.rst +++ b/docs/Reference/parameters.rst @@ -76,7 +76,7 @@ class_change_matrices *Optional* A dictionary of class change matrices for each node. -For more details see :ref:`dynamic-classes`. +For more details see :ref:`changeclass-afterservice`. An example for a two node network with two classes of customer:: @@ -91,6 +91,22 @@ An example for a two node network with two classes of customer:: +class_change_time_distributions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Optional* + +A dictionary of distributions representing the time it takes to change from one class into another while waiting. For more details see :ref:`changeclass-whilequeueing`. + +An example of a two class network where customers of class 0 change to customers of class 1 according to an exponential distribution:: + + class_change_dist_dict = { + 'Class 0': {'Class 1': ciw.dists.Exponential(rate=5)} + } + + + + number_of_servers ~~~~~~~~~~~~~~~~~ @@ -123,19 +139,63 @@ Example:: +ps_thresholds +~~~~~~~~~~~~~ + +A list of thresholds for capacitated processor sharing queues. +For more information see :ref:`processor-sharing`. + +Example:: + + ps_thresholds=[3] + + + + queue_capacities ~~~~~~~~~~~~~~~~ *Optional* A list of maximum queue capacities at each node. -If ommitted, default values of :code:`float('inf')` for every node are given. +If omitted, default values of :code:`float('inf')` for every node are given. +For more details see :ref:`queue-capacities`. Example:: queue_capacities=[5, float('inf'), float('inf'), 10] +reneging_destinations +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Optional* + +A dictionary of lists representing the destination a customer goes to when they renege, or abandon the queue, while waiting. For more details see :ref:`reneging-customers`. + +An example of a one node, two class network where customers of class 0 renege to node 2, and customers of class 1 renege and leave the system:: + + reneging_destinations = { + 'Class 0': [2], + 'Class 1': [-1] + } + + + +reneging_time_distributions +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Optional* + +A dictionary of distributions representing the time it takes for a customer to renege, or abandon the queue, while waiting. For more details see :ref:`reneging-customers`. + +An example of a one node, two class network where customers of class 0 renege after a 5 time units, and customers of class 1 do not renege:: + + reneging_time_distributions = { + 'Class 0': [ciw.dists.Deterministic(value=5)], + 'Class 1': [None] + } + routing ~~~~~~~ @@ -144,31 +204,59 @@ routing *Optional for 1 node* -Describes how each customer class routes around the system. -This may be a routing matrix for each customer class, or a list routing function for process-based simulations, see :ref:`process-based`. +Describes how each customer class routes around the system. +This may be a routing matrix for each customer class, or a routing object, see :ref:`routing-objects`. -This is a dictionary, with keys as customer classes, and values are lists of lists (matrices) containing the routing probabilities. -If only one class of customer is required it is sufficient to simply enter single routing matrix (a list of lists). +This is a dictionary, with keys as customer classes, and values are routing objects (or lists of of lists, matrices, containing the routing probabilities). +If only one class of customer is required it is sufficient to simply enter single routing object or matrix. -An example is shown:: +An example of using a routing object:: + + routing = ciw.routing.NetworkRouting( + routers=[ + ciw.routing.Direct(to=2), + ciw.routing.Leave() + ] + ) + +And an example of using transition matrices is shown:: routing={'Class 0': [[0.1, 0.3], [0.0, 0.8]], 'Class 1': [[0.0, 1.0], [0.0, 0.0]]} -An example where only one class of customer is required:: - routing=[[0.5, 0.3], - [0.2, 0.6]] +server_priority_functions +~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Optional* -If using only one node, the default value is:: +A function for each node that decides how to choose between multiple servers in the same node. +For more details see :ref:`server-priority`. - routing={'Class 0': [[0.0]]} +Example:: + + server_priority_functions=[custom_server_priority] + + + +service_disciplines +~~~~~~~~~~~~~~~~~~~ + +*Optional* -Otherwise a process-based routing function:: +A list of service discipline functions, that describe the order in which customers are taken from the queue and served. +For more details see :ref:`service-disciplines`. + +If omitted, FIFO service disciplines are assumed. + +Example of a 3 node network, one using FIFO, one using LIFO, and one using SIRO:: + + service_disciplines=[ciw.disciplines.FIFO, + ciw.disciplines.LIFO, + ciw.disciplines.SIRO] - routing=[routing_function] @@ -194,3 +282,20 @@ An example where only one class of customer is required:: service_distributions=[ciw.dists.Exponential(rate=4.8), ciw.dists.Exponential(rate=5.2)] + + + +system_capacity +~~~~~~~~~~~~~~~ + +*Optional* + +The maximum queue capacity for the system. +If omitted, a default value of :code:`float('inf')` is given. +For more details see :ref:`system-capacity`. + +Example:: + + system_capacity=12 + +