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.
Files changed (45) hide show
  1. most_queue/__init__.py +7 -1
  2. most_queue/random/map_fit.py +137 -0
  3. most_queue/random/map_ph.py +48 -0
  4. most_queue/sim/aoi.py +133 -0
  5. most_queue/sim/base.py +3 -2
  6. most_queue/sim/base_core.py +7 -3
  7. most_queue/sim/bmap.py +130 -0
  8. most_queue/sim/bulk_service.py +95 -0
  9. most_queue/sim/msj.py +117 -0
  10. most_queue/structs.py +9 -0
  11. most_queue/theory/aoi/__init__.py +5 -0
  12. most_queue/theory/aoi/calc.py +114 -0
  13. most_queue/theory/batch/__init__.py +7 -0
  14. most_queue/theory/batch/bulk_service.py +138 -0
  15. most_queue/theory/batch/mm1.py +3 -2
  16. most_queue/theory/closed/__init__.py +7 -0
  17. most_queue/theory/fifo/__init__.py +12 -0
  18. most_queue/theory/fifo/gi_m_1.py +10 -13
  19. most_queue/theory/fifo/mgn_takahasi.py +33 -0
  20. most_queue/theory/fork_join/__init__.py +11 -0
  21. most_queue/theory/impatience/__init__.py +11 -0
  22. most_queue/theory/matrix/__init__.py +16 -1
  23. most_queue/theory/matrix/bmap_m1.py +163 -0
  24. most_queue/theory/matrix/bmap_ph1.py +180 -0
  25. most_queue/theory/matrix/map_mmc.py +190 -0
  26. most_queue/theory/matrix/map_phc.py +278 -0
  27. most_queue/theory/msj/__init__.py +7 -0
  28. most_queue/theory/msj/exact.py +123 -0
  29. most_queue/theory/msj/saturated.py +138 -0
  30. most_queue/theory/negative/__init__.py +15 -0
  31. most_queue/theory/networks/__init__.py +7 -2
  32. most_queue/theory/priority/__init__.py +7 -1
  33. most_queue/theory/priority/preemptive/mm2_3cls_busy_approx.py +9 -12
  34. most_queue/theory/priority/preemptive/mmk_prty_exact.py +315 -0
  35. most_queue/theory/priority/preemptive/mmn_2cls_pr_busy_approx.py +9 -12
  36. most_queue/theory/priority/preemptive/mph_ph_k_2class.py +227 -0
  37. most_queue/theory/priority/preemptive/rdr_a.py +185 -0
  38. most_queue/theory/srpt/__init__.py +3 -0
  39. most_queue/theory/srpt/degradation.py +71 -0
  40. most_queue/theory/utils/passage_time.py +48 -37
  41. most_queue/theory/vacations/__init__.py +20 -0
  42. {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/METADATA +27 -12
  43. {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/RECORD +45 -25
  44. {most_queue-2.8.dist-info → most_queue-2.9.dist-info}/WHEEL +0 -0
  45. {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
- __version__ = "2.7"
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)
@@ -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
@@ -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])