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.
- most_queue/__init__.py +7 -1
- most_queue/io/plots.py +41 -5
- most_queue/misc/calc_v_pls.py +1 -1
- most_queue/random/distributions.py +62 -0
- most_queue/random/map_fit.py +137 -0
- most_queue/random/map_ph.py +330 -0
- most_queue/random/utils/create.py +9 -0
- most_queue/sim/__init__.py +2 -1
- most_queue/sim/aoi.py +133 -0
- most_queue/sim/base.py +20 -2
- most_queue/sim/base_core.py +11 -9
- most_queue/sim/batch.py +99 -95
- most_queue/sim/bmap.py +130 -0
- most_queue/sim/bulk_service.py +95 -0
- most_queue/sim/finite_source.py +246 -242
- most_queue/sim/flow_sum.py +240 -240
- most_queue/sim/fork_join.py +264 -260
- most_queue/sim/impatient.py +177 -181
- most_queue/sim/msj.py +117 -0
- most_queue/sim/negative.py +190 -35
- most_queue/sim/networks/negative_network.py +3 -4
- most_queue/sim/priority.py +4 -9
- most_queue/sim/retrial.py +79 -0
- most_queue/sim/single_server_disciplines.py +225 -0
- most_queue/sim/size_based.py +248 -0
- most_queue/sim/unreliable.py +95 -0
- most_queue/sim/utils/predictor.py +44 -0
- most_queue/sim/utils/qs_queue.py +12 -0
- most_queue/sim/utils/queue_priority_size.py +112 -0
- most_queue/sim/utils/servers.py +45 -1
- most_queue/sim/utils/stats_update.py +2 -2
- most_queue/sim/utils/tasks.py +21 -0
- most_queue/sim/vacations.py +364 -316
- most_queue/structs.py +41 -1
- 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/erlang.py +249 -0
- most_queue/theory/fifo/gi_g_approx.py +223 -0
- most_queue/theory/fifo/gi_m_1.py +10 -13
- most_queue/theory/fifo/gi_m_n.py +257 -258
- most_queue/theory/fifo/gmc_takahasi.py +0 -1
- most_queue/theory/fifo/hkhk_takahasi.py +475 -486
- most_queue/theory/fifo/m_g_inf.py +114 -0
- most_queue/theory/fifo/mg1_lcfs_pr.py +106 -0
- most_queue/theory/fifo/mg1_ps.py +119 -0
- most_queue/theory/fifo/mgn_takahasi.py +34 -1
- most_queue/theory/fork_join/__init__.py +11 -0
- most_queue/theory/impatience/__init__.py +11 -0
- most_queue/theory/impatience/mmn.py +147 -0
- most_queue/theory/matrix/__init__.py +22 -0
- 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_ph1.py +190 -0
- most_queue/theory/matrix/map_phc.py +278 -0
- most_queue/theory/matrix/qbd.py +147 -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/negative/mg1_disasters.py +112 -18
- most_queue/theory/negative/mgn_disaster.py +389 -90
- most_queue/theory/negative/mgn_rcs.py +544 -43
- most_queue/theory/negative/structs.py +0 -1
- 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/retrial/__init__.py +8 -0
- most_queue/theory/retrial/mg1.py +102 -0
- most_queue/theory/retrial/mm1.py +160 -0
- most_queue/theory/srpt/__init__.py +25 -0
- most_queue/theory/srpt/_base.py +136 -0
- most_queue/theory/srpt/degradation.py +71 -0
- most_queue/theory/srpt/mg1_fb.py +68 -0
- most_queue/theory/srpt/mg1_psjf.py +53 -0
- most_queue/theory/srpt/mg1_sjf.py +46 -0
- most_queue/theory/srpt/mg1_spjf.py +96 -0
- most_queue/theory/srpt/mg1_srpt.py +61 -0
- most_queue/theory/srpt/utils/__init__.py +12 -0
- most_queue/theory/srpt/utils/load_below.py +123 -0
- most_queue/theory/srpt/utils/predictor.py +153 -0
- most_queue/theory/utils/busy_periods.py +1 -2
- most_queue/theory/utils/conditional.py +1 -1
- most_queue/theory/utils/derivative.py +93 -0
- most_queue/theory/utils/passage_time.py +52 -39
- most_queue/theory/utils/restarts.py +241 -0
- most_queue/theory/utils/timing.py +2 -2
- most_queue/theory/vacations/__init__.py +20 -0
- most_queue/theory/vacations/m_h2_h2warm.py +1 -1
- most_queue/theory/vacations/mg1_unreliable.py +159 -0
- most_queue/theory/vacations/mg1_vacations.py +228 -0
- most_queue/theory/vacations/mg1_warm_calc.py +1 -1
- most_queue/theory/vacations/mgn_with_h2_delay_cold_warm.py +1 -1
- most_queue/theory/vacations/mmn_with_h2_cold_and_h2_warmup.py +1 -1
- most_queue-2.9.dist-info/METADATA +200 -0
- most_queue-2.9.dist-info/RECORD +174 -0
- {most_queue-2.7.dist-info → most_queue-2.9.dist-info}/WHEEL +1 -1
- most_queue-2.7.dist-info/METADATA +0 -274
- most_queue-2.7.dist-info/RECORD +0 -121
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
ax.plot(self.xs, self.
|
|
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
|
-
|
|
92
|
-
ax.plot(self.xs, self.
|
|
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)
|
most_queue/misc/calc_v_pls.py
CHANGED
|
@@ -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
|