most-queue 2.8__py3-none-any.whl → 2.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- most_queue/__init__.py +7 -1
- most_queue/random/map_fit.py +137 -0
- most_queue/random/map_ph.py +48 -0
- most_queue/sim/aoi.py +133 -0
- most_queue/sim/base.py +3 -2
- most_queue/sim/base_core.py +7 -3
- most_queue/sim/bmap.py +130 -0
- most_queue/sim/bulk_service.py +95 -0
- most_queue/sim/msj.py +117 -0
- most_queue/structs.py +9 -0
- most_queue/theory/aoi/__init__.py +5 -0
- most_queue/theory/aoi/calc.py +114 -0
- most_queue/theory/batch/__init__.py +7 -0
- most_queue/theory/batch/bulk_service.py +138 -0
- most_queue/theory/batch/mm1.py +3 -2
- most_queue/theory/closed/__init__.py +7 -0
- most_queue/theory/fifo/__init__.py +12 -0
- most_queue/theory/fifo/gi_m_1.py +10 -13
- most_queue/theory/fifo/mgn_takahasi.py +33 -0
- most_queue/theory/fork_join/__init__.py +11 -0
- most_queue/theory/impatience/__init__.py +11 -0
- most_queue/theory/matrix/__init__.py +16 -1
- most_queue/theory/matrix/bmap_m1.py +163 -0
- most_queue/theory/matrix/bmap_ph1.py +180 -0
- most_queue/theory/matrix/map_mmc.py +190 -0
- most_queue/theory/matrix/map_phc.py +278 -0
- most_queue/theory/msj/__init__.py +7 -0
- most_queue/theory/msj/exact.py +123 -0
- most_queue/theory/msj/saturated.py +138 -0
- most_queue/theory/negative/__init__.py +15 -0
- most_queue/theory/networks/__init__.py +7 -2
- most_queue/theory/priority/__init__.py +7 -1
- most_queue/theory/priority/preemptive/mm2_3cls_busy_approx.py +9 -12
- most_queue/theory/priority/preemptive/mmk_prty_exact.py +315 -0
- most_queue/theory/priority/preemptive/mmn_2cls_pr_busy_approx.py +9 -12
- most_queue/theory/priority/preemptive/mph_ph_k_2class.py +227 -0
- most_queue/theory/priority/preemptive/rdr_a.py +185 -0
- most_queue/theory/srpt/__init__.py +3 -0
- most_queue/theory/srpt/degradation.py +71 -0
- most_queue/theory/utils/passage_time.py +48 -37
- most_queue/theory/vacations/__init__.py +20 -0
- {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/METADATA +27 -12
- {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/RECORD +45 -25
- {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/WHEEL +0 -0
- {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/licenses/LICENSE +0 -0
most_queue/__init__.py
CHANGED
|
@@ -5,12 +5,18 @@ This package provides both simulation and numerical calculation methods
|
|
|
5
5
|
for various types of queueing systems and networks.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
9
|
|
|
10
10
|
# Import main simulation and theory classes for easy access
|
|
11
11
|
from most_queue.sim.base import QsSim
|
|
12
12
|
from most_queue.theory.base_queue import BaseQueue
|
|
13
13
|
|
|
14
|
+
try:
|
|
15
|
+
# single source of truth for the version: the installed package metadata
|
|
16
|
+
__version__ = version("most-queue")
|
|
17
|
+
except PackageNotFoundError: # running from a source checkout that is not installed
|
|
18
|
+
__version__ = "0.0.0.dev0"
|
|
19
|
+
|
|
14
20
|
__all__ = [
|
|
15
21
|
"QsSim",
|
|
16
22
|
"BaseQueue",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fitting a Markovian Arrival Process (MAP) to data.
|
|
3
|
+
|
|
4
|
+
The workhorse is the two-state MMPP (MMPP-2): a Poisson process whose rate
|
|
5
|
+
switches between two levels driven by a two-state Markov chain. It is fitted
|
|
6
|
+
by matching three interarrival statistics — the rate, the squared coefficient
|
|
7
|
+
of variation (SCV) and the lag-1 autocorrelation — for which the MAP has
|
|
8
|
+
closed-form expressions (see ``most_queue.random.map_ph``).
|
|
9
|
+
|
|
10
|
+
An MMPP is always overdispersed and positively correlated: SCV >= 1 and
|
|
11
|
+
lag-1 correlation >= 0. Targets outside this region cannot be represented by
|
|
12
|
+
an MMPP-2 and raise a clear error.
|
|
13
|
+
|
|
14
|
+
References:
|
|
15
|
+
Fischer W., Meier-Hellstern K. The Markov-modulated Poisson process (MMPP)
|
|
16
|
+
cookbook. Performance Evaluation, 18(2), 1993.
|
|
17
|
+
doi:10.1016/0166-5316(93)90035-s.
|
|
18
|
+
Heffes H., Lucantoni D. A Markov modulated characterization of packetized
|
|
19
|
+
voice and data traffic. IEEE JSAC, 4(6), 1986. doi:10.1109/JSAC.1986.1146428.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy.optimize import least_squares
|
|
24
|
+
|
|
25
|
+
from most_queue.random.map_ph import MAP, MAPParams
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def map_statistics(params: MAPParams) -> tuple[float, float, float]:
|
|
29
|
+
"""
|
|
30
|
+
Return (rate, scv, lag1) of a MAP: fundamental arrival rate, squared
|
|
31
|
+
coefficient of variation and lag-1 autocorrelation of interarrival times.
|
|
32
|
+
"""
|
|
33
|
+
rate = MAP.arrival_rate(params)
|
|
34
|
+
moments = MAP.calc_theory_moments(params, 2)
|
|
35
|
+
m1, m2 = moments[0], moments[1]
|
|
36
|
+
scv = m2 / (m1 * m1) - 1.0
|
|
37
|
+
lag1 = MAP.lag_correlation(params, 1)
|
|
38
|
+
return rate, scv, lag1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _mmpp2_from_vector(x: np.ndarray) -> MAPParams:
|
|
42
|
+
"""Build an MMPP-2 from log-parameters x = log([l1, l2, q12, q21])."""
|
|
43
|
+
l1, l2, q12, q21 = np.exp(x)
|
|
44
|
+
return MAP.mmpp([l1, l2], np.array([[-q12, q12], [q21, -q21]]))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fit_mmpp2(
|
|
48
|
+
rate: float,
|
|
49
|
+
scv: float,
|
|
50
|
+
lag1: float,
|
|
51
|
+
n_restarts: int = 6,
|
|
52
|
+
tol: float = 1e-9,
|
|
53
|
+
) -> MAPParams:
|
|
54
|
+
"""
|
|
55
|
+
Fit an MMPP-2 matching the given interarrival rate, SCV and lag-1
|
|
56
|
+
autocorrelation.
|
|
57
|
+
|
|
58
|
+
:param rate: target fundamental arrival rate (> 0)
|
|
59
|
+
:param scv: target squared coefficient of variation (>= 1 for an MMPP)
|
|
60
|
+
:param lag1: target lag-1 autocorrelation (>= 0 for an MMPP)
|
|
61
|
+
:param n_restarts: number of random restarts for the local optimizer
|
|
62
|
+
:param tol: acceptable residual norm to stop early
|
|
63
|
+
:return: MAPParams of the fitted MMPP-2
|
|
64
|
+
:raises ValueError: if the target is outside the MMPP-representable region
|
|
65
|
+
"""
|
|
66
|
+
if rate <= 0:
|
|
67
|
+
raise ValueError(f"rate must be positive, got {rate}")
|
|
68
|
+
if scv < 1.0 - 1e-6:
|
|
69
|
+
raise ValueError(f"an MMPP-2 requires SCV >= 1 (overdispersed), got {scv:.4f}")
|
|
70
|
+
if lag1 < -1e-6:
|
|
71
|
+
raise ValueError(f"an MMPP-2 requires non-negative lag-1 correlation, got {lag1:.4f}")
|
|
72
|
+
|
|
73
|
+
target = np.array([rate, scv, lag1])
|
|
74
|
+
weights = np.array([1.0 / max(rate, 1e-9), 1.0, 1.0])
|
|
75
|
+
|
|
76
|
+
def residuals(x: np.ndarray) -> np.ndarray:
|
|
77
|
+
try:
|
|
78
|
+
stats = np.array(map_statistics(_mmpp2_from_vector(x)))
|
|
79
|
+
except (np.linalg.LinAlgError, ValueError):
|
|
80
|
+
return np.full(3, 1e3)
|
|
81
|
+
return (stats - target) * weights
|
|
82
|
+
|
|
83
|
+
rng = np.random.default_rng(12345)
|
|
84
|
+
best = np.log([rate, rate, 0.1 * rate + 1e-3, 0.1 * rate + 1e-3])
|
|
85
|
+
best_cost = np.inf
|
|
86
|
+
for k in range(max(1, n_restarts)):
|
|
87
|
+
if k == 0:
|
|
88
|
+
# deterministic heuristic start: rates spread by the overdispersion,
|
|
89
|
+
# slow switching to create the correlation
|
|
90
|
+
spread = 1.0 + np.sqrt(max(scv - 1.0, 0.0))
|
|
91
|
+
x0 = np.log([rate * spread, rate / spread, 0.1 * rate + 1e-3, 0.1 * rate + 1e-3])
|
|
92
|
+
else:
|
|
93
|
+
x0 = np.log(rate) + rng.uniform(-2.0, 2.0, size=4)
|
|
94
|
+
# trf (not lm): the 4-parameter MMPP-2 is under-determined by the
|
|
95
|
+
# 3 target statistics, so residuals < variables is expected
|
|
96
|
+
sol = least_squares(residuals, x0, method="trf", max_nfev=2000)
|
|
97
|
+
if sol.cost < best_cost:
|
|
98
|
+
best, best_cost = sol.x, sol.cost
|
|
99
|
+
if np.linalg.norm(sol.fun) < tol:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
fitted = _mmpp2_from_vector(best)
|
|
103
|
+
achieved = map_statistics(fitted)
|
|
104
|
+
err = np.abs(np.array(achieved) - target) * weights
|
|
105
|
+
if np.max(err) > 1e-2:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"failed to fit MMPP-2 to (rate={rate:.4f}, scv={scv:.4f}, lag1={lag1:.4f}); "
|
|
108
|
+
f"achieved (rate={achieved[0]:.4f}, scv={achieved[1]:.4f}, lag1={achieved[2]:.4f}). "
|
|
109
|
+
"The target may be near the boundary of the MMPP-representable region."
|
|
110
|
+
)
|
|
111
|
+
return fitted
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def trace_statistics(interarrivals) -> tuple[float, float, float]:
|
|
115
|
+
"""
|
|
116
|
+
Empirical (rate, scv, lag1) of a sequence of interarrival times.
|
|
117
|
+
"""
|
|
118
|
+
x = np.asarray(interarrivals, dtype=float)
|
|
119
|
+
if x.size < 3:
|
|
120
|
+
raise ValueError("need at least 3 interarrival samples")
|
|
121
|
+
mean = x.mean()
|
|
122
|
+
scv = x.var() / (mean * mean)
|
|
123
|
+
lag1 = float(np.corrcoef(x[:-1], x[1:])[0, 1])
|
|
124
|
+
return 1.0 / mean, scv, lag1
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fit_map_from_trace(interarrivals, **kwargs) -> MAPParams:
|
|
128
|
+
"""
|
|
129
|
+
Fit an MMPP-2 to a trace of interarrival times by matching its empirical
|
|
130
|
+
rate, SCV and lag-1 autocorrelation.
|
|
131
|
+
|
|
132
|
+
:param interarrivals: sequence of interarrival times
|
|
133
|
+
:param kwargs: forwarded to :func:`fit_mmpp2`
|
|
134
|
+
:return: MAPParams of the fitted MMPP-2
|
|
135
|
+
"""
|
|
136
|
+
rate, scv, lag1 = trace_statistics(interarrivals)
|
|
137
|
+
return fit_mmpp2(rate, max(scv, 1.0), max(lag1, 0.0), **kwargs)
|
most_queue/random/map_ph.py
CHANGED
|
@@ -43,6 +43,54 @@ class MAPParams:
|
|
|
43
43
|
D1: np.ndarray # transitions generating an arrival, shape (m, m)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
@dataclass
|
|
47
|
+
class BMAPParams:
|
|
48
|
+
"""
|
|
49
|
+
Batch Markovian Arrival Process parameters: a list of matrices
|
|
50
|
+
D[0], D[1], D[2], ... where D[0] holds phase transitions without arrivals
|
|
51
|
+
and D[k] (k >= 1) holds transitions that generate a batch of k arrivals.
|
|
52
|
+
sum_k D[k] is the generator of the phase process.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
D: list # [D0, D1, D2, ...], each an (m, m) matrix
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bmap_poisson_batch(rate: float, batch_probs: list) -> BMAPParams:
|
|
59
|
+
"""
|
|
60
|
+
Single-phase BMAP: batches arrive as a Poisson process of intensity
|
|
61
|
+
``rate`` with batch-size probabilities ``batch_probs`` (batch_probs[k-1] is
|
|
62
|
+
P(batch size = k)). Equivalent to the M^[X]/... batch arrival stream.
|
|
63
|
+
"""
|
|
64
|
+
p = np.asarray(batch_probs, dtype=float)
|
|
65
|
+
if abs(p.sum() - 1.0) > 1e-9 or np.any(p < 0):
|
|
66
|
+
raise ValueError("batch_probs must be a probability vector")
|
|
67
|
+
d = [np.array([[-rate]])]
|
|
68
|
+
for pk in p:
|
|
69
|
+
d.append(np.array([[rate * pk]]))
|
|
70
|
+
return BMAPParams(D=d)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bmap_from_map(params: MAPParams) -> BMAPParams:
|
|
74
|
+
"""A MAP as a BMAP with only size-1 batches (D[1] = D1)."""
|
|
75
|
+
return BMAPParams(D=[np.asarray(params.D0, dtype=float), np.asarray(params.D1, dtype=float)])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def bmap_arrival_rate(params: BMAPParams) -> float:
|
|
79
|
+
"""
|
|
80
|
+
Fundamental arrival rate of a BMAP: pi @ (sum_k k * D[k]) @ 1, where pi is
|
|
81
|
+
the stationary distribution of the phase generator sum_k D[k].
|
|
82
|
+
"""
|
|
83
|
+
mats = [np.asarray(x, dtype=float) for x in params.D]
|
|
84
|
+
gen = np.sum(mats, axis=0)
|
|
85
|
+
m = gen.shape[0]
|
|
86
|
+
a = np.vstack([gen.T, np.ones(m)])
|
|
87
|
+
b = np.zeros(m + 1)
|
|
88
|
+
b[-1] = 1.0
|
|
89
|
+
pi, *_ = np.linalg.lstsq(a, b, rcond=None)
|
|
90
|
+
batch_rate = np.sum([k * mats[k] for k in range(1, len(mats))], axis=0)
|
|
91
|
+
return float(pi @ batch_rate @ np.ones(m))
|
|
92
|
+
|
|
93
|
+
|
|
46
94
|
def _validate_ph(alpha: np.ndarray, T: np.ndarray) -> None:
|
|
47
95
|
if T.ndim != 2 or T.shape[0] != T.shape[1]:
|
|
48
96
|
raise ValueError("T must be a square matrix")
|
most_queue/sim/aoi.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discrete-event simulator for Age of Information (AoI).
|
|
3
|
+
|
|
4
|
+
Tracks the sawtooth age process Delta(t) = t - u(t), where u(t) is the
|
|
5
|
+
generation time of the freshest *delivered* update, and reports the time-average
|
|
6
|
+
AoI and the average peak AoI (PAoI). Supports FCFS, LCFS (non-preemptive) and
|
|
7
|
+
LCFS-preemptive (a fresh arrival preempts and discards the in-service update)
|
|
8
|
+
disciplines on one or more servers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from most_queue.random.utils.create import create_distribution
|
|
12
|
+
from most_queue.sim.base_core import BaseSimulationCore
|
|
13
|
+
from most_queue.structs import AoIResults
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AoISim(BaseSimulationCore):
|
|
17
|
+
"""
|
|
18
|
+
Age-of-Information simulator.
|
|
19
|
+
|
|
20
|
+
:param num_channels: number of servers.
|
|
21
|
+
:param discipline: "FCFS", "LCFS" (non-preemptive) or "LCFS-PR" (preemptive:
|
|
22
|
+
a new update preempts and discards the one in service).
|
|
23
|
+
:param seed: RNG seed for reproducible runs.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, num_channels: int = 1, discipline: str = "FCFS", seed: int | None = None):
|
|
27
|
+
super().__init__(seed=seed)
|
|
28
|
+
self.n = num_channels
|
|
29
|
+
self.discipline = discipline.upper()
|
|
30
|
+
if self.discipline == "LCFS-PR" and num_channels != 1:
|
|
31
|
+
raise ValueError("LCFS-PR is only supported for a single server")
|
|
32
|
+
self.source = None
|
|
33
|
+
self.server = None
|
|
34
|
+
self.is_sources_set = False
|
|
35
|
+
self.is_servers_set = False
|
|
36
|
+
|
|
37
|
+
def set_sources(self, params, kendall_notation: str = "M"):
|
|
38
|
+
"""Set the arrival (update generation) process."""
|
|
39
|
+
self.source = create_distribution(params, kendall_notation, self.generator)
|
|
40
|
+
self.is_sources_set = True
|
|
41
|
+
|
|
42
|
+
def set_servers(self, params, kendall_notation: str = "M"):
|
|
43
|
+
"""Set the service (update delivery) process."""
|
|
44
|
+
self.server = create_distribution(params, kendall_notation, self.generator)
|
|
45
|
+
self.is_servers_set = True
|
|
46
|
+
|
|
47
|
+
def run(self, num_updates: int, warmup_fraction: float = 0.05) -> AoIResults:
|
|
48
|
+
"""
|
|
49
|
+
Run until `num_updates` fresh deliveries have been counted after warm-up.
|
|
50
|
+
|
|
51
|
+
:param warmup_fraction: initial fraction of deliveries discarded before
|
|
52
|
+
age accounting begins (removes transient bias).
|
|
53
|
+
"""
|
|
54
|
+
if not (self.is_sources_set and self.is_servers_set):
|
|
55
|
+
raise RuntimeError("sources and servers must be set before run()")
|
|
56
|
+
|
|
57
|
+
INF = float("inf")
|
|
58
|
+
t = 0.0
|
|
59
|
+
next_arrival = self.source.generate()
|
|
60
|
+
server_done = [INF] * self.n # completion time per server
|
|
61
|
+
server_gen = [0.0] * self.n # generation time of the update in service
|
|
62
|
+
queue: list[float] = [] # generation times of waiting updates (arrival order)
|
|
63
|
+
|
|
64
|
+
freshest_gen = 0.0 # generation time of the freshest delivered update
|
|
65
|
+
area = 0.0 # integral of the age over the accounting window
|
|
66
|
+
peak_sum = 0.0
|
|
67
|
+
delivered = 0
|
|
68
|
+
counted = 0 # fresh deliveries counted after warm-up
|
|
69
|
+
accounting = False
|
|
70
|
+
acct_start_t = 0.0
|
|
71
|
+
last_t = 0.0
|
|
72
|
+
warmup = int(num_updates * warmup_fraction)
|
|
73
|
+
target = num_updates + warmup
|
|
74
|
+
|
|
75
|
+
def free_server():
|
|
76
|
+
for i in range(self.n):
|
|
77
|
+
if server_done[i] == INF:
|
|
78
|
+
return i
|
|
79
|
+
return -1
|
|
80
|
+
|
|
81
|
+
def start(gen_time):
|
|
82
|
+
i = free_server()
|
|
83
|
+
server_gen[i] = gen_time
|
|
84
|
+
server_done[i] = t + self.server.generate()
|
|
85
|
+
|
|
86
|
+
while delivered < target:
|
|
87
|
+
done_idx = min(range(self.n), key=lambda i: server_done[i])
|
|
88
|
+
next_done = server_done[done_idx]
|
|
89
|
+
|
|
90
|
+
if next_arrival <= next_done:
|
|
91
|
+
# ---- arrival of a new update ----
|
|
92
|
+
t = next_arrival
|
|
93
|
+
next_arrival = t + self.source.generate()
|
|
94
|
+
gen = t
|
|
95
|
+
if self.discipline == "LCFS-PR":
|
|
96
|
+
server_gen[0] = gen
|
|
97
|
+
server_done[0] = t + self.server.generate()
|
|
98
|
+
elif free_server() != -1:
|
|
99
|
+
start(gen)
|
|
100
|
+
else:
|
|
101
|
+
queue.append(gen)
|
|
102
|
+
else:
|
|
103
|
+
# ---- delivery of an update ----
|
|
104
|
+
t = next_done
|
|
105
|
+
gen = server_gen[done_idx]
|
|
106
|
+
server_done[done_idx] = INF
|
|
107
|
+
delivered += 1
|
|
108
|
+
is_fresh = gen > freshest_gen
|
|
109
|
+
|
|
110
|
+
if accounting and is_fresh:
|
|
111
|
+
# age grew as (t - freshest_gen) then resets: accumulate the
|
|
112
|
+
# trapezoid and record the peak (age just before the reset)
|
|
113
|
+
area += ((t - freshest_gen) ** 2 - (last_t - freshest_gen) ** 2) / 2.0
|
|
114
|
+
peak_sum += t - freshest_gen
|
|
115
|
+
last_t = t
|
|
116
|
+
counted += 1
|
|
117
|
+
if is_fresh:
|
|
118
|
+
freshest_gen = gen
|
|
119
|
+
|
|
120
|
+
if not accounting and delivered >= warmup:
|
|
121
|
+
accounting = True
|
|
122
|
+
acct_start_t = t
|
|
123
|
+
last_t = t
|
|
124
|
+
freshest_gen = gen
|
|
125
|
+
|
|
126
|
+
if queue:
|
|
127
|
+
nxt = queue.pop() if self.discipline == "LCFS" else queue.pop(0)
|
|
128
|
+
start(nxt)
|
|
129
|
+
|
|
130
|
+
# area is accumulated up to the last fresh delivery (last_t); the window
|
|
131
|
+
# is [acct_start_t, last_t]
|
|
132
|
+
window = max(last_t - acct_start_t, 1e-12)
|
|
133
|
+
return AoIResults(avg_aoi=area / window, peak_aoi=peak_sum / max(counted, 1))
|
most_queue/sim/base.py
CHANGED
|
@@ -26,15 +26,16 @@ class QsSim(BaseSimulationCore):
|
|
|
26
26
|
Base class for Queueing System Simulator
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
def __init__(self, num_of_channels, buffer=None, verbose=True, buffer_type="list"):
|
|
29
|
+
def __init__(self, num_of_channels, buffer=None, verbose=True, buffer_type="list", seed=None):
|
|
30
30
|
"""
|
|
31
31
|
Initialize the queueing system with GI/G/n/r or GI/G/n model.
|
|
32
32
|
:param num_of_channels: int : number of channels in the system
|
|
33
33
|
:param buffer: Optional(int, None) : maximum length of the queue, None if infinite
|
|
34
34
|
:param verbose: bool : whether to print detailed information during simulation
|
|
35
35
|
:param buffer_type: str : type of the buffer, "list" or "deque"
|
|
36
|
+
:param seed: Optional(int, None) : RNG seed for reproducible runs (None — non-deterministic)
|
|
36
37
|
"""
|
|
37
|
-
super().__init__()
|
|
38
|
+
super().__init__(seed=seed)
|
|
38
39
|
|
|
39
40
|
self.n = num_of_channels
|
|
40
41
|
self.buffer = buffer
|
most_queue/sim/base_core.py
CHANGED
|
@@ -13,10 +13,14 @@ class BaseSimulationCore:
|
|
|
13
13
|
Contains common attributes and methods shared across different simulator types.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
def __init__(self):
|
|
17
|
-
"""Initialize the base simulation core.
|
|
16
|
+
def __init__(self, seed: int | None = None):
|
|
17
|
+
"""Initialize the base simulation core.
|
|
18
|
+
|
|
19
|
+
:param seed: optional RNG seed for reproducible simulation runs
|
|
20
|
+
(None — non-deterministic, as before).
|
|
21
|
+
"""
|
|
18
22
|
self.ttek = 0 # current simulation time
|
|
19
|
-
self.generator = np.random.default_rng()
|
|
23
|
+
self.generator = np.random.default_rng(seed)
|
|
20
24
|
self.time_spent = 0
|
|
21
25
|
|
|
22
26
|
# Statistics for busy periods
|
most_queue/sim/bmap.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simulation of a BMAP/PH/1 queue: batch Markovian arrivals, phase-type service,
|
|
3
|
+
single FCFS server. Used to validate the analytical BmapPh1Calc.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from most_queue.random.map_ph import BMAPParams, PHDistribution, PHParams
|
|
11
|
+
from most_queue.structs import QueueResults
|
|
12
|
+
|
|
13
|
+
NUM_OF_MOMENTS = 4
|
|
14
|
+
P_NUM = 100
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BmapPh1Sim:
|
|
18
|
+
"""
|
|
19
|
+
Discrete-event simulator for BMAP/PH/1. The BMAP phase process jumps between
|
|
20
|
+
phases; a jump via D[k] (k >= 1) emits a batch of k jobs; jobs are served
|
|
21
|
+
FCFS with phase-type service.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, bmap: BMAPParams, ph: PHParams, seed: int | None = None):
|
|
25
|
+
self.generator = np.random.default_rng(seed)
|
|
26
|
+
self.d = [np.asarray(x, dtype=float) for x in bmap.D]
|
|
27
|
+
self.bmap = bmap
|
|
28
|
+
self.ph = ph
|
|
29
|
+
self.m = self.d[0].shape[0]
|
|
30
|
+
self._build_jump_table()
|
|
31
|
+
self.ttek = 0.0
|
|
32
|
+
self.served = 0
|
|
33
|
+
self.busy_time = 0.0
|
|
34
|
+
self.v = [0.0] * NUM_OF_MOMENTS
|
|
35
|
+
self.w = [0.0] * NUM_OF_MOMENTS
|
|
36
|
+
self.p_time = [0.0] * P_NUM
|
|
37
|
+
|
|
38
|
+
def _build_jump_table(self):
|
|
39
|
+
"""Per phase: list of (rate, target_phase, batch_size) and total out-rate.
|
|
40
|
+
|
|
41
|
+
The total event rate out of phase i is -D0[i][i]: the D0 diagonal
|
|
42
|
+
absorbs every outgoing rate, silent phase changes and batch arrivals
|
|
43
|
+
alike.
|
|
44
|
+
"""
|
|
45
|
+
self.out_rate = -np.diag(self.d[0])
|
|
46
|
+
self.jumps = [[] for _ in range(self.m)]
|
|
47
|
+
for i in range(self.m):
|
|
48
|
+
for j in range(self.m):
|
|
49
|
+
if j != i and self.d[0][i, j] > 0:
|
|
50
|
+
self.jumps[i].append((self.d[0][i, j], j, 0)) # silent D0 transition
|
|
51
|
+
for k in range(1, len(self.d)):
|
|
52
|
+
for j in range(self.m):
|
|
53
|
+
if self.d[k][i, j] > 0:
|
|
54
|
+
self.jumps[i].append((self.d[k][i, j], j, k)) # batch of k
|
|
55
|
+
|
|
56
|
+
def _next_batch(self, phase):
|
|
57
|
+
"""Sample time to the next BMAP transition and its (target phase, batch size)."""
|
|
58
|
+
rate = self.out_rate[phase]
|
|
59
|
+
dt = self.generator.exponential(1.0 / rate)
|
|
60
|
+
opts = self.jumps[phase]
|
|
61
|
+
probs = np.array([o[0] for o in opts]) / rate
|
|
62
|
+
idx = int(self.generator.choice(len(opts), p=probs))
|
|
63
|
+
_, target, batch = opts[idx]
|
|
64
|
+
return dt, target, batch
|
|
65
|
+
|
|
66
|
+
def _advance(self, dt, in_sys):
|
|
67
|
+
if in_sys < P_NUM:
|
|
68
|
+
self.p_time[in_sys] += dt
|
|
69
|
+
if in_sys > 0:
|
|
70
|
+
self.busy_time += dt
|
|
71
|
+
|
|
72
|
+
def run(self, total_served: int) -> QueueResults:
|
|
73
|
+
"""Run until total_served jobs have departed."""
|
|
74
|
+
start_time = time.process_time()
|
|
75
|
+
rng = self.generator
|
|
76
|
+
phase = int(rng.choice(self.m))
|
|
77
|
+
queue = [] # arrival times of waiting jobs
|
|
78
|
+
service_end = None
|
|
79
|
+
current_arr = None
|
|
80
|
+
dt, next_phase, batch = self._next_batch(phase)
|
|
81
|
+
next_bmap = self.ttek + dt
|
|
82
|
+
|
|
83
|
+
while self.served < total_served:
|
|
84
|
+
in_sys = len(queue) + (1 if service_end is not None else 0)
|
|
85
|
+
if service_end is not None and service_end <= next_bmap:
|
|
86
|
+
# service completion
|
|
87
|
+
self._advance(service_end - self.ttek, in_sys)
|
|
88
|
+
self.ttek = service_end
|
|
89
|
+
self._register(self.ttek - current_arr)
|
|
90
|
+
if queue:
|
|
91
|
+
current_arr = queue.pop(0)
|
|
92
|
+
service_end = self.ttek + PHDistribution(self.ph, generator=rng).generate()
|
|
93
|
+
else:
|
|
94
|
+
service_end = None
|
|
95
|
+
current_arr = None
|
|
96
|
+
else:
|
|
97
|
+
# BMAP transition (possibly a batch)
|
|
98
|
+
self._advance(next_bmap - self.ttek, in_sys)
|
|
99
|
+
self.ttek = next_bmap
|
|
100
|
+
for _ in range(batch):
|
|
101
|
+
if service_end is None:
|
|
102
|
+
current_arr = self.ttek
|
|
103
|
+
service_end = self.ttek + PHDistribution(self.ph, generator=rng).generate()
|
|
104
|
+
else:
|
|
105
|
+
queue.append(self.ttek)
|
|
106
|
+
phase = next_phase
|
|
107
|
+
dt, next_phase, batch = self._next_batch(phase)
|
|
108
|
+
next_bmap = self.ttek + dt
|
|
109
|
+
|
|
110
|
+
return self._results(start_time)
|
|
111
|
+
|
|
112
|
+
def _register(self, sojourn):
|
|
113
|
+
# service = sojourn - waiting; but we only kept arrival time, so recover
|
|
114
|
+
# waiting from the job's own service is not tracked here — use sojourn and
|
|
115
|
+
# a separate service sample is not needed: W = V - S is done at aggregate
|
|
116
|
+
# level via Little in the calculator. Here we store sojourn; waiting is
|
|
117
|
+
# sojourn minus the realised service, which we did not keep, so store
|
|
118
|
+
# sojourn only and derive mean waiting as mean sojourn - mean service.
|
|
119
|
+
self.served += 1
|
|
120
|
+
for k in range(NUM_OF_MOMENTS):
|
|
121
|
+
self.v[k] += sojourn ** (k + 1)
|
|
122
|
+
|
|
123
|
+
def _results(self, start_time):
|
|
124
|
+
v = [m / self.served for m in self.v]
|
|
125
|
+
t_mat = np.asarray(self.ph.T)
|
|
126
|
+
b1 = float(np.asarray(self.ph.alpha) @ np.linalg.inv(-t_mat) @ np.ones(t_mat.shape[0]))
|
|
127
|
+
w = [v[0] - b1] # mean waiting = mean sojourn - mean service
|
|
128
|
+
p = [float(t / self.ttek) for t in self.p_time]
|
|
129
|
+
utilization = float(self.busy_time / self.ttek)
|
|
130
|
+
return QueueResults(v=v, w=w, p=p, utilization=utilization, duration=time.process_time() - start_time)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discrete-event simulator for the M/G^[a,b]/1 bulk-service queue.
|
|
3
|
+
|
|
4
|
+
The server waits until at least `a` customers are queued, then serves a batch of
|
|
5
|
+
up to `b` of them together; the whole batch leaves after one (exponential or
|
|
6
|
+
general) batch-service time, which may depend on the batch size.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from most_queue.random.utils.create import create_distribution
|
|
10
|
+
from most_queue.sim.base_core import BaseSimulationCore
|
|
11
|
+
from most_queue.structs import QueueResults
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BulkServiceSim(BaseSimulationCore):
|
|
15
|
+
"""
|
|
16
|
+
Bulk-service single-server simulator.
|
|
17
|
+
|
|
18
|
+
:param a: minimum batch size to start service.
|
|
19
|
+
:param b: maximum batch size.
|
|
20
|
+
:param seed: RNG seed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, a: int = 1, b: int = 1, seed: int | None = None):
|
|
24
|
+
super().__init__(seed=seed)
|
|
25
|
+
self.a = a
|
|
26
|
+
self.b = b
|
|
27
|
+
self.source = None
|
|
28
|
+
self.mu_fn = None
|
|
29
|
+
self.batch_kendall = "M"
|
|
30
|
+
self.is_sources_set = False
|
|
31
|
+
self.is_servers_set = False
|
|
32
|
+
|
|
33
|
+
def set_sources(self, params, kendall_notation: str = "M"):
|
|
34
|
+
"""Set the arrival process."""
|
|
35
|
+
self.source = create_distribution(params, kendall_notation, self.generator)
|
|
36
|
+
self.is_sources_set = True
|
|
37
|
+
|
|
38
|
+
def set_servers(self, mu):
|
|
39
|
+
"""
|
|
40
|
+
:param mu: batch-service rate — scalar (Exp(mu)) or callable mu(batch_size).
|
|
41
|
+
"""
|
|
42
|
+
if callable(mu):
|
|
43
|
+
self.mu_fn = mu
|
|
44
|
+
else:
|
|
45
|
+
self.mu_fn = lambda _i, _mu=float(mu): _mu
|
|
46
|
+
self.is_servers_set = True
|
|
47
|
+
|
|
48
|
+
def run(self, total_served: int, warmup_fraction: float = 0.05) -> QueueResults:
|
|
49
|
+
"""Run until `total_served` customers have departed; return mean w and v."""
|
|
50
|
+
if not (self.is_sources_set and self.is_servers_set):
|
|
51
|
+
raise RuntimeError("sources and servers must be set before run()")
|
|
52
|
+
|
|
53
|
+
INF = float("inf")
|
|
54
|
+
t = 0.0
|
|
55
|
+
next_arrival = self.source.generate()
|
|
56
|
+
queue: list[float] = [] # arrival times of waiting customers (FCFS)
|
|
57
|
+
server_busy = False
|
|
58
|
+
server_done = INF
|
|
59
|
+
batch: list[float] = [] # arrival times of the in-service batch
|
|
60
|
+
batch_start = 0.0
|
|
61
|
+
|
|
62
|
+
soj_sum = 0.0
|
|
63
|
+
wait_sum = 0.0
|
|
64
|
+
served = 0
|
|
65
|
+
warmup = int(total_served * warmup_fraction)
|
|
66
|
+
|
|
67
|
+
def maybe_start():
|
|
68
|
+
nonlocal server_busy, server_done, batch, batch_start
|
|
69
|
+
if not server_busy and len(queue) >= self.a:
|
|
70
|
+
take = min(self.b, len(queue))
|
|
71
|
+
batch = [queue.pop(0) for _ in range(take)]
|
|
72
|
+
server_busy = True
|
|
73
|
+
batch_start = t
|
|
74
|
+
server_done = t + self.generator.exponential(1.0 / self.mu_fn(take))
|
|
75
|
+
|
|
76
|
+
while served < total_served + warmup:
|
|
77
|
+
if next_arrival <= server_done:
|
|
78
|
+
t = next_arrival
|
|
79
|
+
queue.append(t)
|
|
80
|
+
next_arrival = t + self.source.generate()
|
|
81
|
+
maybe_start()
|
|
82
|
+
else:
|
|
83
|
+
t = server_done
|
|
84
|
+
for arr in batch:
|
|
85
|
+
if served >= warmup:
|
|
86
|
+
soj_sum += t - arr
|
|
87
|
+
wait_sum += batch_start - arr
|
|
88
|
+
served += 1
|
|
89
|
+
server_busy = False
|
|
90
|
+
server_done = INF
|
|
91
|
+
batch = []
|
|
92
|
+
maybe_start()
|
|
93
|
+
|
|
94
|
+
n = max(served - warmup, 1)
|
|
95
|
+
return QueueResults(v=[soj_sum / n, 0, 0, 0], w=[wait_sum / n, 0, 0, 0])
|