LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ldes_ssj_variates.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_LDES_LDES_SSJ_VARIATES_H
6#define LINE_SOLVERS_LDES_LDES_SSJ_VARIATES_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The variate layer of the Java LDES engine, reproduced: SSJ's `randvar`
12 * generators as inverse-CDF functions of one uniform.
13 *
14 * THE MEASUREMENT THAT SHAPED THIS FILE. Every `*Gen` instance `Solver_ssj`
15 * builds -- Exponential, Erlang, Uniform, Weibull, Pareto, Lognormal, Gamma,
16 * Poisson, Binomial, Bernoulli -- consumes EXACTLY ONE uniform per draw. That
17 * was measured, not assumed: a probe generated five variates from a seeded
18 * MRG32k3a and then searched the raw stream for the next value, and every
19 * family had advanced the stream by exactly five. SSJ's instance generators
20 * invert; none of them rejects. The consequence is the whole reason a
21 * bit-compatible C++ engine is feasible: the stream can never desynchronize, so
22 * a family whose quantile this file computes to 1e-13 rather than to the last
23 * bit still leaves every LATER draw identical, and only perturbs its own sample
24 * below the tolerance of any event comparison.
25 *
26 * WHAT IS EXACT AND WHAT IS TO 1e-13. Exponential, Uniform, Pareto, Weibull and
27 * Bernoulli have closed-form quantiles and agree with SSJ to the last bit.
28 * Normal (hence Lognormal) uses Wichura's AS 241, Gamma and Erlang inverting
29 * the regularized incomplete gamma by Newton with a Wilson-Hilferty start, and
30 * Poisson and Binomial by discrete inversion -- the two discrete families return
31 * INTEGERS and so are exact wherever the cdf comparison lands on the same side,
32 * which it does everywhere the test looks.
33 *
34 * WHY NOT CALL SSJ'S OWN FORMULAE BLINDLY. `ExponentialDist.inverseF` is
35 * `-log1p(-u)/lambda` and not `-log(1-u)/lambda`; the two differ in the last
36 * bits for small u, which is exactly where an interarrival time matters most.
37 * Where a spelling like that decides agreement, this file uses SSJ's.
38 *
39 * AND THE SPELLING IS NOT ENOUGH: IT MUST BE THE SAME log1p. `std::log1p` is
40 * allowed a 1-ulp error, and glibc changed which representative it returns
41 * between 2.35 and 2.39, so the SAME `common/ldes` binary on the SAME
42 * model.json at `--seed 23000` gave two different sample paths -- 98.565592 on
43 * a 22.04 host against 98.562490 under the containerized MATLAB's 24.04, which
44 * is how this was found. The quantiles below therefore call
45 * `line::fdlibm::log1p`, the algorithm `StrictMath.log1p` is DEFINED to use and
46 * which `Math.log1p` was measured to match on every one of 400k draws; see
47 * `line/util/fdlibm.h`. That fixes the engine to one arithmetic everywhere AND
48 * puts it on the Java engine's, which is what "bit-compatible" was supposed to
49 * mean. `pow`, `log`, `exp`, `sqrt`, `lgamma` and `tgamma` were measured to
50 * agree across those glibcs and are left to libm.
51 */
52
53#include <cmath>
54#include <string>
55#include <vector>
56
57#include "line/util/error.h"
58#include "line/util/fdlibm.h"
59
60namespace line {
61namespace ldes {
62namespace ssj {
63
64// ---------------------------------------------------------------------------
65// Closed-form quantiles
66// ---------------------------------------------------------------------------
67
68/** ExponentialDist.inverseF(lambda, u), SSJ's log1p spelling. */
69inline double exponential_inverse(double lambda, double u) {
70 if (lambda <= 0.0) throw InputError("ssj::exponential_inverse: lambda must be positive");
71 if (u >= 1.0) return std::numeric_limits<double>::infinity();
72 if (u <= 0.0) return 0.0;
73 return -line::fdlibm::log1p(-u) / lambda;
74}
75
76/** UniformDist.inverseF(a, b, u). */
77inline double uniform_inverse(double a, double b, double u) {
78 return a + (b - a) * u;
79}
80
81/**
82 * UniformIntDist.inverseF(i, j, u), written over the lower bound and the WIDTH
83 * w = j - i + 1 so that a lattice shifted off the integers keeps its offset.
84 *
85 * SSJ computes `i + (int)(u*(j - i + 1.0))`, clamping the two open ends, and
86 * the cast truncates toward zero on a non-negative product, which is the floor.
87 */
88inline double uniform_int_inverse(double lo, double width, double u) {
89 if (!(width >= 1.0)) throw InputError("ssj::uniform_int_inverse: the width must be at least 1");
90 if (u <= 0.0) return lo;
91 if (u >= 1.0) return lo + width - 1.0;
92 return lo + std::floor(u * width);
93}
94
95/** ParetoDist.inverseF(alpha, beta, u) = beta (1-u)^(-1/alpha). */
96inline double pareto_inverse(double alpha, double beta, double u) {
97 if (alpha <= 0.0 || beta <= 0.0)
98 throw InputError("ssj::pareto_inverse: alpha and beta must be positive");
99 if (u >= 1.0) return std::numeric_limits<double>::infinity();
100 return beta * std::pow(1.0 - u, -1.0 / alpha);
101}
102
103/**
104 * WeibullDist.inverseF(alpha, lambda, delta, u).
105 * SSJ parameterises the scale as lambda with the variate
106 * delta + (1/lambda) (-log(1-u))^(1/alpha).
107 */
108inline double weibull_inverse(double alpha, double lambda, double delta, double u) {
109 if (alpha <= 0.0 || lambda <= 0.0)
110 throw InputError("ssj::weibull_inverse: alpha and lambda must be positive");
111 if (u >= 1.0) return std::numeric_limits<double>::infinity();
112 if (u <= 0.0) return delta;
113 return delta + std::pow(-line::fdlibm::log1p(-u), 1.0 / alpha) / lambda;
114}
115
116/** BernoulliDist.inverseF(p, u): 1 when u exceeds 1 - p. */
117inline double bernoulli_inverse(double p, double u) {
118 if (p < 0.0 || p > 1.0) throw InputError("ssj::bernoulli_inverse: p must lie in [0,1]");
119 return (u <= 1.0 - p) ? 0.0 : 1.0;
120}
121
122// ---------------------------------------------------------------------------
123// Normal, and Lognormal on top of it
124// ---------------------------------------------------------------------------
125
126/**
127 * NormalDist.inverseF(0, 1, u) by Wichura's AS 241 (Applied Statistics 37,
128 * 1988), the algorithm SSJ's `inverseF01` implements. Accurate to about 1e-16
129 * over the whole range, which is what makes the Lognormal agree with SSJ to
130 * the last few bits rather than only to a plotting tolerance.
131 */
132inline double normal_inverse01(double u) {
133 if (u <= 0.0) return -std::numeric_limits<double>::infinity();
134 if (u >= 1.0) return std::numeric_limits<double>::infinity();
135
136 const double q = u - 0.5;
137 double r;
138 if (std::fabs(q) <= 0.425) {
139 r = 0.180625 - q * q;
140 return q *
141 (((((((2509.0809287301226727 * r + 33430.575583588128105) * r +
142 67265.770927008700853) * r + 45921.953931549871457) * r +
143 13731.693765509461125) * r + 1971.5909503065514427) * r +
144 133.14166789178437745) * r + 3.387132872796366608) /
145 (((((((5226.495278852854561 * r + 28729.085735721942674) * r +
146 39307.89580009271061) * r + 21213.794301586595867) * r +
147 5394.1960214247511077) * r + 687.1870074920579083) * r +
148 42.313330701600911252) * r + 1.0);
149 }
150 r = (q < 0.0) ? u : 1.0 - u;
151 r = std::sqrt(-std::log(r));
152 double x;
153 if (r <= 5.0) {
154 r -= 1.6;
155 x = (((((((7.7454501427834140764e-4 * r + 0.0227238449892691845833) * r +
156 0.24178072517745061177) * r + 1.27045825245236838258) * r +
157 3.64784832476320460504) * r + 5.7694972214606914055) * r +
158 4.6303378461565452959) * r + 1.42343711074968357734) /
159 (((((((1.05075007164441684324e-9 * r + 5.475938084995344946e-4) * r +
160 0.0151986665636164571966) * r + 0.14810397642748007459) * r +
161 0.68976733498510000455) * r + 1.6763848301838038494) * r +
162 2.05319162663775882187) * r + 1.0);
163 } else {
164 r -= 5.0;
165 x = (((((((2.01033439929228813265e-7 * r + 2.71155556874348757815e-5) * r +
166 0.0012426609473880784386) * r + 0.026532189526576123093) * r +
167 0.29656057182850489123) * r + 1.7848265399172913358) * r +
168 5.4637849111641143699) * r + 6.6579046435011037772) /
169 (((((((2.04426310338993978564e-15 * r + 1.4215117583164458887e-7) * r +
170 1.8463183175100546818e-5) * r + 7.868691311456132591e-4) * r +
171 0.0148753612908506148525) * r + 0.13692988092273580531) * r +
172 0.59983220655588793769) * r + 1.0);
173 }
174 return (q < 0.0) ? -x : x;
175}
176
177/** NormalDist.inverseF(mu, sigma, u). */
178inline double normal_inverse(double mu, double sigma, double u) {
179 if (sigma <= 0.0) throw InputError("ssj::normal_inverse: sigma must be positive");
180 return mu + sigma * normal_inverse01(u);
181}
182
183/** LognormalDist.inverseF(mu, sigma, u) = exp(mu + sigma Phi^-1(u)). */
184inline double lognormal_inverse(double mu, double sigma, double u) {
185 if (u >= 1.0) return std::numeric_limits<double>::infinity();
186 if (u <= 0.0) return 0.0;
187 return std::exp(normal_inverse(mu, sigma, u));
188}
189
190// ---------------------------------------------------------------------------
191// Gamma, and Erlang as the integer-shape case
192// ---------------------------------------------------------------------------
193
194namespace detail {
195
196/** Regularized lower incomplete gamma P(a, x), by series and continued fraction. */
197inline double gamma_p(double a, double x) {
198 if (x <= 0.0) return 0.0;
199 const double lg = std::lgamma(a);
200 if (x < a + 1.0) {
201 // Series expansion.
202 double ap = a, sum = 1.0 / a, del = sum;
203 for (int n = 0; n < 1000; ++n) {
204 ap += 1.0;
205 del *= x / ap;
206 sum += del;
207 if (std::fabs(del) < std::fabs(sum) * 1e-16) break;
208 }
209 return sum * std::exp(-x + a * std::log(x) - lg);
210 }
211 // Continued fraction for Q(a, x), then P = 1 - Q.
212 const double tiny = 1e-300;
213 double b = x + 1.0 - a, c = 1.0 / tiny, d = 1.0 / b, h = d;
214 for (int i = 1; i <= 1000; ++i) {
215 const double an = -static_cast<double>(i) * (static_cast<double>(i) - a);
216 b += 2.0;
217 d = an * d + b;
218 if (std::fabs(d) < tiny) d = tiny;
219 c = b + an / c;
220 if (std::fabs(c) < tiny) c = tiny;
221 d = 1.0 / d;
222 const double del = d * c;
223 h *= del;
224 if (std::fabs(del - 1.0) < 1e-16) break;
225 }
226 return 1.0 - std::exp(-x + a * std::log(x) - lg) * h;
227}
228
229} // namespace detail
230
231/**
232 * GammaDist.inverseF(alpha, lambda, u): the quantile of a Gamma of shape alpha
233 * and RATE lambda, so the mean is alpha/lambda, which is SSJ's convention.
234 *
235 * Newton on P(alpha, lambda x) = u from a Wilson-Hilferty start, with a
236 * bisection guard: the density vanishes at the origin for alpha > 1 and Newton
237 * alone can step negative there. Converges to about 1e-14 relative, which is
238 * below the resolution at which a service time can reorder two events.
239 */
240inline double gamma_inverse(double alpha, double lambda, double u) {
241 if (alpha <= 0.0 || lambda <= 0.0)
242 throw InputError("ssj::gamma_inverse: alpha and lambda must be positive");
243 if (u <= 0.0) return 0.0;
244 if (u >= 1.0) return std::numeric_limits<double>::infinity();
245
246 // Wilson-Hilferty: a cube-root normal approximation to the Gamma quantile.
247 const double z = normal_inverse01(u);
248 const double wh = alpha * std::pow(1.0 - 1.0 / (9.0 * alpha) + z / (3.0 * std::sqrt(alpha)), 3.0);
249 double x = (wh > 0.0) ? wh : alpha * 0.5;
250
251 double lo = 0.0, hi = 0.0; // hi == 0 means "not yet bracketed"
252 const double lg = std::lgamma(alpha);
253 for (int it = 0; it < 200; ++it) {
254 const double p = detail::gamma_p(alpha, x);
255 if (p < u) {
256 lo = x;
257 } else {
258 hi = x;
259 }
260 const double logpdf = (alpha - 1.0) * std::log(x) - x - lg;
261 const double pdf = std::exp(logpdf);
262 double step = (pdf > 0.0) ? (p - u) / pdf : 0.0;
263 double next = x - step;
264 if (!(next > lo) || (hi > 0.0 && !(next < hi)) || !(next > 0.0) || !std::isfinite(next)) {
265 next = (hi > 0.0) ? 0.5 * (lo + hi) : 2.0 * x;
266 }
267 const double rel = std::fabs(next - x) / (std::fabs(x) + 1e-300);
268 x = next;
269 if (rel < 1e-15) break;
270 }
271 return x / lambda;
272}
273
274/**
275 * ErlangGen(k, lambda): the Gamma of integer shape k and rate lambda. SSJ's
276 * ErlangGen inverts the Gamma rather than summing k exponentials, which is why
277 * it costs ONE uniform and not k -- the measurement above pinned that.
278 */
279inline double erlang_inverse(int k, double lambda, double u) {
280 if (k <= 0) throw InputError("ssj::erlang_inverse: k must be positive");
281 return gamma_inverse(static_cast<double>(k), lambda, u);
282}
283
284// ---------------------------------------------------------------------------
285// Discrete families, by inversion
286// ---------------------------------------------------------------------------
287
288/**
289 * PoissonDist.inverseF(lambda, u): the smallest k whose cdf reaches u.
290 *
291 * Summed forward from k = 0 with the pmf carried recursively. The result is an
292 * INTEGER, so it agrees with SSJ exactly whenever the two cdfs put u on the
293 * same side of a step, which no test has yet found a counterexample to.
294 */
295inline double poisson_inverse(double lambda, double u) {
296 if (lambda <= 0.0) throw InputError("ssj::poisson_inverse: lambda must be positive");
297 if (u <= 0.0) return 0.0;
298 if (u >= 1.0) return std::numeric_limits<double>::infinity();
299 double p = std::exp(-lambda), cdf = p;
300 int k = 0;
301 const int guard = static_cast<int>(lambda + 20.0 * std::sqrt(lambda) + 1000.0);
302 while (cdf < u && k < guard) {
303 ++k;
304 p *= lambda / static_cast<double>(k);
305 cdf += p;
306 }
307 return static_cast<double>(k);
308}
309
310/** BinomialDist.inverseF(n, p, u), by the same forward inversion. */
311inline double binomial_inverse(int n, double p, double u) {
312 if (n < 0) throw InputError("ssj::binomial_inverse: n must be non-negative");
313 if (p < 0.0 || p > 1.0) throw InputError("ssj::binomial_inverse: p must lie in [0,1]");
314 if (u <= 0.0) return 0.0;
315 if (p <= 0.0) return 0.0;
316 if (p >= 1.0) return static_cast<double>(n);
317 double pk = std::pow(1.0 - p, static_cast<double>(n)), cdf = pk;
318 int k = 0;
319 while (cdf < u && k < n) {
320 pk *= (static_cast<double>(n - k) / static_cast<double>(k + 1)) * (p / (1.0 - p));
321 ++k;
322 cdf += pk;
323 }
324 return static_cast<double>(k);
325}
326
327} // namespace ssj
328} // namespace ldes
329} // namespace line
330
331#endif // LINE_SOLVERS_LDES_LDES_SSJ_VARIATES_H
InputError(const std::string &what)
Definition error.h:39
The exception types the port throws.
double exponential_inverse(double lambda, double u)
ExponentialDist.inverseF(lambda, u), SSJ's log1p spelling.
double uniform_int_inverse(double lo, double width, double u)
UniformIntDist.inverseF(i, j, u), written over the lower bound and the WIDTH w = j - i + 1 so that a ...
double binomial_inverse(int n, double p, double u)
BinomialDist.inverseF(n, p, u), by the same forward inversion.
double erlang_inverse(int k, double lambda, double u)
ErlangGen(k, lambda): the Gamma of integer shape k and rate lambda.
double uniform_inverse(double a, double b, double u)
UniformDist.inverseF(a, b, u).
double weibull_inverse(double alpha, double lambda, double delta, double u)
WeibullDist.inverseF(alpha, lambda, delta, u).
double pareto_inverse(double alpha, double beta, double u)
ParetoDist.inverseF(alpha, beta, u) = beta (1-u)^(-1/alpha).
double gamma_inverse(double alpha, double lambda, double u)
GammaDist.inverseF(alpha, lambda, u): the quantile of a Gamma of shape alpha and RATE lambda,...
double normal_inverse01(double u)
NormalDist.inverseF(0, 1, u) by Wichura's AS 241 (Applied Statistics 37, 1988), the algorithm SSJ's i...
double bernoulli_inverse(double p, double u)
BernoulliDist.inverseF(p, u): 1 when u exceeds 1 - p.
double normal_inverse(double mu, double sigma, double u)
NormalDist.inverseF(mu, sigma, u).
double lognormal_inverse(double mu, double sigma, double u)
LognormalDist.inverseF(mu, sigma, u) = exp(mu + sigma Phi^-1(u)).
double poisson_inverse(double lambda, double u)
PoissonDist.inverseF(lambda, u): the smallest k whose cdf reaches u.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52