Skip to content

Commit 01bccf4

Browse files
authored
Merge pull request #62 from pythonhealthdatascience/mms
Additional Validation using queuing theory
2 parents 8883829 + bf7ee80 commit 01bccf4

File tree

2 files changed

+263
-0
lines changed

2 files changed

+263
-0
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Dates formatted as YYYY-MM-DD as per [ISO standard](https://www.iso.org/iso-8601-date-and-time-format.html).
77

8+
9+
## Unreleased
10+
11+
Additional validation: Test simulation model against analytical queuing results
12+
13+
### Added
14+
15+
* Analytical queuing model `MMSQueue` in `test/test_mms.py`
16+
* A small test suite for comparing long run simulation estimation of means to analytical results (to 3 dp with 0.15 relative tolerance). Tested L_q, L_s, W_q, W_s.
17+
818
## v1.2.0 - 2025-03-26
919

1020
Add tests, change from default inputs, rename some variables, and add a method which allows the solution of `ReplicationsAlgorithm` to be less than the `initial_replications` set.

tests/test_mms.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)