most-queue 2.7__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 (109) hide show
  1. most_queue/__init__.py +7 -1
  2. most_queue/io/plots.py +41 -5
  3. most_queue/misc/calc_v_pls.py +1 -1
  4. most_queue/random/distributions.py +62 -0
  5. most_queue/random/map_fit.py +137 -0
  6. most_queue/random/map_ph.py +330 -0
  7. most_queue/random/utils/create.py +9 -0
  8. most_queue/sim/__init__.py +2 -1
  9. most_queue/sim/aoi.py +133 -0
  10. most_queue/sim/base.py +20 -2
  11. most_queue/sim/base_core.py +11 -9
  12. most_queue/sim/batch.py +99 -95
  13. most_queue/sim/bmap.py +130 -0
  14. most_queue/sim/bulk_service.py +95 -0
  15. most_queue/sim/finite_source.py +246 -242
  16. most_queue/sim/flow_sum.py +240 -240
  17. most_queue/sim/fork_join.py +264 -260
  18. most_queue/sim/impatient.py +177 -181
  19. most_queue/sim/msj.py +117 -0
  20. most_queue/sim/negative.py +190 -35
  21. most_queue/sim/networks/negative_network.py +3 -4
  22. most_queue/sim/priority.py +4 -9
  23. most_queue/sim/retrial.py +79 -0
  24. most_queue/sim/single_server_disciplines.py +225 -0
  25. most_queue/sim/size_based.py +248 -0
  26. most_queue/sim/unreliable.py +95 -0
  27. most_queue/sim/utils/predictor.py +44 -0
  28. most_queue/sim/utils/qs_queue.py +12 -0
  29. most_queue/sim/utils/queue_priority_size.py +112 -0
  30. most_queue/sim/utils/servers.py +45 -1
  31. most_queue/sim/utils/stats_update.py +2 -2
  32. most_queue/sim/utils/tasks.py +21 -0
  33. most_queue/sim/vacations.py +364 -316
  34. most_queue/structs.py +41 -1
  35. most_queue/theory/aoi/__init__.py +5 -0
  36. most_queue/theory/aoi/calc.py +114 -0
  37. most_queue/theory/batch/__init__.py +7 -0
  38. most_queue/theory/batch/bulk_service.py +138 -0
  39. most_queue/theory/batch/mm1.py +3 -2
  40. most_queue/theory/closed/__init__.py +7 -0
  41. most_queue/theory/fifo/__init__.py +12 -0
  42. most_queue/theory/fifo/erlang.py +249 -0
  43. most_queue/theory/fifo/gi_g_approx.py +223 -0
  44. most_queue/theory/fifo/gi_m_1.py +10 -13
  45. most_queue/theory/fifo/gi_m_n.py +257 -258
  46. most_queue/theory/fifo/gmc_takahasi.py +0 -1
  47. most_queue/theory/fifo/hkhk_takahasi.py +475 -486
  48. most_queue/theory/fifo/m_g_inf.py +114 -0
  49. most_queue/theory/fifo/mg1_lcfs_pr.py +106 -0
  50. most_queue/theory/fifo/mg1_ps.py +119 -0
  51. most_queue/theory/fifo/mgn_takahasi.py +34 -1
  52. most_queue/theory/fork_join/__init__.py +11 -0
  53. most_queue/theory/impatience/__init__.py +11 -0
  54. most_queue/theory/impatience/mmn.py +147 -0
  55. most_queue/theory/matrix/__init__.py +22 -0
  56. most_queue/theory/matrix/bmap_m1.py +163 -0
  57. most_queue/theory/matrix/bmap_ph1.py +180 -0
  58. most_queue/theory/matrix/map_mmc.py +190 -0
  59. most_queue/theory/matrix/map_ph1.py +190 -0
  60. most_queue/theory/matrix/map_phc.py +278 -0
  61. most_queue/theory/matrix/qbd.py +147 -0
  62. most_queue/theory/msj/__init__.py +7 -0
  63. most_queue/theory/msj/exact.py +123 -0
  64. most_queue/theory/msj/saturated.py +138 -0
  65. most_queue/theory/negative/__init__.py +15 -0
  66. most_queue/theory/negative/mg1_disasters.py +112 -18
  67. most_queue/theory/negative/mgn_disaster.py +389 -90
  68. most_queue/theory/negative/mgn_rcs.py +544 -43
  69. most_queue/theory/negative/structs.py +0 -1
  70. most_queue/theory/networks/__init__.py +7 -2
  71. most_queue/theory/priority/__init__.py +7 -1
  72. most_queue/theory/priority/preemptive/mm2_3cls_busy_approx.py +9 -12
  73. most_queue/theory/priority/preemptive/mmk_prty_exact.py +315 -0
  74. most_queue/theory/priority/preemptive/mmn_2cls_pr_busy_approx.py +9 -12
  75. most_queue/theory/priority/preemptive/mph_ph_k_2class.py +227 -0
  76. most_queue/theory/priority/preemptive/rdr_a.py +185 -0
  77. most_queue/theory/retrial/__init__.py +8 -0
  78. most_queue/theory/retrial/mg1.py +102 -0
  79. most_queue/theory/retrial/mm1.py +160 -0
  80. most_queue/theory/srpt/__init__.py +25 -0
  81. most_queue/theory/srpt/_base.py +136 -0
  82. most_queue/theory/srpt/degradation.py +71 -0
  83. most_queue/theory/srpt/mg1_fb.py +68 -0
  84. most_queue/theory/srpt/mg1_psjf.py +53 -0
  85. most_queue/theory/srpt/mg1_sjf.py +46 -0
  86. most_queue/theory/srpt/mg1_spjf.py +96 -0
  87. most_queue/theory/srpt/mg1_srpt.py +61 -0
  88. most_queue/theory/srpt/utils/__init__.py +12 -0
  89. most_queue/theory/srpt/utils/load_below.py +123 -0
  90. most_queue/theory/srpt/utils/predictor.py +153 -0
  91. most_queue/theory/utils/busy_periods.py +1 -2
  92. most_queue/theory/utils/conditional.py +1 -1
  93. most_queue/theory/utils/derivative.py +93 -0
  94. most_queue/theory/utils/passage_time.py +52 -39
  95. most_queue/theory/utils/restarts.py +241 -0
  96. most_queue/theory/utils/timing.py +2 -2
  97. most_queue/theory/vacations/__init__.py +20 -0
  98. most_queue/theory/vacations/m_h2_h2warm.py +1 -1
  99. most_queue/theory/vacations/mg1_unreliable.py +159 -0
  100. most_queue/theory/vacations/mg1_vacations.py +228 -0
  101. most_queue/theory/vacations/mg1_warm_calc.py +1 -1
  102. most_queue/theory/vacations/mgn_with_h2_delay_cold_warm.py +1 -1
  103. most_queue/theory/vacations/mmn_with_h2_cold_and_h2_warmup.py +1 -1
  104. most_queue-2.9.dist-info/METADATA +200 -0
  105. most_queue-2.9.dist-info/RECORD +174 -0
  106. {most_queue-2.7.dist-info → most_queue-2.9.dist-info}/WHEEL +1 -1
  107. most_queue-2.7.dist-info/METADATA +0 -274
  108. most_queue-2.7.dist-info/RECORD +0 -121
  109. {most_queue-2.7.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.05"
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",
most_queue/io/plots.py CHANGED
@@ -17,6 +17,7 @@ class DependsType(Enum):
17
17
  UTILIZATION_FACTOR = 1
18
18
  CHANNELS_NUMBER = 2
19
19
  COEFFICIENT_OF_VARIATION = 3
20
+ NEGATIVE_RATE = 4
20
21
 
21
22
 
22
23
  class Plotter:
@@ -43,12 +44,26 @@ class Plotter:
43
44
  ax.set_ylabel(ylabel)
44
45
  if self.depends_on == DependsType.UTILIZATION_FACTOR:
45
46
  ax.set_xlabel(r"$\rho$")
47
+ elif self.depends_on == DependsType.NEGATIVE_RATE:
48
+ ax.set_xlabel(r"$\delta$")
46
49
  elif self.depends_on == DependsType.COEFFICIENT_OF_VARIATION:
47
50
  ax.set_xlabel(r"$\nu$")
48
51
  else:
49
52
  ax.set_xlabel("n")
50
53
  plt.xticks(range(1, len(self.xs) + 1))
51
54
 
55
+ @staticmethod
56
+ def _series_style():
57
+ """
58
+ Centralized plot style for series used in figures.
59
+ Keeps line styles (solid/dashed) but uses distinct colors for readability in print/PDF.
60
+ """
61
+ return {
62
+ "sim": {"color": "tab:orange", "linestyle": "--", "linewidth": 2.0},
63
+ "calc": {"color": "tab:blue", "linestyle": "-", "linewidth": 2.0},
64
+ "err": {"color": "tab:red", "linestyle": "-", "linewidth": 2.0},
65
+ }
66
+
52
67
  def plot_errors(self, save_path: str = None):
53
68
  """
54
69
  Plots the simulation and calculated moments for a given list of x values.
@@ -56,7 +71,8 @@ class Plotter:
56
71
  _fig, ax = plt.subplots()
57
72
  errors = [100 * (w_sim - w_tt) / w_tt for w_sim, w_tt in zip(self.sim_results, self.calc_results)]
58
73
 
59
- ax.plot(self.xs, errors, color="black")
74
+ style = self._series_style()
75
+ ax.plot(self.xs, errors, **style["err"])
60
76
  self._configure_plot(ax, r"$\varepsilon$, %")
61
77
 
62
78
  if save_path is not None:
@@ -71,8 +87,9 @@ class Plotter:
71
87
  Plots the simulation and calculated queue waiting times.
72
88
  """
73
89
  _fig, ax = plt.subplots()
74
- ax.plot(self.xs, self.sim_results, label="Sim", color="black", linestyle="--")
75
- ax.plot(self.xs, self.calc_results, label="Calc", color="black")
90
+ style = self._series_style()
91
+ ax.plot(self.xs, self.sim_results, label="ИМ", **style["sim"])
92
+ ax.plot(self.xs, self.calc_results, label="Числ", **style["calc"])
76
93
  ax.legend()
77
94
  self._configure_plot(ax, r"$\omega_{1}$")
78
95
 
@@ -88,8 +105,9 @@ class Plotter:
88
105
  Plots the simulation and calculated sojourn times.
89
106
  """
90
107
  _fig, ax = plt.subplots()
91
- ax.plot(self.xs, self.sim_results, label="Sim", color="black", linestyle="--")
92
- ax.plot(self.xs, self.calc_results, label="Calc", color="black")
108
+ style = self._series_style()
109
+ ax.plot(self.xs, self.sim_results, label="ИМ", **style["sim"])
110
+ ax.plot(self.xs, self.calc_results, label="Числ", **style["calc"])
93
111
  ax.legend()
94
112
  self._configure_plot(ax, r"$\upsilon_{1}$")
95
113
 
@@ -99,3 +117,21 @@ class Plotter:
99
117
  plt.show()
100
118
 
101
119
  plt.close(_fig)
120
+
121
+ def plot_series(self, ylabel: str, save_path: str = None):
122
+ """
123
+ Generic plot: two series (sim vs calc) with a custom y-label.
124
+ """
125
+ _fig, ax = plt.subplots()
126
+ style = self._series_style()
127
+ ax.plot(self.xs, self.sim_results, label="ИМ", **style["sim"])
128
+ ax.plot(self.xs, self.calc_results, label="Числ", **style["calc"])
129
+ ax.legend()
130
+ self._configure_plot(ax, ylabel)
131
+
132
+ if save_path is not None:
133
+ plt.savefig(save_path, dpi=300)
134
+ else:
135
+ plt.show()
136
+
137
+ plt.close(_fig)
@@ -7,11 +7,11 @@ import math
7
7
  from functools import lru_cache
8
8
 
9
9
  import numpy as np
10
- from scipy.misc import derivative
11
10
 
12
11
  from most_queue.random.distributions import H2Distribution, H2Params
13
12
  from most_queue.theory.fifo.mgn_takahasi import MGnCalc, TakahashiTakamiParams
14
13
  from most_queue.theory.utils.conv import conv_moments
14
+ from most_queue.theory.utils.derivative import derivative
15
15
  from most_queue.theory.utils.transforms import lst_exp
16
16
 
17
17
 
@@ -251,6 +251,24 @@ class NormalDistribution(Distribution):
251
251
  std_dev = cv * mean
252
252
  return GaussianParams(mean, std_dev)
253
253
 
254
+ @staticmethod
255
+ def get_pdf(params: GaussianParams, t: float) -> float:
256
+ """
257
+ PDF of the Gaussian distribution. Returns 0 for t < 0 (truncated at zero).
258
+ """
259
+ if t < 0:
260
+ return 0.0
261
+ return float(stats.norm.pdf(t, loc=params.mean, scale=params.std_dev))
262
+
263
+ @staticmethod
264
+ def get_cdf(params: GaussianParams, t: float) -> float:
265
+ """
266
+ CDF of the Gaussian distribution. Returns 0 for t < 0 (truncated at zero).
267
+ """
268
+ if t < 0:
269
+ return 0.0
270
+ return float(stats.norm.cdf(t, loc=params.mean, scale=params.std_dev))
271
+
254
272
 
255
273
  class UniformDistribution(Distribution):
256
274
  """
@@ -637,6 +655,26 @@ class DeterministicDistribution(Distribution):
637
655
  b = f1
638
656
  return b
639
657
 
658
+ @staticmethod
659
+ def get_pdf(_params, _t: float) -> float:
660
+ """
661
+ Deterministic distribution has no density function.
662
+
663
+ Raises ``ValueError`` unconditionally — size-based SRPT/SJF/PSJF
664
+ formulas require a continuous service distribution.
665
+ """
666
+ raise ValueError(
667
+ "Deterministic distribution ('D') has no density function; "
668
+ "SRPT/SJF/PSJF formulas require a continuous service distribution."
669
+ )
670
+
671
+ @staticmethod
672
+ def get_cdf(params, t: float) -> float:
673
+ """
674
+ CDF of D(b): Heaviside step at b. *params* is the deterministic value b (float).
675
+ """
676
+ return 0.0 if t < float(params) else 1.0
677
+
640
678
 
641
679
  class ParetoDistribution(Distribution):
642
680
  """
@@ -802,6 +840,16 @@ class ErlangDistribution(Distribution):
802
840
  """
803
841
  return 1.0 - ErlangDistribution.get_cdf(params, t)
804
842
 
843
+ @staticmethod
844
+ def get_pdf(params: ErlangParams, t: float) -> float:
845
+ """
846
+ PDF of the Erlang(r, mu) distribution: f(t) = mu^r * t^(r-1) * exp(-mu*t) / (r-1)!
847
+ """
848
+ if t < 0:
849
+ return 0.0
850
+ r, mu = params.r, params.mu
851
+ return (mu**r) * (t ** (r - 1)) * math.exp(-mu * t) / math.factorial(r - 1)
852
+
805
853
  @staticmethod
806
854
  def calc_theory_moments(params: ErlangParams, num: int = 3) -> list[float]:
807
855
  """
@@ -891,6 +939,20 @@ class ExpDistribution(Distribution):
891
939
  """
892
940
  return 1 / f1
893
941
 
942
+ @staticmethod
943
+ def get_pdf(params: float, t: float) -> float:
944
+ """
945
+ PDF of the exponential distribution with rate *params* (= mu = 1/mean).
946
+ """
947
+ return ErlangDistribution.get_pdf(ErlangParams(r=1, mu=params), t)
948
+
949
+ @staticmethod
950
+ def get_cdf(params: float, t: float) -> float:
951
+ """
952
+ CDF of the exponential distribution with rate *params* (= mu = 1/mean).
953
+ """
954
+ return ErlangDistribution.get_cdf(ErlangParams(r=1, mu=params), t)
955
+
894
956
 
895
957
  class GammaDistribution(Distribution):
896
958
  """
@@ -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)
@@ -0,0 +1,330 @@
1
+ """
2
+ Phase-type (PH) distributions and Markovian Arrival Processes (MAP).
3
+
4
+ A PH distribution is the time to absorption of a CTMC with initial vector
5
+ alpha and sub-generator T (absorption rates t0 = -T @ 1). A MAP is defined by
6
+ matrices (D0, D1): D0 holds phase transitions without arrivals, D1 —
7
+ transitions that generate an arrival; D0 + D1 is the generator of the phase
8
+ process. MAPs model correlated (bursty) traffic; a renewal PH source and the
9
+ MMPP are special cases.
10
+
11
+ References:
12
+ Neuts M.F. Matrix-Geometric Solutions in Stochastic Models. Johns Hopkins
13
+ University Press, 1981.
14
+ Latouche G., Ramaswami V. Introduction to Matrix Analytic Methods in
15
+ Stochastic Modeling. SIAM, 1999. doi:10.1137/1.9780898719734.
16
+ Fischer W., Meier-Hellstern K. The Markov-modulated Poisson process (MMPP)
17
+ cookbook. Performance Evaluation, 18(2), 1993.
18
+ doi:10.1016/0166-5316(93)90035-s.
19
+ """
20
+
21
+ import math
22
+ from dataclasses import dataclass
23
+
24
+ import numpy as np
25
+ from scipy.linalg import expm
26
+
27
+ from most_queue.random.utils.params import Cox2Params, ErlangParams, H2Params
28
+
29
+
30
+ @dataclass
31
+ class PHParams:
32
+ """Phase-type distribution parameters: initial vector and sub-generator."""
33
+
34
+ alpha: np.ndarray # initial probability row vector, shape (m,)
35
+ T: np.ndarray # sub-generator matrix, shape (m, m)
36
+
37
+
38
+ @dataclass
39
+ class MAPParams:
40
+ """Markovian Arrival Process parameters."""
41
+
42
+ D0: np.ndarray # transitions without arrivals (sub-generator), shape (m, m)
43
+ D1: np.ndarray # transitions generating an arrival, shape (m, m)
44
+
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
+
94
+ def _validate_ph(alpha: np.ndarray, T: np.ndarray) -> None:
95
+ if T.ndim != 2 or T.shape[0] != T.shape[1]:
96
+ raise ValueError("T must be a square matrix")
97
+ if alpha.shape != (T.shape[0],):
98
+ raise ValueError(f"alpha shape {alpha.shape} does not match T {T.shape}")
99
+ if not math.isclose(float(np.sum(alpha)), 1.0, rel_tol=1e-9):
100
+ raise ValueError("alpha must sum to 1 (mass at zero is not supported)")
101
+ if np.any(np.diag(T) >= 0):
102
+ raise ValueError("diagonal of T must be negative")
103
+ exit_rates = -T @ np.ones(T.shape[0])
104
+ if np.any(exit_rates < -1e-12):
105
+ raise ValueError("row sums of T must be <= 0 (t0 = -T@1 must be non-negative)")
106
+
107
+
108
+ class PHDistribution:
109
+ """
110
+ Phase-type distribution PH(alpha, T).
111
+
112
+ Follows the library's distribution interface: ``generate()`` draws a
113
+ sample by simulating the absorbing CTMC, static ``calc_theory_moments``
114
+ returns raw moments m_k = k! * alpha @ (-T)^{-k} @ 1.
115
+ """
116
+
117
+ def __init__(self, params: PHParams, generator=None):
118
+ self.params = params
119
+ self.alpha = np.asarray(params.alpha, dtype=float)
120
+ self.T = np.asarray(params.T, dtype=float)
121
+ _validate_ph(self.alpha, self.T)
122
+ self.t0 = -self.T @ np.ones(self.T.shape[0])
123
+ self.type = "PH"
124
+ self.generator = generator if generator is not None else np.random.default_rng()
125
+
126
+ # ------------------------------------------------------------- theory
127
+ @staticmethod
128
+ def calc_theory_moments(params: PHParams, num: int = 4) -> list[float]:
129
+ """
130
+ Raw moments of PH(alpha, T): m_k = k! * alpha @ (-T)^{-k} @ 1.
131
+ """
132
+ alpha = np.asarray(params.alpha, dtype=float)
133
+ T = np.asarray(params.T, dtype=float)
134
+ m_inv = np.linalg.inv(-T)
135
+ ones = np.ones(T.shape[0])
136
+ moments, vec = [], alpha.copy()
137
+ for k in range(1, num + 1):
138
+ vec = vec @ m_inv
139
+ moments.append(math.factorial(k) * float(vec @ ones))
140
+ return moments
141
+
142
+ @staticmethod
143
+ def get_pdf(params: PHParams, x: float) -> float:
144
+ """Probability density f(x) = alpha @ expm(T x) @ t0."""
145
+ alpha = np.asarray(params.alpha, dtype=float)
146
+ T = np.asarray(params.T, dtype=float)
147
+ t0 = -T @ np.ones(T.shape[0])
148
+ return float(alpha @ expm(T * x) @ t0) if x >= 0 else 0.0
149
+
150
+ @staticmethod
151
+ def get_cdf(params: PHParams, x: float) -> float:
152
+ """Cumulative distribution F(x) = 1 - alpha @ expm(T x) @ 1."""
153
+ alpha = np.asarray(params.alpha, dtype=float)
154
+ T = np.asarray(params.T, dtype=float)
155
+ return 1.0 - float(alpha @ expm(T * x) @ np.ones(T.shape[0])) if x >= 0 else 0.0
156
+
157
+ # ---------------------------------------------------------- converters
158
+ @staticmethod
159
+ def from_exp(mu: float) -> PHParams:
160
+ """Exponential(mu) as a single-phase PH."""
161
+ return PHParams(alpha=np.array([1.0]), T=np.array([[-mu]]))
162
+
163
+ @staticmethod
164
+ def from_erlang(params: ErlangParams) -> PHParams:
165
+ """Erlang-r as a chain of r exponential phases."""
166
+ r, mu = params.r, params.mu
167
+ T = -mu * np.eye(r) + mu * np.eye(r, k=1)
168
+ alpha = np.zeros(r)
169
+ alpha[0] = 1.0
170
+ return PHParams(alpha=alpha, T=T)
171
+
172
+ @staticmethod
173
+ def from_h2(params: H2Params) -> PHParams:
174
+ """Hyperexponential H2 (real parameters only) as a two-phase PH."""
175
+ p1, mu1, mu2 = float(params.p1.real), float(params.mu1.real), float(params.mu2.real)
176
+ return PHParams(alpha=np.array([p1, 1.0 - p1]), T=np.array([[-mu1, 0.0], [0.0, -mu2]]))
177
+
178
+ @staticmethod
179
+ def from_cox(params: Cox2Params) -> PHParams:
180
+ """Second-order Coxian as a two-phase PH."""
181
+ mu1, mu2, p1 = float(params.mu1.real), float(params.mu2.real), float(params.p1.real)
182
+ return PHParams(alpha=np.array([1.0, 0.0]), T=np.array([[-mu1, p1 * mu1], [0.0, -mu2]]))
183
+
184
+ # ------------------------------------------------------------ sampling
185
+ def generate(self) -> float:
186
+ """Draw one sample by simulating the absorbing phase process."""
187
+ rng = self.generator
188
+ m = self.T.shape[0]
189
+ phase = int(rng.choice(m, p=self.alpha))
190
+ elapsed = 0.0
191
+ while True:
192
+ rate = -self.T[phase, phase]
193
+ elapsed += rng.exponential(1.0 / rate)
194
+ # transition probabilities from `phase`: to other phases or absorb
195
+ probs = np.append(self.T[phase].copy(), self.t0[phase])
196
+ probs[phase] = 0.0
197
+ probs /= rate
198
+ nxt = int(rng.choice(m + 1, p=probs))
199
+ if nxt == m:
200
+ return elapsed
201
+ phase = nxt
202
+
203
+
204
+ class MAP:
205
+ """
206
+ Markovian Arrival Process MAP(D0, D1).
207
+
208
+ ``generate()`` returns successive interarrival times (the phase is kept
209
+ between calls, starting from the arrival-stationary distribution).
210
+ """
211
+
212
+ def __init__(self, params: MAPParams, generator=None):
213
+ self.params = params
214
+ self.D0 = np.asarray(params.D0, dtype=float)
215
+ self.D1 = np.asarray(params.D1, dtype=float)
216
+ if self.D0.shape != self.D1.shape or self.D0.ndim != 2 or self.D0.shape[0] != self.D0.shape[1]:
217
+ raise ValueError("D0 and D1 must be square matrices of the same shape")
218
+ gen_row_sums = (self.D0 + self.D1) @ np.ones(self.D0.shape[0])
219
+ if not np.allclose(gen_row_sums, 0.0, atol=1e-9):
220
+ raise ValueError("D0 + D1 must be a generator (zero row sums)")
221
+ if np.any(self.D1 < -1e-12):
222
+ raise ValueError("D1 must be non-negative")
223
+ self.type = "MAP"
224
+ self.generator = generator if generator is not None else np.random.default_rng()
225
+ self._phase = int(self.generator.choice(self.D0.shape[0], p=MAP.arrival_stationary_phase(params)))
226
+
227
+ # ------------------------------------------------------------- theory
228
+ @staticmethod
229
+ def phase_stationary(params: MAPParams) -> np.ndarray:
230
+ """Stationary distribution pi of the phase generator D = D0 + D1."""
231
+ d = np.asarray(params.D0, dtype=float) + np.asarray(params.D1, dtype=float)
232
+ m = d.shape[0]
233
+ a = np.vstack([d.T, np.ones(m)])
234
+ b = np.zeros(m + 1)
235
+ b[-1] = 1.0
236
+ pi, *_ = np.linalg.lstsq(a, b, rcond=None)
237
+ return pi
238
+
239
+ @staticmethod
240
+ def arrival_rate(params: MAPParams) -> float:
241
+ """Fundamental arrival rate lambda = pi @ D1 @ 1."""
242
+ pi = MAP.phase_stationary(params)
243
+ return float(pi @ np.asarray(params.D1, dtype=float) @ np.ones(len(pi)))
244
+
245
+ @staticmethod
246
+ def arrival_stationary_phase(params: MAPParams) -> np.ndarray:
247
+ """Phase distribution just after an arrival: pi @ D1 / lambda."""
248
+ pi = MAP.phase_stationary(params)
249
+ d1 = np.asarray(params.D1, dtype=float)
250
+ vec = pi @ d1
251
+ return vec / vec.sum()
252
+
253
+ @staticmethod
254
+ def calc_theory_moments(params: MAPParams, num: int = 4) -> list[float]:
255
+ """
256
+ Raw moments of the stationary interarrival time:
257
+ m_k = k! * pi_a @ (-D0)^{-k} @ 1.
258
+ """
259
+ pi_a = MAP.arrival_stationary_phase(params)
260
+ m_inv = np.linalg.inv(-np.asarray(params.D0, dtype=float))
261
+ ones = np.ones(len(pi_a))
262
+ moments, vec = [], pi_a.copy()
263
+ for k in range(1, num + 1):
264
+ vec = vec @ m_inv
265
+ moments.append(math.factorial(k) * float(vec @ ones))
266
+ return moments
267
+
268
+ @staticmethod
269
+ def lag_correlation(params: MAPParams, k: int = 1) -> float:
270
+ """
271
+ Lag-k autocorrelation of interarrival times:
272
+ corr(X_0, X_k) with E[X_0 X_k] = pi_a @ M @ P^k @ M @ 1, M = (-D0)^{-1},
273
+ P = M @ D1 (phase transition matrix over one arrival).
274
+ """
275
+ if k < 1:
276
+ raise ValueError("lag k must be >= 1")
277
+ pi_a = MAP.arrival_stationary_phase(params)
278
+ m_inv = np.linalg.inv(-np.asarray(params.D0, dtype=float))
279
+ p = m_inv @ np.asarray(params.D1, dtype=float)
280
+ ones = np.ones(len(pi_a))
281
+ m1 = float(pi_a @ m_inv @ ones)
282
+ m2 = 2.0 * float(pi_a @ m_inv @ m_inv @ ones)
283
+ joint = float(pi_a @ m_inv @ np.linalg.matrix_power(p, k) @ m_inv @ ones)
284
+ var = m2 - m1 * m1
285
+ return (joint - m1 * m1) / var
286
+
287
+ # ---------------------------------------------------------- factories
288
+ @staticmethod
289
+ def poisson(rate: float) -> MAPParams:
290
+ """Poisson process as a trivial one-phase MAP."""
291
+ return MAPParams(D0=np.array([[-rate]]), D1=np.array([[rate]]))
292
+
293
+ @staticmethod
294
+ def from_ph_renewal(ph: PHParams) -> MAPParams:
295
+ """Renewal process with PH interarrival times: D0 = T, D1 = t0 @ alpha."""
296
+ alpha = np.asarray(ph.alpha, dtype=float)
297
+ T = np.asarray(ph.T, dtype=float)
298
+ t0 = -T @ np.ones(T.shape[0])
299
+ return MAPParams(D0=T, D1=np.outer(t0, alpha))
300
+
301
+ @staticmethod
302
+ def mmpp(rates: list[float], q: np.ndarray) -> MAPParams:
303
+ """
304
+ Markov-modulated Poisson process: Poisson rate rates[i] while the
305
+ modulating chain (generator q) is in state i.
306
+ """
307
+ rates_arr = np.asarray(rates, dtype=float)
308
+ q_arr = np.asarray(q, dtype=float)
309
+ if not np.allclose(q_arr @ np.ones(q_arr.shape[0]), 0.0, atol=1e-9):
310
+ raise ValueError("q must be a generator (zero row sums)")
311
+ return MAPParams(D0=q_arr - np.diag(rates_arr), D1=np.diag(rates_arr))
312
+
313
+ # ------------------------------------------------------------ sampling
314
+ def generate(self) -> float:
315
+ """Time until the next arrival (phase state persists between calls)."""
316
+ rng = self.generator
317
+ m = self.D0.shape[0]
318
+ elapsed = 0.0
319
+ while True:
320
+ rate = -self.D0[self._phase, self._phase]
321
+ elapsed += rng.exponential(1.0 / rate)
322
+ # competing transitions: D0 off-diagonal (no arrival) vs any D1 entry
323
+ probs = np.concatenate([self.D0[self._phase], self.D1[self._phase]])
324
+ probs[self._phase] = 0.0
325
+ probs = probs / rate
326
+ nxt = int(rng.choice(2 * m, p=probs))
327
+ if nxt >= m:
328
+ self._phase = nxt - m
329
+ return elapsed
330
+ self._phase = nxt