LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ldes_stats.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_STATS_H
6#define LINE_SOLVERS_LDES_LDES_STATS_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The estimators of the native LDES engine: running integrals, the MSER-5
12 * warmup filter, and the batch-means confidence intervals.
13 *
14 * THE INTEGRALS ARE LAZY. `tot_qlen(i,r)` is the time integral of the class-r
15 * queue length at station i, advanced only when that pair CHANGES. Advancing
16 * every pair on every event would be O(M*K) per event on a hot loop where the
17 * event touches one pair; the reference does the same thing with
18 * `lastQueueUpdateTime`, and the invariant is that `update_qlen` is called
19 * BEFORE any write to `qlen`, never after.
20 *
21 * THE WARMUP FILTER IS MSER-5 over EVENT-SPACED observations, not time-spaced
22 * ones. That matters for what the estimators may then do with the series: an
23 * unweighted mean of per-interval averages overweights congested epochs,
24 * because a congested epoch contains more events and therefore more intervals
25 * per unit time. So the means below are integral DIFFERENCES over elapsed
26 * time, and only the CI -- which needs a sequence of comparable observations,
27 * not a mean -- reads the per-interval series directly.
28 */
29
30#include <algorithm>
31#include <cmath>
32#include <cstddef>
33#include <cstdint>
34#include <limits>
35#include <string>
36#include <vector>
37
39#include "line/util/matrix.h"
40
41namespace line {
42namespace ldes {
43namespace engine {
44
45/** The running per-(station, class) integrals and tallies. */
46struct Accum {
47 Accum(std::size_t M, std::size_t K)
48 : qlen(M, std::vector<double>(K, 0.0)),
49 busy(M, std::vector<double>(K, 0.0)),
50 tot_qlen(M, std::vector<double>(K, 0.0)),
51 tot_busy(M, std::vector<double>(K, 0.0)),
52 last_qlen(M, std::vector<double>(K, 0.0)),
53 last_busy(M, std::vector<double>(K, 0.0)),
54 busy_scale(M, 1.0),
55 util_peak(M, 1.0),
56 resp_sum(M, std::vector<double>(K, 0.0)),
57 resp_cnt(M, std::vector<double>(K, 0.0)),
58 completed(M, std::vector<double>(K, 0.0)),
59 arrived(M, std::vector<double>(K, 0.0)),
60 join_dropped(M, std::vector<double>(K, 0.0)) {}
61
62 /** Advance the queue-length integral of (i,r) to `now`. Call BEFORE writing qlen. */
63 void update_qlen(std::size_t i, std::size_t r, double now) {
64 const double dt = now - last_qlen[i][r];
65 if (dt > 0.0) tot_qlen[i][r] += qlen[i][r] * dt;
66 last_qlen[i][r] = now;
67 }
68 /**
69 * Advance the busy-server integral of (i,r) to `now`. Call BEFORE writing busy.
70 *
71 * The integrand is the WORK being delivered, `busy * busy_scale`, not the
72 * head count: a load-dependent server running alpha(n) times faster does the
73 * same work in less time, so plain busy TIME reads it as no busier than one
74 * at its nominal rate. `busy_scale` is 1 unless the station is load
75 * dependent, so this is the identity everywhere else.
76 */
77 void update_busy(std::size_t i, std::size_t r, double now) {
78 const double dt = now - last_busy[i][r];
79 if (dt > 0.0) tot_busy[i][r] += busy[i][r] * busy_scale[i] * dt;
80 last_busy[i][r] = now;
81 }
82 /**
83 * Install the load-dependent speed station `i` runs at from `now` on.
84 *
85 * Flushes every class FIRST, so the interval that just ended is credited at
86 * the scale that was actually in force over it. The engine calls this after
87 * the population is written, from the same chokepoint that re-times the
88 * departures.
89 */
90 void set_busy_scale(std::size_t i, double v, double now) {
91 if (v == busy_scale[i]) return;
92 for (std::size_t r = 0; r < busy[i].size(); ++r) update_busy(i, r, now);
93 busy_scale[i] = v;
94 }
95
96 std::vector<std::vector<double>> qlen, busy;
97 std::vector<std::vector<double>> tot_qlen, tot_busy;
98 std::vector<std::vector<double>> last_qlen, last_busy;
99 /**
100 * The speed each station is running at right now (load dependence only),
101 * and the peak capacity that normalizes its utilization,
102 * max(c, max(alpha)).
103 *
104 * `util_peak` is CTMC's own `ceff`, which is what makes LDES report the same
105 * utilization as the analytic solvers on a load-dependent station. Both are
106 * 1 and c respectively when the station is not load dependent, leaving every
107 * other model's numbers untouched.
108 */
109 std::vector<double> busy_scale, util_peak;
110 std::vector<std::vector<double>> resp_sum, resp_cnt, completed;
111 /**
112 * Jobs that ARRIVED at each (station, class), which is not what `completed`
113 * counts: the reference reports AN as arrivedCustomers / simTime, and the
114 * two differ by whatever is still in the station at the end and by anything
115 * dropped, balked or reneged. Reporting completions as the arrival rate
116 * would hide exactly the loss those features exist to measure.
117 */
118 std::vector<std::vector<double>> arrived;
119 /**
120 * Siblings a QUORUM Join discarded, per (station, class): a sibling that
121 * reaches the Join after its parent already fired lost the race and is
122 * thrown away. It is a completion of nothing, so it belongs neither in
123 * `completed` nor in `arrived`, and it is what a Join row's loss rate is.
124 * Cumulative, and differenced against the truncation point like `completed`.
125 */
126 std::vector<std::vector<double>> join_dropped;
127};
128
129/**
130 * MSER-5 truncation point over a series of observations, in BATCHES.
131 *
132 * Returns the batch index d minimising var(Z_{d..N-1}) / (N-d)^2 over the
133 * batch means Z, and 0 when there are fewer than four batches -- below that
134 * the criterion is estimated from too few terms to be a truncation rule.
135 * Transcribes `computeMSER5TruncationPoint`.
136 */
137inline int mser5_truncation(const std::vector<double>& obs, int batch_size) {
138 const std::size_t n = obs.size();
139 if (batch_size <= 0 || n < static_cast<std::size_t>(batch_size) * 4) return 0;
140 const int num_batches = static_cast<int>(n / static_cast<std::size_t>(batch_size));
141 std::vector<double> means(static_cast<std::size_t>(num_batches), 0.0);
142 for (int j = 0; j < num_batches; ++j) {
143 double sum = 0.0;
144 for (int i = 0; i < batch_size; ++i)
145 sum += obs[static_cast<std::size_t>(j * batch_size + i)];
146 means[static_cast<std::size_t>(j)] = sum / batch_size;
147 }
148 double min_mser = std::numeric_limits<double>::max();
149 int optimal_d = 0;
150 const int max_d = num_batches / 2;
151 for (int d = 0; d < max_d; ++d) {
152 const int remaining = num_batches - d;
153 if (remaining < 2) break;
154 double sum = 0.0;
155 for (int j = d; j < num_batches; ++j) sum += means[static_cast<std::size_t>(j)];
156 const double mean = sum / remaining;
157 double variance = 0.0;
158 for (int j = d; j < num_batches; ++j) {
159 const double diff = means[static_cast<std::size_t>(j)] - mean;
160 variance += diff * diff;
161 }
162 variance /= (remaining - 1);
163 const double mser = variance / (static_cast<double>(remaining) * remaining);
164 if (mser < min_mser) {
165 min_mser = mser;
166 optimal_d = d;
167 }
168 }
169 return optimal_d;
170}
171
172/** Where the warmup ended, and whether a truncation was applied at all. */
174 std::size_t index = 0; ///< observation index of the truncation point
175 double warmup_end = 0.0; ///< the instant of that observation
176 bool applied = false;
177};
178
179/** The event-spaced observation series MSER-5 and the CI both read. */
181 Observations(std::size_t M, std::size_t K, bool mser_on, int batch)
182 : enabled(mser_on),
183 batch_size(batch),
184 qlen(M, std::vector<std::vector<double>>(K)),
185 qt(M, std::vector<std::vector<double>>(K)),
186 bt(M, std::vector<std::vector<double>>(K)),
187 cmp(M, std::vector<std::vector<double>>(K)),
188 drp(M, std::vector<std::vector<double>>(K)),
189 last_qt(M, std::vector<double>(K, 0.0)),
190 in_mser(M, static_cast<char>(1)),
191 nstations(M),
192 nclasses(K) {}
193
194 /**
195 * Record one observation.
196 *
197 * The queue-length entry is the INTERVAL TIME-AVERAGE, not the instantaneous
198 * count: it is the integral delivered since the previous observation over
199 * the interval length, which is what makes a sequence of them comparable
200 * even though the intervals differ in duration. The other three are
201 * CUMULATIVE, because the estimators difference them against the truncation
202 * point rather than averaging them.
203 */
204 void collect(const Accum& acc, double now) {
205 const double dt = now - last_time;
206 time.push_back(now);
207 for (std::size_t i = 0; i < nstations; ++i)
208 for (std::size_t r = 0; r < nclasses; ++r) {
209 if (dt > 0.0) {
210 qlen[i][r].push_back((acc.tot_qlen[i][r] - last_qt[i][r]) / dt);
211 last_qt[i][r] = acc.tot_qlen[i][r];
212 } else {
213 qlen[i][r].push_back(acc.qlen[i][r]);
214 }
215 qt[i][r].push_back(acc.tot_qlen[i][r]);
216 bt[i][r].push_back(acc.tot_busy[i][r]);
217 cmp[i][r].push_back(acc.completed[i][r]);
218 drp[i][r].push_back(acc.join_dropped[i][r]);
219 }
220 last_time = now;
221 }
222
223 /**
224 * The truncation point, on the AGGREGATE queue length first.
225 *
226 * The per-series fallback is not a refinement, it is the closed-network
227 * case: a closed model holds a constant total population, so the aggregate
228 * series is flat and carries no transient signal at all, however long the
229 * warmup actually was. The most conservative per-series point is taken then.
230 *
231 * BOTH RUN OVER THE SERVICE STATIONS ONLY, which is the range
232 * `applyMSER5Truncation` walks. A Join is measured like a station but is
233 * not one of them, and folding its series in would move the truncation
234 * point of every fork-join model away from the reference's.
235 */
237 Truncation t;
238 if (!enabled || time.empty()) return t;
239 std::vector<double> aggregate(time.size(), 0.0);
240 for (std::size_t k = 0; k < time.size(); ++k) {
241 double tot = 0.0;
242 for (std::size_t i = 0; i < nstations; ++i) {
243 if (!in_mser[i]) continue;
244 for (std::size_t r = 0; r < nclasses; ++r)
245 if (k < qlen[i][r].size()) tot += qlen[i][r][k];
246 }
247 aggregate[k] = tot;
248 }
249 int batch = mser5_truncation(aggregate, batch_size);
250 if (batch == 0) {
251 for (std::size_t i = 0; i < nstations; ++i) {
252 if (!in_mser[i]) continue;
253 for (std::size_t r = 0; r < nclasses; ++r) {
254 const int b = mser5_truncation(qlen[i][r], batch_size);
255 if (b > batch) batch = b;
256 }
257 }
258 }
259 const std::size_t idx =
260 static_cast<std::size_t>(batch) * static_cast<std::size_t>(batch_size);
261 if (idx < time.size()) {
262 t.index = idx;
263 t.warmup_end = time[idx];
264 t.applied = true;
265 }
266 return t;
267 }
268
269 bool enabled = true;
270 int batch_size = 5;
271 std::vector<double> time;
272 std::vector<std::vector<std::vector<double>>> qlen, qt, bt, cmp, drp;
273 std::vector<std::vector<double>> last_qt;
274 /// Whether station i feeds the truncation criterion; every one is still recorded.
275 std::vector<char> in_mser;
276 double last_time = 0.0;
277 std::size_t nstations = 0, nclasses = 0;
278};
279
280/** Grand mean, standard error and degrees of freedom of a batch-means estimate. */
282 double mean = 0.0;
283 double stderr_ = 0.0;
284 int df = 0;
285 bool ok = false;
286};
287
288/**
289 * The variance inflation of OVERLAPPING batch means.
290 *
291 * Overlapping batches are positively correlated, so the naive sample variance
292 * of their means underestimates the true one; 4/3 is the standard asymptotic
293 * correction at 50% overlap, interpolated linearly below it and reducing to 1
294 * at zero overlap, where the batches are disjoint and no correction is due.
295 * Transcribes `computeOverlapAdjustmentFactor`.
296 */
297inline double overlap_adjustment(double overlap) {
298 if (overlap <= 0.0) return 1.0;
299 if (overlap >= 0.5) return 4.0 / 3.0;
300 return 1.0 + (overlap / 0.5) * (4.0 / 3.0 - 1.0);
301}
302
303/** Non-overlapping batch means. Transcribes `computeBMStatisticsInternal`. */
304inline StatTriple bm_statistics(const std::vector<double>& obs, int batch_size) {
305 StatTriple s;
306 const std::size_t n = obs.size();
307 const int nb = (batch_size > 0) ? static_cast<int>(n / static_cast<std::size_t>(batch_size)) : 0;
308 if (nb < 2) return s;
309 std::vector<double> means(static_cast<std::size_t>(nb), 0.0);
310 for (int i = 0; i < nb; ++i) {
311 double sum = 0.0;
312 for (int j = 0; j < batch_size; ++j)
313 sum += obs[static_cast<std::size_t>(i * batch_size + j)];
314 means[static_cast<std::size_t>(i)] = sum / batch_size;
315 }
316 double grand = 0.0;
317 for (int i = 0; i < nb; ++i) grand += means[static_cast<std::size_t>(i)];
318 grand /= nb;
319 double ss = 0.0;
320 for (int i = 0; i < nb; ++i) {
321 const double d = means[static_cast<std::size_t>(i)] - grand;
322 ss += d * d;
323 }
324 s.mean = grand;
325 s.stderr_ = std::sqrt((ss / (nb - 1)) / nb);
326 s.df = nb - 1;
327 s.ok = true;
328 return s;
329}
330
331/** Overlapping batch means. Transcribes `computeOBMStatisticsInternal`. */
332inline StatTriple obm_statistics(const std::vector<double>& obs, int batch_size, double overlap) {
333 StatTriple s;
334 const std::size_t n = obs.size();
335 if (batch_size <= 0 || n < static_cast<std::size_t>(batch_size) * 2) return s;
336 int step = static_cast<int>(batch_size * (1.0 - overlap));
337 if (step < 1) step = 1;
338 const int nb = static_cast<int>((n - static_cast<std::size_t>(batch_size)) /
339 static_cast<std::size_t>(step)) + 1;
340 if (nb < 2) return s;
341 std::vector<double> means(static_cast<std::size_t>(nb), 0.0);
342 for (int i = 0; i < nb; ++i) {
343 const std::size_t start = static_cast<std::size_t>(i) * static_cast<std::size_t>(step);
344 double sum = 0.0;
345 for (int j = 0; j < batch_size; ++j)
346 if (start + static_cast<std::size_t>(j) < n) sum += obs[start + static_cast<std::size_t>(j)];
347 means[static_cast<std::size_t>(i)] = sum / batch_size;
348 }
349 double grand = 0.0;
350 for (int i = 0; i < nb; ++i) grand += means[static_cast<std::size_t>(i)];
351 grand /= nb;
352 double ss = 0.0;
353 for (int i = 0; i < nb; ++i) {
354 const double d = means[static_cast<std::size_t>(i)] - grand;
355 ss += d * d;
356 }
357 const double adj = overlap_adjustment(overlap);
358 s.mean = grand;
359 s.stderr_ = std::sqrt((adj * ss / (nb - 1)) / nb);
360 s.df = static_cast<int>((nb - 1) / adj);
361 if (s.df < 1) s.df = 1;
362 s.ok = true;
363 return s;
364}
365
366/**
367 * The two-sided t critical value, from the REFERENCE'S TABLE.
368 *
369 * Deliberately the table and not an exact quantile: this port must report the
370 * same interval WIDTH as the Java engine on the same series, and the reference
371 * rounds its own critical values to three decimals and saturates at df = 30.
372 * Substituting an exact `sim_tinv` here would move every half-width by a few
373 * parts in a thousand and make a cross-engine comparison of CI widths fail for
374 * a reason that has nothing to do with the simulation.
375 */
376inline double t_critical(double level, int df) {
377 static const double t90[30] = {
378 6.314, 2.920, 2.353, 2.132, 2.015, 1.943, 1.895, 1.860, 1.833, 1.812,
379 1.796, 1.782, 1.771, 1.761, 1.753, 1.746, 1.740, 1.734, 1.729, 1.725,
380 1.721, 1.717, 1.714, 1.711, 1.708, 1.706, 1.703, 1.701, 1.699, 1.697};
381 static const double t95[30] = {
382 12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228,
383 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086,
384 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042};
385 static const double t99[30] = {
386 63.657, 9.925, 5.841, 4.604, 4.032, 3.707, 3.499, 3.355, 3.250, 3.169,
387 3.106, 3.055, 3.012, 2.977, 2.947, 2.921, 2.898, 2.878, 2.861, 2.845,
388 2.831, 2.819, 2.807, 2.797, 2.787, 2.779, 2.771, 2.763, 2.756, 2.750};
389 const int idx = std::min(df, 30) - 1;
390 if (idx < 0) return 1.96;
391 if (level >= 0.99) return t99[idx];
392 if (level >= 0.95) return t95[idx];
393 return t90[idx];
394}
395
396
397/**
398 * Heidelberger-Welch spectral estimate of the variance of the sample mean.
399 *
400 * The variance of a mean over a correlated series is 2*pi*S(0)/m, with S(0)
401 * the spectral density at frequency zero. S(0) cannot be read off the
402 * periodogram directly -- the ordinate at f=0 is exactly the quantity being
403 * estimated and carries all the bias -- so the LOG-periodogram over the lowest
404 * frequencies is fitted with a quadratic and the intercept extrapolated back
405 * to zero. That is what makes this estimator different from batch means rather
406 * than a re-spelling of it: it models the correlation instead of trying to
407 * batch it away.
408 *
409 * FALLS BACK TO PLAIN BATCH MEANS, not to an error, whenever the fit cannot be
410 * trusted: too few batches, too few regression points, a zero periodogram
411 * ordinate (log undefined) or a non-positive intercept. A spectral estimate
412 * from a singular fit is not conservative, it is arbitrary. Transcribes
413 * `computeSpectralStatisticsInternal`.
414 */
415inline StatTriple spectral_statistics(const std::vector<double>& obs, int batch_size,
416 double low_freq_frac) {
417 const std::size_t n = obs.size();
418 const int m = (batch_size > 0) ? static_cast<int>(n / static_cast<std::size_t>(batch_size)) : 0;
419 if (m < 4) return bm_statistics(obs, batch_size);
420
421 std::vector<double> means(static_cast<std::size_t>(m), 0.0);
422 for (int i = 0; i < m; ++i) {
423 double sum = 0.0;
424 for (int j = 0; j < batch_size; ++j)
425 sum += obs[static_cast<std::size_t>(i * batch_size + j)];
426 means[static_cast<std::size_t>(i)] = sum / batch_size;
427 }
428 double grand = 0.0;
429 for (int i = 0; i < m; ++i) grand += means[static_cast<std::size_t>(i)];
430 grand /= m;
431
432 const int nf = m / 2;
433 if (nf < 2) return bm_statistics(obs, batch_size);
434 std::vector<double> per(static_cast<std::size_t>(nf), 0.0);
435 for (int k = 1; k <= nf; ++k) {
436 double re = 0.0, im = 0.0;
437 const double f = 2.0 * 3.14159265358979323846 * k / m;
438 for (int t = 0; t < m; ++t) {
439 const double c = means[static_cast<std::size_t>(t)] - grand;
440 re += c * std::cos(f * t);
441 im += c * std::sin(f * t);
442 }
443 per[static_cast<std::size_t>(k - 1)] = (re * re + im * im) / m;
444 }
445
446 int npts = static_cast<int>(nf * low_freq_frac);
447 if (npts < 3) npts = 3;
448 if (npts > nf) return bm_statistics(obs, batch_size);
449 for (int k = 0; k < npts; ++k)
450 if (!(per[static_cast<std::size_t>(k)] > 0.0)) return bm_statistics(obs, batch_size);
451
452 // Ordinary least squares for log I(f) = b0 + b1 f + b2 f^2, solved through
453 // the 3x3 normal equations by Gaussian elimination: the design is tiny and
454 // pulling in a general solver here would only add a failure mode.
455 double A[3][4];
456 for (int r = 0; r < 3; ++r)
457 for (int c = 0; c < 4; ++c) A[r][c] = 0.0;
458 for (int k = 0; k < npts; ++k) {
459 const double f = 2.0 * 3.14159265358979323846 * (k + 1) / m;
460 const double x[3] = {1.0, f, f * f};
461 const double y = std::log(per[static_cast<std::size_t>(k)]);
462 for (int r = 0; r < 3; ++r) {
463 for (int c = 0; c < 3; ++c) A[r][c] += x[r] * x[c];
464 A[r][3] += x[r] * y;
465 }
466 }
467 for (int i = 0; i < 3; ++i) {
468 int piv = i;
469 for (int r = i + 1; r < 3; ++r)
470 if (std::fabs(A[r][i]) > std::fabs(A[piv][i])) piv = r;
471 if (!(std::fabs(A[piv][i]) > 1e-300)) return bm_statistics(obs, batch_size);
472 if (piv != i)
473 for (int c = 0; c < 4; ++c) std::swap(A[i][c], A[piv][c]);
474 for (int r = 0; r < 3; ++r) {
475 if (r == i) continue;
476 const double fct = A[r][i] / A[i][i];
477 for (int c = i; c < 4; ++c) A[r][c] -= fct * A[i][c];
478 }
479 }
480 const double b0 = A[0][3] / A[0][0];
481 const double s0 = std::exp(b0);
482 if (!(s0 > 0.0) || !std::isfinite(s0)) return bm_statistics(obs, batch_size);
483
484 StatTriple st;
485 st.mean = grand;
486 st.stderr_ = std::sqrt(2.0 * 3.14159265358979323846 * s0 / m);
487 st.df = npts - 3;
488 if (st.df < 1) st.df = 1;
489 st.ok = true;
490 return st;
491}
492
493/** Dispatch on `cimethod`. */
494inline StatTriple ci_statistics(const std::vector<double>& obs, int batch_size,
495 const LdesOptions& o) {
496 if (o.cimethod == "bm") return bm_statistics(obs, batch_size);
497 if (o.cimethod == "spectral")
498 return spectral_statistics(obs, batch_size, o.spectral_low_freq_frac);
499 return obm_statistics(obs, batch_size, o.obmoverlap);
500}
501
502
503/**
504 * Convergence-based stopping: batch the four metrics and stop once EVERY
505 * active (station, class) pair has reached the requested relative precision.
506 *
507 * THE CONJUNCTION IS THE POINT. A run stops when the WORST metric at the WORST
508 * pair is precise enough, not when the aggregate is: stopping on an average
509 * relative precision leaves the lightly loaded stations, whose estimates
510 * converge slowest, arbitrarily wrong while the report looks converged.
511 *
512 * A pair whose mean is effectively zero counts as converged: a relative
513 * precision against zero is not a bound, it is a division. Transcribes
514 * `checkConvergence` and `isMetricConverged`.
515 */
517public:
518 void init(std::size_t M, std::size_t K, const LdesOptions& o, std::uint64_t max_events) {
519 enabled_ = o.cnvgon;
520 if (!enabled_) return;
521 tol_ = o.cnvgtol;
522 min_batches_ = o.cnvgbatch;
523 interval_ = (o.cnvgchk > 0) ? static_cast<std::uint64_t>(o.cnvgchk)
524 : std::max<std::uint64_t>(1, max_events / 50);
525 level_ = (o.confint > 0.0) ? o.confint : 0.95;
526 q_.assign(M, std::vector<std::vector<double>>(K));
527 u_.assign(M, std::vector<std::vector<double>>(K));
528 r_.assign(M, std::vector<std::vector<double>>(K));
529 t_.assign(M, std::vector<std::vector<double>>(K));
530 start_qt_.assign(M, std::vector<double>(K, 0.0));
531 start_bt_.assign(M, std::vector<double>(K, 0.0));
532 start_cmp_.assign(M, std::vector<double>(K, 0.0));
533 start_rsum_.assign(M, std::vector<double>(K, 0.0));
534 start_rcnt_.assign(M, std::vector<double>(K, 0.0));
535 nstations_ = M;
536 nclasses_ = K;
537 }
538
539 bool enabled() const { return enabled_; }
540 std::uint64_t interval() const { return interval_; }
541
542 /** Close the current batch at `now` and record one batch mean per metric. */
543 void finalize_batch(const Accum& acc, const std::vector<std::size_t>& nservers, double now) {
544 if (!enabled_) return;
545 const double dur = now - batch_start_;
546 if (!(dur > 0.0)) {
547 reset_batch(acc, now);
548 return;
549 }
550 for (std::size_t i = 0; i < nstations_; ++i)
551 for (std::size_t k = 0; k < nclasses_; ++k) {
552 const double ql = (acc.tot_qlen[i][k] - start_qt_[i][k]) / dur;
553 q_[i][k].push_back(ql);
554 const double c = acc.util_peak[i] > 0.0
555 ? acc.util_peak[i]
556 : static_cast<double>(nservers[i] > 0 ? nservers[i] : 1);
557 u_[i][k].push_back((acc.tot_busy[i][k] - start_bt_[i][k]) / (dur * c));
558 t_[i][k].push_back((acc.completed[i][k] - start_cmp_[i][k]) / dur);
559 const double dn = acc.resp_cnt[i][k] - start_rcnt_[i][k];
560 r_[i][k].push_back(dn > 0.0 ? (acc.resp_sum[i][k] - start_rsum_[i][k]) / dn : 0.0);
561 }
562 reset_batch(acc, now);
563 }
564
565 /** True once every active pair has reached the tolerance on all four metrics. */
566 bool converged(const std::vector<std::vector<bool>>& off) const {
567 if (!enabled_) return false;
568 if (nstations_ == 0 || nclasses_ == 0) return false;
569 if (static_cast<int>(q_[0][0].size()) < min_batches_) return false;
570 for (std::size_t i = 0; i < nstations_; ++i)
571 for (std::size_t k = 0; k < nclasses_; ++k) {
572 if (off[i][k]) continue;
573 if (!metric_ok(q_[i][k])) return false;
574 if (!metric_ok(u_[i][k])) return false;
575 if (!metric_ok(t_[i][k])) return false;
576 bool any_positive = false;
577 for (double v : r_[i][k])
578 if (v > 0.0) {
579 any_positive = true;
580 break;
581 }
582 if (any_positive && !metric_ok(r_[i][k])) return false;
583 }
584 return true;
585 }
586
587 int batches() const { return (nstations_ && nclasses_) ? static_cast<int>(q_[0][0].size()) : 0; }
588
589private:
590 void reset_batch(const Accum& acc, double now) {
591 batch_start_ = now;
592 for (std::size_t i = 0; i < nstations_; ++i)
593 for (std::size_t k = 0; k < nclasses_; ++k) {
594 start_qt_[i][k] = acc.tot_qlen[i][k];
595 start_bt_[i][k] = acc.tot_busy[i][k];
596 start_cmp_[i][k] = acc.completed[i][k];
597 start_rsum_[i][k] = acc.resp_sum[i][k];
598 start_rcnt_[i][k] = acc.resp_cnt[i][k];
599 }
600 }
601
602 bool metric_ok(const std::vector<double>& b) const {
603 const std::size_t n = b.size();
604 if (n < 2) return false;
605 double sum = 0.0;
606 for (double x : b) sum += x;
607 const double mean = sum / n;
608 // A relative precision against a zero mean is a division, not a bound.
609 if (std::fabs(mean) < 1e-12) return true;
610 double var = 0.0;
611 for (double x : b) var += (x - mean) * (x - mean);
612 var /= (n - 1);
613 const double se = std::sqrt(var / n);
614 const double half = t_critical(level_, static_cast<int>(n) - 1) * se;
615 return half / std::fabs(mean) <= tol_;
616 }
617
618 bool enabled_ = false;
619 double tol_ = 0.05, level_ = 0.95, batch_start_ = 0.0;
620 int min_batches_ = 20;
621 std::uint64_t interval_ = 1;
622 std::size_t nstations_ = 0, nclasses_ = 0;
623 std::vector<std::vector<std::vector<double>>> q_, u_, r_, t_;
624 std::vector<std::vector<double>> start_qt_, start_bt_, start_cmp_, start_rsum_, start_rcnt_;
625};
626
627/**
628 * Fill the half-width matrices of `res` from the post-warmup observation
629 * series. Transcribes `computeOBMConfidenceIntervals`.
630 *
631 * The three series are not treated alike, and that is the reference's design:
632 *
633 * - QUEUE LENGTH has one observation per interval already, so it batches
634 * directly;
635 * - THROUGHPUT is stored CUMULATIVE, so it is differenced into per-interval
636 * rates first -- batching the cumulative counts would estimate the variance
637 * of a random walk;
638 * - UTILIZATION is not measured separately at all. Its interval is the
639 * throughput's, carried through the utilization law U = T/(mu*c), which is
640 * exact for the mean and is what keeps the two intervals consistent.
641 *
642 * A series shorter than `ciminobs` yields NO interval rather than a wide one:
643 * a half-width computed from a handful of batches is not conservative, it is
644 * arbitrary.
645 */
646inline void batch_means_ci(const Observations& obs, const Truncation& tr, const LdesOptions& o,
647 LdesResult& res) {
648 if (o.cimethod == "none" || !(o.confint > 0.0) || obs.time.empty()) return;
649 const std::size_t M = obs.nstations, K = obs.nclasses;
650 res.QNCI = Matrix<double>(M, K, 0.0);
651 res.UNCI = Matrix<double>(M, K, 0.0);
652 res.RNCI = Matrix<double>(M, K, 0.0);
653 res.TNCI = Matrix<double>(M, K, 0.0);
654 const std::size_t t0 = tr.applied ? tr.index : 0;
655
656 for (std::size_t i = 0; i < M; ++i) {
657 for (std::size_t r = 0; r < K; ++r) {
658 if (t0 >= obs.qlen[i][r].size()) continue;
659 const std::vector<double> q(obs.qlen[i][r].begin() + static_cast<std::ptrdiff_t>(t0),
660 obs.qlen[i][r].end());
661 if (q.size() >= static_cast<std::size_t>(o.ciminobs)) {
662 const int b = std::max(o.ciminbatch, static_cast<int>(std::sqrt(
663 static_cast<double>(q.size()))));
664 const StatTriple s = ci_statistics(q, b, o);
665 if (s.ok) res.QNCI(i, r) = t_critical(o.confint, s.df) * s.stderr_;
666 }
667
668 std::vector<double> rates;
669 for (std::size_t k = t0 + 1; k < obs.cmp[i][r].size(); ++k) {
670 const double dt = obs.time[k] - obs.time[k - 1];
671 if (dt > 0.0) rates.push_back((obs.cmp[i][r][k] - obs.cmp[i][r][k - 1]) / dt);
672 }
673 if (rates.size() >= static_cast<std::size_t>(o.ciminobs)) {
674 const int b = std::max(o.ciminbatch, static_cast<int>(std::sqrt(
675 static_cast<double>(rates.size()))));
676 const StatTriple s = ci_statistics(rates, b, o);
677 if (s.ok) {
678 const double half = t_critical(o.confint, s.df) * s.stderr_;
679 res.TNCI(i, r) = half;
680 if (res.TN.rows() > i && res.RN.rows() > i && res.UN(i, r) > 0.0 &&
681 res.TN(i, r) > 0.0)
682 res.UNCI(i, r) = half * res.UN(i, r) / res.TN(i, r);
683 }
684 }
685 }
686 }
687
688 // HOW LONG THE RUN SHOULD HAVE BEEN, when the caller asked for it. The
689 // half-widths just computed pin the ASYMPTOTIC variance of each estimator,
690 // which is the quantity a run length is planned from -- not the stationary
691 // variance, which on M/M/1 differs from it by a factor blowing up like
692 // (1-rho)^-2.
693 if (o.run_length_plan_precision > 0.0) {
694 // The ACTUAL number of events simulated where the result carries it,
695 // not the budget: LDES stops early on convergence, and planning from a
696 // budget it never spent would overstate N and so overstate sigma^2.
697 const double used = res.total_simulated_events > 0
698 ? static_cast<double>(res.total_simulated_events)
699 : static_cast<double>(o.events > 0 ? o.events : o.samples);
700 if (used > 0.0) {
702 res.QN, res.QNCI, used, o.run_length_plan_precision, o.confint);
703 res.has_run_length_plan = true;
704 }
705 }
706}
707
708} // namespace engine
709} // namespace ldes
710} // namespace line
711
712#endif // LINE_SOLVERS_LDES_LDES_STATS_H
std::size_t rows() const
Definition matrix.h:89
Convergence-based stopping: batch the four metrics and stop once EVERY active (station,...
Definition ldes_stats.h:516
bool converged(const std::vector< std::vector< bool > > &off) const
True once every active pair has reached the tolerance on all four metrics.
Definition ldes_stats.h:566
std::uint64_t interval() const
Definition ldes_stats.h:540
void init(std::size_t M, std::size_t K, const LdesOptions &o, std::uint64_t max_events)
Definition ldes_stats.h:518
void finalize_batch(const Accum &acc, const std::vector< std::size_t > &nservers, double now)
Close the current batch at now and record one batch mean per metric.
Definition ldes_stats.h:543
The option and result records of SolverLDES, the discrete-event simulator.
Dense matrix and non-owning view.
StatTriple bm_statistics(const std::vector< double > &obs, int batch_size)
Non-overlapping batch means.
Definition ldes_stats.h:304
StatTriple spectral_statistics(const std::vector< double > &obs, int batch_size, double low_freq_frac)
Heidelberger-Welch spectral estimate of the variance of the sample mean.
Definition ldes_stats.h:415
void batch_means_ci(const Observations &obs, const Truncation &tr, const LdesOptions &o, LdesResult &res)
Fill the half-width matrices of res from the post-warmup observation series.
Definition ldes_stats.h:646
StatTriple ci_statistics(const std::vector< double > &obs, int batch_size, const LdesOptions &o)
Dispatch on cimethod.
Definition ldes_stats.h:494
StatTriple obm_statistics(const std::vector< double > &obs, int batch_size, double overlap)
Overlapping batch means.
Definition ldes_stats.h:332
double t_critical(double level, int df)
The two-sided t critical value, from the REFERENCE'S TABLE.
Definition ldes_stats.h:376
double overlap_adjustment(double overlap)
The variance inflation of OVERLAPPING batch means.
Definition ldes_stats.h:297
int mser5_truncation(const std::vector< double > &obs, int batch_size)
MSER-5 truncation point over a series of observations, in BATCHES.
Definition ldes_stats.h:137
RunLengthPlan< T > sim_runlength_plan(const Matrix< T > &means, const Matrix< T > &ciHalfWidth, const T &samplesUsed, const T &relPrecision=num_traits< T >::from_rational(1, 20), const T &confidence=num_traits< T >::from_rational(19, 20))
How long a simulation run should have been, from the one it already did.
The knobs of one LDES run.
int ciminbatch
–ciminbatch
double spectral_low_freq_frac
–spectrallowfreqfrac
double cnvgtol
–cnvgtol
double confint
Confidence level of the reported half-widths.
double obmoverlap
–obmoverlap; 0 reduces OBM to plain batch means
double run_length_plan_precision
options.config.runLengthPlan: ask for the run length this run SHOULD have had, for a target relative ...
std::string cimethod
–cimethod: obm | bm | spectral | none
int cnvgchk
–cnvgchk, events between checks; 0 = samples/50
int ciminobs
–ciminobs, below which no CI is reported
std::size_t events
0 = not given; overrides samples when set
int cnvgbatch
–cnvgbatch, batches before the first check
std::size_t samples
-s, service-completion budget
One ldes-result document, parsed.
sim::RunLengthPlan< double > run_length_plan
Matrix< double > TNCI
bool has_run_length_plan
options.config.runLengthPlan: the run length the caller would need for the precision they asked for,...
Matrix< double > UN
Matrix< double > TN
Matrix< double > UNCI
Matrix< double > RN
Matrix< double > QNCI
long long total_simulated_events
Matrix< double > RNCI
Matrix< double > QN
The running per-(station, class) integrals and tallies.
Definition ldes_stats.h:46
Accum(std::size_t M, std::size_t K)
Definition ldes_stats.h:47
void update_busy(std::size_t i, std::size_t r, double now)
Advance the busy-server integral of (i,r) to now.
Definition ldes_stats.h:77
std::vector< std::vector< double > > busy
Definition ldes_stats.h:96
std::vector< double > busy_scale
The speed each station is running at right now (load dependence only), and the peak capacity that nor...
Definition ldes_stats.h:109
std::vector< std::vector< double > > last_qlen
Definition ldes_stats.h:98
std::vector< std::vector< double > > resp_cnt
Definition ldes_stats.h:110
std::vector< std::vector< double > > qlen
Definition ldes_stats.h:96
std::vector< std::vector< double > > tot_qlen
Definition ldes_stats.h:97
std::vector< std::vector< double > > completed
Definition ldes_stats.h:110
std::vector< std::vector< double > > resp_sum
Definition ldes_stats.h:110
std::vector< std::vector< double > > join_dropped
Siblings a QUORUM Join discarded, per (station, class): a sibling that reaches the Join after its par...
Definition ldes_stats.h:126
void update_qlen(std::size_t i, std::size_t r, double now)
Advance the queue-length integral of (i,r) to now.
Definition ldes_stats.h:63
void set_busy_scale(std::size_t i, double v, double now)
Install the load-dependent speed station i runs at from now on.
Definition ldes_stats.h:90
std::vector< std::vector< double > > tot_busy
Definition ldes_stats.h:97
std::vector< std::vector< double > > arrived
Jobs that ARRIVED at each (station, class), which is not what completed counts: the reference reports...
Definition ldes_stats.h:118
std::vector< std::vector< double > > last_busy
Definition ldes_stats.h:98
std::vector< double > util_peak
Definition ldes_stats.h:109
The event-spaced observation series MSER-5 and the CI both read.
Definition ldes_stats.h:180
Truncation truncate() const
The truncation point, on the AGGREGATE queue length first.
Definition ldes_stats.h:236
std::vector< std::vector< double > > last_qt
Definition ldes_stats.h:273
std::vector< std::vector< std::vector< double > > > bt
Definition ldes_stats.h:272
std::vector< std::vector< std::vector< double > > > qlen
Definition ldes_stats.h:272
std::vector< std::vector< std::vector< double > > > cmp
Definition ldes_stats.h:272
std::vector< char > in_mser
Whether station i feeds the truncation criterion; every one is still recorded.
Definition ldes_stats.h:275
std::vector< double > time
Definition ldes_stats.h:271
void collect(const Accum &acc, double now)
Record one observation.
Definition ldes_stats.h:204
std::vector< std::vector< std::vector< double > > > drp
Definition ldes_stats.h:272
std::vector< std::vector< std::vector< double > > > qt
Definition ldes_stats.h:272
Observations(std::size_t M, std::size_t K, bool mser_on, int batch)
Definition ldes_stats.h:181
Grand mean, standard error and degrees of freedom of a batch-means estimate.
Definition ldes_stats.h:281
Where the warmup ended, and whether a truncation was applied at all.
Definition ldes_stats.h:173
double warmup_end
the instant of that observation
Definition ldes_stats.h:175
std::size_t index
observation index of the truncation point
Definition ldes_stats.h:174