|
| 1 | +""" |
| 2 | +Validates a discrete event simulation of a healthcare M/M/S queue by comparing |
| 3 | +simulation results to analytical queueing theory. |
| 4 | +
|
| 5 | +Metrics (using standard queueing theory notation): |
| 6 | +- ρ (rho): utilisation |
| 7 | +- L_q: mean queue length |
| 8 | +- L_s: mean number of patients in system |
| 9 | +- W_q: mean waiting |
| 10 | +- W_s: mean time in system |
| 11 | +
|
| 12 | +Results must match theory with a 15% tolerance (accomodates stochasticity). |
| 13 | +Tests are run across diverse parameter combinations and utilisation levels. |
| 14 | +System stability requires arrival rate < number_of_servers * service_rate. |
| 15 | +""" |
| 16 | + |
| 17 | +import math |
| 18 | +import pytest |
| 19 | + |
| 20 | +from simulation import Param, Runner |
| 21 | + |
| 22 | + |
| 23 | +class MMSQueue: |
| 24 | + """ |
| 25 | + Analytical M/M/S queue formulas. |
| 26 | +
|
| 27 | + Parameters |
| 28 | + ---------- |
| 29 | + arrival_rate : float |
| 30 | + Customer arrival rate (λ). |
| 31 | + service_rate : float |
| 32 | + Service rate per server (μ). |
| 33 | + num_servers : int |
| 34 | + Number of servers (s). |
| 35 | +
|
| 36 | + Attributes |
| 37 | + ---------- |
| 38 | + rho : float |
| 39 | + Utilisation (λ / (sμ)). |
| 40 | + lambda_over_mu : float |
| 41 | + Arrival/service rate ratio (λ / μ). |
| 42 | + metrics : dict |
| 43 | + Calculated performance metrics. |
| 44 | + """ |
| 45 | + |
| 46 | + def __init__(self, arrival_rate, service_rate, num_servers): |
| 47 | + """ |
| 48 | + Initialise the M/M/S queue. |
| 49 | +
|
| 50 | + Raises |
| 51 | + ------ |
| 52 | + ValueError |
| 53 | + If parameters are invalid or system is unstable. |
| 54 | + """ |
| 55 | + if arrival_rate <= 0: |
| 56 | + raise ValueError("Arrival rate must be positive") |
| 57 | + if service_rate <= 0: |
| 58 | + raise ValueError("Service rate must be positive") |
| 59 | + if num_servers < 1: |
| 60 | + raise ValueError("Number of servers must be at least 1") |
| 61 | + |
| 62 | + self.arrival_rate = arrival_rate |
| 63 | + self.service_rate = service_rate |
| 64 | + self.num_servers = num_servers |
| 65 | + |
| 66 | + # Calculate utilisation |
| 67 | + self.rho = self.get_traffic_intensity() |
| 68 | + |
| 69 | + # Check system stability |
| 70 | + if self.rho >= 1: |
| 71 | + raise ValueError( |
| 72 | + f"System is unstable: ρ = {self.rho:.4f} >= 1. " |
| 73 | + f"Need λ < s*μ ({arrival_rate} < {num_servers * service_rate})" |
| 74 | + ) |
| 75 | + |
| 76 | + # Calculate λ/μ (average customers in service if infinite servers) |
| 77 | + self.lambda_over_mu = self.arrival_rate / self.service_rate |
| 78 | + |
| 79 | + # Calculate performance metrics using Little's Law |
| 80 | + self.metrics = self.calculate_metrics() |
| 81 | + |
| 82 | + def get_traffic_intensity(self): |
| 83 | + """ |
| 84 | + Calculate the traffic intensity (server utilisation). |
| 85 | +
|
| 86 | + Returns |
| 87 | + ------- |
| 88 | + float |
| 89 | + Traffic intensity ρ = λ/(s*μ). |
| 90 | + """ |
| 91 | + return self.arrival_rate / (self.num_servers * self.service_rate) |
| 92 | + |
| 93 | + def calculate_metrics(self): |
| 94 | + """ |
| 95 | + Calculate all performance metrics for the queue. |
| 96 | +
|
| 97 | + Returns |
| 98 | + ------- |
| 99 | + dict[str, float] |
| 100 | + Dictionary containing performance metrics. |
| 101 | + """ |
| 102 | + metrics = {} |
| 103 | + metrics["rho"] = self.rho |
| 104 | + metrics["L_q"] = self.get_mean_queue_length() |
| 105 | + metrics["L_s"] = metrics["L_q"] + ( |
| 106 | + self.arrival_rate / self.service_rate |
| 107 | + ) |
| 108 | + metrics["W_s"] = metrics["L_s"] / self.arrival_rate |
| 109 | + metrics["W_q"] = metrics["W_s"] - (1 / self.service_rate) |
| 110 | + return metrics |
| 111 | + |
| 112 | + def get_mean_queue_length(self): |
| 113 | + """ |
| 114 | + Calculate the expected number of customers waiting in queue (L_q). |
| 115 | +
|
| 116 | + Uses the formula: |
| 117 | + L_q = P₀ * (λ/μ)^s * ρ / (s! * (1-ρ)²) |
| 118 | +
|
| 119 | + Returns |
| 120 | + ------- |
| 121 | + float |
| 122 | + Expected queue length. |
| 123 | + """ |
| 124 | + p0 = self.prob_system_empty() |
| 125 | + |
| 126 | + lq = (p0 * (self.lambda_over_mu**self.num_servers) * self.rho) / ( |
| 127 | + math.factorial(self.num_servers) * (1 - self.rho) ** 2 |
| 128 | + ) |
| 129 | + |
| 130 | + return lq |
| 131 | + |
| 132 | + def prob_system_empty(self): |
| 133 | + """ |
| 134 | + Calculate the probability that the system is empty (P₀). |
| 135 | +
|
| 136 | + Uses the formula: |
| 137 | + P₀ = [Σ(n=0 to s-1) (λ/μ)^n/n! + (λ/μ)^s/(s!(1-ρ))]^(-1) |
| 138 | +
|
| 139 | + Returns |
| 140 | + ------- |
| 141 | + float |
| 142 | + Probability that system is empty. |
| 143 | + """ |
| 144 | + # Sum for n = 0 to s-1 |
| 145 | + sum_part = sum( |
| 146 | + (self.lambda_over_mu**n) / math.factorial(n) |
| 147 | + for n in range(self.num_servers) |
| 148 | + ) |
| 149 | + |
| 150 | + # Term for n >= s |
| 151 | + server_term = (self.lambda_over_mu**self.num_servers) / ( |
| 152 | + math.factorial(self.num_servers) * (1 - self.rho) |
| 153 | + ) |
| 154 | + |
| 155 | + return 1 / (sum_part + server_term) |
| 156 | + |
| 157 | + |
| 158 | +def run_simulation_model( |
| 159 | + patient_inter, |
| 160 | + mean_n_consult_time, |
| 161 | + number_of_nurses |
| 162 | +): |
| 163 | + """ |
| 164 | + Run simulation and return key performance indicators using standard |
| 165 | + queueing theory notation. |
| 166 | +
|
| 167 | + The warm-up period should be sufficiently long to allow the system |
| 168 | + to reach steady-state before data collection begins. |
| 169 | + """ |
| 170 | + param = Param( |
| 171 | + patient_inter=patient_inter, |
| 172 | + mean_n_consult_time=mean_n_consult_time, |
| 173 | + number_of_nurses=number_of_nurses, |
| 174 | + warm_up_period=500, |
| 175 | + data_collection_period=1500, |
| 176 | + number_of_runs=100, |
| 177 | + audit_interval=50, |
| 178 | + scenario_name=0, |
| 179 | + cores=1, |
| 180 | + ) |
| 181 | + experiment = Runner(param) |
| 182 | + experiment.run_reps() |
| 183 | + |
| 184 | + # Calculate the mean time in the system |
| 185 | + df = experiment.overall_results_df.copy() |
| 186 | + df["mean_time_in_system"] = ( |
| 187 | + df["mean_q_time_nurse"] + df["mean_time_with_nurse"] |
| 188 | + ) |
| 189 | + |
| 190 | + # Rename the columns using queuing theory notation |
| 191 | + mapping = { |
| 192 | + "mean_q_time_nurse": "W_q", |
| 193 | + "mean_time_in_system": "W_s", |
| 194 | + "mean_nurse_utilisation": "rho", |
| 195 | + "mean_nurse_q_length": "L_q", |
| 196 | + } |
| 197 | + df = df.rename(columns=mapping) |
| 198 | + |
| 199 | + # Return relevant columns |
| 200 | + return df[mapping.values()].T["mean"] |
| 201 | + |
| 202 | + |
| 203 | +@pytest.mark.parametrize( |
| 204 | + "patient_inter,mean_n_consult_time,number_of_nurses", |
| 205 | + [ |
| 206 | + # Test case 1: Low utilisation (ρ ≈ 0.3) |
| 207 | + (10, 3, 2), |
| 208 | + # Test case 2: Medium utilisation (ρ ≈ 0.67) |
| 209 | + (6, 4, 2), |
| 210 | + # Test case 3: M/M/1 (ρ = 0.75) |
| 211 | + (4, 3, 1), |
| 212 | + # Test case 4: Multiple servers, high utilisation (ρ ≈ 0.91) |
| 213 | + (5.5, 5, 3), |
| 214 | + # Test case 5: Balanced system (ρ = 0.5) |
| 215 | + (8, 4, 1), |
| 216 | + # Test case 6: Many servers, low individual utilisation (ρ ≈ 0.63) |
| 217 | + (4, 10, 4), |
| 218 | + # Test case 7: Very low utilisation (ρ ≈ 0.167) |
| 219 | + (60, 10, 15), |
| 220 | + ], |
| 221 | +) |
| 222 | +def test_simulation_against_theory( |
| 223 | + patient_inter, |
| 224 | + mean_n_consult_time, |
| 225 | + number_of_nurses |
| 226 | +): |
| 227 | + """Test simulation results against theoretical M/M/S queue calculations.""" |
| 228 | + |
| 229 | + # Create theoretical M/M/S queue model and get metrics |
| 230 | + lam = 1 / patient_inter |
| 231 | + mu = 1 / mean_n_consult_time |
| 232 | + theory = MMSQueue(lam, mu, number_of_nurses).metrics |
| 233 | + |
| 234 | + # Run simulation |
| 235 | + sim = run_simulation_model( |
| 236 | + patient_inter=patient_inter, |
| 237 | + mean_n_consult_time=mean_n_consult_time, |
| 238 | + number_of_nurses=number_of_nurses |
| 239 | + ) |
| 240 | + |
| 241 | + # Compare results with appropriate tolerance (round to 3dp + 15% tolerance) |
| 242 | + metrics = [ |
| 243 | + ("rho", "Utilisation"), |
| 244 | + ("L_q", "Queue length"), |
| 245 | + ("W_q", "Wait time"), |
| 246 | + ("W_s", "System time") |
| 247 | + ] |
| 248 | + for key, label in metrics: |
| 249 | + sim_val = round(sim[key], 3) |
| 250 | + theory_val = round(theory[key], 3) |
| 251 | + assert sim_val == pytest.approx(theory_val, rel=0.15), ( |
| 252 | + f"{label} mismatch: sim={sim_val}, theory={theory_val}" |
| 253 | + ) |
0 commit comments