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
40#include "line/util/matrix.h"
41
42namespace line {
43namespace ldes {
44namespace engine {
45
46/** The running per-(station, class) integrals and tallies. */
47struct Accum {
48 Accum(std::size_t M, std::size_t K)
49 : qlen(M, std::vector<double>(K, 0.0)),
50 held(M, std::vector<double>(K, 0.0)),
51 busy(M, std::vector<double>(K, 0.0)),
52 tot_qlen(M, std::vector<double>(K, 0.0)),
53 tot_busy(M, std::vector<double>(K, 0.0)),
54 last_qlen(M, std::vector<double>(K, 0.0)),
55 last_busy(M, std::vector<double>(K, 0.0)),
56 busy_scale(M, 1.0),
57 util_peak(M, 1.0),
58 resp_sum(M, std::vector<double>(K, 0.0)),
59 resp_cnt(M, std::vector<double>(K, 0.0)),
60 resp_sq(M, std::vector<double>(K, 0.0)),
61 completed(M, std::vector<double>(K, 0.0)),
62 arrived(M, std::vector<double>(K, 0.0)),
63 join_dropped(M, std::vector<double>(K, 0.0)) {}
64
65 /** Advance the queue-length integral of (i,r) to `now`. Call BEFORE writing qlen. */
66 void update_qlen(std::size_t i, std::size_t r, double now) {
67 const double dt = now - last_qlen[i][r];
68 if (dt > 0.0) tot_qlen[i][r] += (qlen[i][r] + held[i][r]) * dt;
69 last_qlen[i][r] = now;
70 }
71 /**
72 * Advance the busy-server integral of (i,r) to `now`. Call BEFORE writing busy.
73 *
74 * The integrand is the WORK being delivered, `busy * busy_scale`, not the
75 * head count: a load-dependent server running alpha(n) times faster does the
76 * same work in less time, so plain busy TIME reads it as no busier than one
77 * at its nominal rate. `busy_scale` is 1 unless the station is load
78 * dependent, so this is the identity everywhere else.
79 */
80 void update_busy(std::size_t i, std::size_t r, double now) {
81 const double dt = now - last_busy[i][r];
82 if (dt > 0.0) tot_busy[i][r] += busy[i][r] * busy_scale[i] * dt;
83 last_busy[i][r] = now;
84 }
85 /**
86 * Install the load-dependent speed station `i` runs at from `now` on.
87 *
88 * Flushes every class FIRST, so the interval that just ended is credited at
89 * the scale that was actually in force over it. The engine calls this after
90 * the population is written, from the same chokepoint that re-times the
91 * departures.
92 */
93 void set_busy_scale(std::size_t i, double v, double now) {
94 if (v == busy_scale[i]) return;
95 for (std::size_t r = 0; r < busy[i].size(); ++r) update_busy(i, r, now);
96 busy_scale[i] = v;
97 }
98
99 std::vector<std::vector<double>> qlen;
100 /**
101 * Callers parked on their server awaiting a synchronous REPLY (sn.syncreply), per (station, class).
102 *
103 * They are counted in the QLen INTEGRAL, as the reference counts currentBlockedServers in
104 * Solver_ssj.updateQueueStats, but kept OUT of `qlen`, which every decision reads: capacity,
105 * load-dependent rates, JSQ and setup all use currentQueueLength there, which excludes them.
106 * Written only after update_qlen, like qlen.
107 */
108 std::vector<std::vector<double>> held;
109 std::vector<std::vector<double>> busy;
110 std::vector<std::vector<double>> tot_qlen, tot_busy;
111 std::vector<std::vector<double>> last_qlen, last_busy;
112 /**
113 * The speed each station is running at right now (load dependence only),
114 * and the peak capacity that normalizes its utilization,
115 * max(c, max(alpha)).
116 *
117 * `util_peak` is CTMC's own `ceff`, which is what makes LDES report the same
118 * utilization as the analytic solvers on a load-dependent station. Both are
119 * 1 and c respectively when the station is not load dependent, leaving every
120 * other model's numbers untouched.
121 */
122 std::vector<double> busy_scale, util_peak;
123 std::vector<std::vector<double>> resp_sum, resp_cnt, completed;
124 /** Sum of squared response times, for the iid interval the reference takes off its response-time Tally. */
125 std::vector<std::vector<double>> resp_sq;
126 /** Tally one response time `x` at (i,r): the sum, its square and the count move together. */
127 void tally_resp(std::size_t i, std::size_t r, double x) {
128 resp_sum[i][r] += x;
129 resp_sq[i][r] += x * x;
130 resp_cnt[i][r] += 1.0;
131 }
132 /**
133 * Jobs that ARRIVED at each (station, class), which is not what `completed`
134 * counts: the reference reports AN as the arrivals after the truncation point
135 * over the truncated window (cumulative, differenced like `completed`), and the
136 * two differ by whatever is still in the station at the end and by anything
137 * dropped, balked or reneged. Reporting completions as the arrival rate
138 * would hide exactly the loss those features exist to measure.
139 */
140 std::vector<std::vector<double>> arrived;
141 /**
142 * Siblings a QUORUM Join discarded, per (station, class): a sibling that
143 * reaches the Join after its parent already fired lost the race and is
144 * thrown away. It is a completion of nothing, so it belongs neither in
145 * `completed` nor in `arrived`, and it is what a Join row's loss rate is.
146 * Cumulative, and differenced against the truncation point like `completed`.
147 */
148 std::vector<std::vector<double>> join_dropped;
149};
150
151/**
152 * MSER-5 truncation point over a series of observations, in BATCHES.
153 *
154 * Returns the batch index d minimising var(Z_{d..N-1}) / (N-d)^2 over the
155 * batch means Z, and 0 when there are fewer than four batches -- below that
156 * the criterion is estimated from too few terms to be a truncation rule.
157 * Transcribes `computeMSER5TruncationPoint`.
158 */
159inline int mser5_truncation(const std::vector<double>& obs, int batch_size) {
160 const std::size_t n = obs.size();
161 if (batch_size <= 0 || n < static_cast<std::size_t>(batch_size) * 4) return 0;
162 const int num_batches = static_cast<int>(n / static_cast<std::size_t>(batch_size));
163 std::vector<double> means(static_cast<std::size_t>(num_batches), 0.0);
164 for (int j = 0; j < num_batches; ++j) {
165 double sum = 0.0;
166 for (int i = 0; i < batch_size; ++i)
167 sum += obs[static_cast<std::size_t>(j * batch_size + i)];
168 means[static_cast<std::size_t>(j)] = sum / batch_size;
169 }
170 double min_mser = std::numeric_limits<double>::max();
171 int optimal_d = 0;
172 const int max_d = num_batches / 2;
173 for (int d = 0; d < max_d; ++d) {
174 const int remaining = num_batches - d;
175 if (remaining < 2) break;
176 double sum = 0.0;
177 for (int j = d; j < num_batches; ++j) sum += means[static_cast<std::size_t>(j)];
178 const double mean = sum / remaining;
179 double variance = 0.0;
180 for (int j = d; j < num_batches; ++j) {
181 const double diff = means[static_cast<std::size_t>(j)] - mean;
182 variance += diff * diff;
183 }
184 variance /= (remaining - 1);
185 const double mser = variance / (static_cast<double>(remaining) * remaining);
186 if (mser < min_mser) {
187 min_mser = mser;
188 optimal_d = d;
189 }
190 }
191 return optimal_d;
192}
193
194/** Where the warmup ended, and whether a truncation was applied at all. */
196 std::size_t index = 0; ///< observation index of the truncation point
197 double warmup_end = 0.0; ///< the instant of that observation
198 bool applied = false;
199};
200
201/** The event-spaced observation series MSER-5 and the CI both read. */
203 Observations(std::size_t M, std::size_t K, bool mser_on, int batch)
204 : enabled(mser_on),
205 batch_size(batch),
206 qlen(M, std::vector<std::vector<double>>(K)),
207 qt(M, std::vector<std::vector<double>>(K)),
208 bt(M, std::vector<std::vector<double>>(K)),
209 cmp(M, std::vector<std::vector<double>>(K)),
210 drp(M, std::vector<std::vector<double>>(K)),
211 arv(M, std::vector<std::vector<double>>(K)),
212 last_qt(M, std::vector<double>(K, 0.0)),
213 in_mser(M, static_cast<char>(1)),
214 nstations(M),
215 nclasses(K) {}
216
217 /**
218 * Record one observation.
219 *
220 * The queue-length entry is the INTERVAL TIME-AVERAGE, not the instantaneous
221 * count: it is the integral delivered since the previous observation over
222 * the interval length, which is what makes a sequence of them comparable
223 * even though the intervals differ in duration. The other three are
224 * CUMULATIVE, because the estimators difference them against the truncation
225 * point rather than averaging them.
226 */
227 void collect(const Accum& acc, double now) {
228 const double dt = now - last_time;
229 time.push_back(now);
230 for (std::size_t i = 0; i < nstations; ++i)
231 for (std::size_t r = 0; r < nclasses; ++r) {
232 if (dt > 0.0) {
233 qlen[i][r].push_back((acc.tot_qlen[i][r] - last_qt[i][r]) / dt);
234 last_qt[i][r] = acc.tot_qlen[i][r];
235 } else {
236 qlen[i][r].push_back(acc.qlen[i][r]);
237 }
238 qt[i][r].push_back(acc.tot_qlen[i][r]);
239 bt[i][r].push_back(acc.tot_busy[i][r]);
240 cmp[i][r].push_back(acc.completed[i][r]);
241 drp[i][r].push_back(acc.join_dropped[i][r]);
242 arv[i][r].push_back(acc.arrived[i][r]);
243 }
244 last_time = now;
245 }
246
247 /**
248 * The truncation point, on the AGGREGATE queue length first.
249 *
250 * The per-series fallback is not a refinement, it is the closed-network
251 * case: a closed model holds a constant total population, so the aggregate
252 * series is flat and carries no transient signal at all, however long the
253 * warmup actually was. The most conservative per-series point is taken then.
254 *
255 * BOTH RUN OVER THE SERVICE STATIONS ONLY, which is the range
256 * `applyMSER5Truncation` walks. A Join is measured like a station but is
257 * not one of them, and folding its series in would move the truncation
258 * point of every fork-join model away from the reference's.
259 */
261 Truncation t;
262 if (!enabled || time.empty()) return t;
263 std::vector<double> aggregate(time.size(), 0.0);
264 for (std::size_t k = 0; k < time.size(); ++k) {
265 double tot = 0.0;
266 for (std::size_t i = 0; i < nstations; ++i) {
267 if (!in_mser[i]) continue;
268 for (std::size_t r = 0; r < nclasses; ++r)
269 if (k < qlen[i][r].size()) tot += qlen[i][r][k];
270 }
271 aggregate[k] = tot;
272 }
273 int batch = mser5_truncation(aggregate, batch_size);
274 if (batch == 0) {
275 for (std::size_t i = 0; i < nstations; ++i) {
276 if (!in_mser[i]) continue;
277 for (std::size_t r = 0; r < nclasses; ++r) {
278 const int b = mser5_truncation(qlen[i][r], batch_size);
279 if (b > batch) batch = b;
280 }
281 }
282 }
283 const std::size_t idx =
284 static_cast<std::size_t>(batch) * static_cast<std::size_t>(batch_size);
285 if (idx < time.size()) {
286 t.index = idx;
287 t.warmup_end = time[idx];
288 t.applied = true;
289 }
290 return t;
291 }
292
293 bool enabled = true;
294 int batch_size = 5;
295 std::vector<double> time;
296 std::vector<std::vector<std::vector<double>>> qlen, qt, bt, cmp, drp, arv;
297 std::vector<std::vector<double>> last_qt;
298 /// Whether station i feeds the truncation criterion; every one is still recorded.
299 std::vector<char> in_mser;
300 double last_time = 0.0;
301 std::size_t nstations = 0, nclasses = 0;
302};
303
304/** Grand mean, standard error and degrees of freedom of a batch-means estimate. */
306 double mean = 0.0;
307 double stderr_ = 0.0;
308 int df = 0;
309 bool ok = false;
310};
311
312/**
313 * The variance inflation of OVERLAPPING batch means.
314 *
315 * Overlapping batches are positively correlated, so the naive sample variance
316 * of their means underestimates the true one; 4/3 is the standard asymptotic
317 * correction at 50% overlap, interpolated linearly below it and reducing to 1
318 * at zero overlap, where the batches are disjoint and no correction is due.
319 * Transcribes `computeOverlapAdjustmentFactor`.
320 */
321inline double overlap_adjustment(double overlap) {
322 if (overlap <= 0.0) return 1.0;
323 if (overlap >= 0.5) return 4.0 / 3.0;
324 return 1.0 + (overlap / 0.5) * (4.0 / 3.0 - 1.0);
325}
326
327/** Non-overlapping batch means. Transcribes `computeBMStatisticsInternal`. */
328inline StatTriple bm_statistics(const std::vector<double>& obs, int batch_size) {
329 StatTriple s;
330 const std::size_t n = obs.size();
331 const int nb = (batch_size > 0) ? static_cast<int>(n / static_cast<std::size_t>(batch_size)) : 0;
332 if (nb < 2) return s;
333 std::vector<double> means(static_cast<std::size_t>(nb), 0.0);
334 for (int i = 0; i < nb; ++i) {
335 double sum = 0.0;
336 for (int j = 0; j < batch_size; ++j)
337 sum += obs[static_cast<std::size_t>(i * batch_size + j)];
338 means[static_cast<std::size_t>(i)] = sum / batch_size;
339 }
340 double grand = 0.0;
341 for (int i = 0; i < nb; ++i) grand += means[static_cast<std::size_t>(i)];
342 grand /= nb;
343 double ss = 0.0;
344 for (int i = 0; i < nb; ++i) {
345 const double d = means[static_cast<std::size_t>(i)] - grand;
346 ss += d * d;
347 }
348 s.mean = grand;
349 s.stderr_ = std::sqrt((ss / (nb - 1)) / nb);
350 s.df = nb - 1;
351 s.ok = true;
352 return s;
353}
354
355/** Overlapping batch means. Transcribes `computeOBMStatisticsInternal`. */
356inline StatTriple obm_statistics(const std::vector<double>& obs, int batch_size, double overlap) {
357 StatTriple s;
358 const std::size_t n = obs.size();
359 if (batch_size <= 0 || n < static_cast<std::size_t>(batch_size) * 2) return s;
360 int step = static_cast<int>(batch_size * (1.0 - overlap));
361 if (step < 1) step = 1;
362 const int nb = static_cast<int>((n - static_cast<std::size_t>(batch_size)) /
363 static_cast<std::size_t>(step)) + 1;
364 if (nb < 2) return s;
365 std::vector<double> means(static_cast<std::size_t>(nb), 0.0);
366 for (int i = 0; i < nb; ++i) {
367 const std::size_t start = static_cast<std::size_t>(i) * static_cast<std::size_t>(step);
368 double sum = 0.0;
369 for (int j = 0; j < batch_size; ++j)
370 if (start + static_cast<std::size_t>(j) < n) sum += obs[start + static_cast<std::size_t>(j)];
371 means[static_cast<std::size_t>(i)] = sum / batch_size;
372 }
373 double grand = 0.0;
374 for (int i = 0; i < nb; ++i) grand += means[static_cast<std::size_t>(i)];
375 grand /= nb;
376 double ss = 0.0;
377 for (int i = 0; i < nb; ++i) {
378 const double d = means[static_cast<std::size_t>(i)] - grand;
379 ss += d * d;
380 }
381 const double adj = overlap_adjustment(overlap);
382 s.mean = grand;
383 s.stderr_ = std::sqrt((adj * ss / (nb - 1)) / nb);
384 s.df = static_cast<int>((nb - 1) / adj);
385 if (s.df < 1) s.df = 1;
386 s.ok = true;
387 return s;
388}
389
390/**
391 * The two-sided t critical value at confidence `level` with `df` degrees of freedom: the EXACT Student-t
392 * quantile t_{(1+level)/2, df}, as the Java engine's `Solver_ssj.getTCriticalValue` computes it.
393 *
394 * Both engines used a three-decimal table, bucketed to the 90/95/99% levels and saturating at df = 30, until
395 * 2026-10-28; that moved every half-width by parts in a thousand and kept df > 30 from tending to the normal
396 * quantile. Below one degree of freedom there is no t law, and the normal quantile at the same level stands in.
397 */
398inline double t_critical(double level, int df) {
399 const double p = 0.5 + 0.5 * level;
400 if (df < 1) return sim::sim_norminv(p);
401 return sim::sim_tinv(p, static_cast<double>(df));
402}
403
404
405/**
406 * Heidelberger-Welch spectral estimate of the variance of the sample mean.
407 *
408 * The variance of a mean over a correlated series is 2*pi*S(0)/m, with S(0)
409 * the spectral density at frequency zero. S(0) cannot be read off the
410 * periodogram directly -- the ordinate at f=0 is exactly the quantity being
411 * estimated and carries all the bias -- so the LOG-periodogram over the lowest
412 * frequencies is fitted with a quadratic and the intercept extrapolated back
413 * to zero. That is what makes this estimator different from batch means rather
414 * than a re-spelling of it: it models the correlation instead of trying to
415 * batch it away.
416 *
417 * FALLS BACK TO PLAIN BATCH MEANS, not to an error, whenever the fit cannot be
418 * trusted: too few batches, too few regression points, a zero periodogram
419 * ordinate (log undefined) or a non-positive intercept. A spectral estimate
420 * from a singular fit is not conservative, it is arbitrary. Transcribes
421 * `computeSpectralStatisticsInternal`.
422 */
423inline StatTriple spectral_statistics(const std::vector<double>& obs, int batch_size,
424 double low_freq_frac) {
425 const std::size_t n = obs.size();
426 const int m = (batch_size > 0) ? static_cast<int>(n / static_cast<std::size_t>(batch_size)) : 0;
427 if (m < 4) return bm_statistics(obs, batch_size);
428
429 std::vector<double> means(static_cast<std::size_t>(m), 0.0);
430 for (int i = 0; i < m; ++i) {
431 double sum = 0.0;
432 for (int j = 0; j < batch_size; ++j)
433 sum += obs[static_cast<std::size_t>(i * batch_size + j)];
434 means[static_cast<std::size_t>(i)] = sum / batch_size;
435 }
436 double grand = 0.0;
437 for (int i = 0; i < m; ++i) grand += means[static_cast<std::size_t>(i)];
438 grand /= m;
439
440 const int nf = m / 2;
441 if (nf < 2) return bm_statistics(obs, batch_size);
442 std::vector<double> per(static_cast<std::size_t>(nf), 0.0);
443 for (int k = 1; k <= nf; ++k) {
444 double re = 0.0, im = 0.0;
445 const double f = 2.0 * 3.14159265358979323846 * k / m;
446 for (int t = 0; t < m; ++t) {
447 const double c = means[static_cast<std::size_t>(t)] - grand;
448 re += c * std::cos(f * t);
449 im += c * std::sin(f * t);
450 }
451 per[static_cast<std::size_t>(k - 1)] = (re * re + im * im) / m;
452 }
453
454 int npts = static_cast<int>(nf * low_freq_frac);
455 if (npts < 3) npts = 3;
456 if (npts > nf) return bm_statistics(obs, batch_size);
457 for (int k = 0; k < npts; ++k)
458 if (!(per[static_cast<std::size_t>(k)] > 0.0)) return bm_statistics(obs, batch_size);
459
460 // Ordinary least squares for log I(f) = b0 + b1 f + b2 f^2, solved through
461 // the 3x3 normal equations by Gaussian elimination: the design is tiny and
462 // pulling in a general solver here would only add a failure mode.
463 double A[3][4];
464 for (int r = 0; r < 3; ++r)
465 for (int c = 0; c < 4; ++c) A[r][c] = 0.0;
466 for (int k = 0; k < npts; ++k) {
467 const double f = 2.0 * 3.14159265358979323846 * (k + 1) / m;
468 const double x[3] = {1.0, f, f * f};
469 const double y = std::log(per[static_cast<std::size_t>(k)]);
470 for (int r = 0; r < 3; ++r) {
471 for (int c = 0; c < 3; ++c) A[r][c] += x[r] * x[c];
472 A[r][3] += x[r] * y;
473 }
474 }
475 for (int i = 0; i < 3; ++i) {
476 int piv = i;
477 for (int r = i + 1; r < 3; ++r)
478 if (std::fabs(A[r][i]) > std::fabs(A[piv][i])) piv = r;
479 if (!(std::fabs(A[piv][i]) > 1e-300)) return bm_statistics(obs, batch_size);
480 if (piv != i)
481 for (int c = 0; c < 4; ++c) std::swap(A[i][c], A[piv][c]);
482 for (int r = 0; r < 3; ++r) {
483 if (r == i) continue;
484 const double fct = A[r][i] / A[i][i];
485 for (int c = i; c < 4; ++c) A[r][c] -= fct * A[i][c];
486 }
487 }
488 const double b0 = A[0][3] / A[0][0];
489 const double s0 = std::exp(b0);
490 if (!(s0 > 0.0) || !std::isfinite(s0)) return bm_statistics(obs, batch_size);
491
492 StatTriple st;
493 st.mean = grand;
494 st.stderr_ = std::sqrt(2.0 * 3.14159265358979323846 * s0 / m);
495 st.df = npts - 3;
496 if (st.df < 1) st.df = 1;
497 st.ok = true;
498 return st;
499}
500
501/** Dispatch on `cimethod`. */
502inline StatTriple ci_statistics(const std::vector<double>& obs, int batch_size,
503 const LdesOptions& o) {
504 if (o.cimethod == "bm") return bm_statistics(obs, batch_size);
505 if (o.cimethod == "spectral")
506 return spectral_statistics(obs, batch_size, o.spectral_low_freq_frac);
507 return obm_statistics(obs, batch_size, o.obmoverlap);
508}
509
510
511/**
512 * Convergence-based stopping: batch the four metrics and stop once EVERY
513 * active (station, class) pair has reached the requested relative precision.
514 *
515 * THE CONJUNCTION IS THE POINT. A run stops when the WORST metric at the WORST
516 * pair is precise enough, not when the aggregate is: stopping on an average
517 * relative precision leaves the lightly loaded stations, whose estimates
518 * converge slowest, arbitrarily wrong while the report looks converged.
519 *
520 * A pair whose mean is effectively zero counts as converged: a relative
521 * precision against zero is not a bound, it is a division. Transcribes
522 * `checkConvergence` and `isMetricConverged`.
523 */
525public:
526 void init(std::size_t M, std::size_t K, const LdesOptions& o, std::uint64_t max_events) {
527 enabled_ = o.cnvgon;
528 if (!enabled_) return;
529 tol_ = o.cnvgtol;
530 min_batches_ = o.cnvgbatch;
531 interval_ = (o.cnvgchk > 0) ? static_cast<std::uint64_t>(o.cnvgchk)
532 : std::max<std::uint64_t>(1, max_events / 50);
533 level_ = (o.confint > 0.0) ? o.confint : 0.95;
534 q_.assign(M, std::vector<std::vector<double>>(K));
535 u_.assign(M, std::vector<std::vector<double>>(K));
536 r_.assign(M, std::vector<std::vector<double>>(K));
537 t_.assign(M, std::vector<std::vector<double>>(K));
538 start_qt_.assign(M, std::vector<double>(K, 0.0));
539 start_bt_.assign(M, std::vector<double>(K, 0.0));
540 start_cmp_.assign(M, std::vector<double>(K, 0.0));
541 start_rsum_.assign(M, std::vector<double>(K, 0.0));
542 start_rcnt_.assign(M, std::vector<double>(K, 0.0));
543 nstations_ = M;
544 nclasses_ = K;
545 }
546
547 bool enabled() const { return enabled_; }
548 std::uint64_t interval() const { return interval_; }
549
550 /** Close the current batch at `now` and record one batch mean per metric. */
551 void finalize_batch(const Accum& acc, const std::vector<std::size_t>& nservers, double now) {
552 if (!enabled_) return;
553 const double dur = now - batch_start_;
554 if (!(dur > 0.0)) {
555 reset_batch(acc, now);
556 return;
557 }
558 for (std::size_t i = 0; i < nstations_; ++i)
559 for (std::size_t k = 0; k < nclasses_; ++k) {
560 const double ql = (acc.tot_qlen[i][k] - start_qt_[i][k]) / dur;
561 q_[i][k].push_back(ql);
562 const double c = acc.util_peak[i] > 0.0
563 ? acc.util_peak[i]
564 : static_cast<double>(nservers[i] > 0 ? nservers[i] : 1);
565 u_[i][k].push_back((acc.tot_busy[i][k] - start_bt_[i][k]) / (dur * c));
566 t_[i][k].push_back((acc.completed[i][k] - start_cmp_[i][k]) / dur);
567 const double dn = acc.resp_cnt[i][k] - start_rcnt_[i][k];
568 r_[i][k].push_back(dn > 0.0 ? (acc.resp_sum[i][k] - start_rsum_[i][k]) / dn : 0.0);
569 }
570 reset_batch(acc, now);
571 }
572
573 /** True once every active pair has reached the tolerance on all four metrics. */
574 bool converged(const std::vector<std::vector<bool>>& off) const {
575 if (!enabled_) return false;
576 if (nstations_ == 0 || nclasses_ == 0) return false;
577 if (static_cast<int>(q_[0][0].size()) < min_batches_) return false;
578 for (std::size_t i = 0; i < nstations_; ++i)
579 for (std::size_t k = 0; k < nclasses_; ++k) {
580 if (off[i][k]) continue;
581 if (!metric_ok(q_[i][k])) return false;
582 if (!metric_ok(u_[i][k])) return false;
583 if (!metric_ok(t_[i][k])) return false;
584 bool any_positive = false;
585 for (double v : r_[i][k])
586 if (v > 0.0) {
587 any_positive = true;
588 break;
589 }
590 if (any_positive && !metric_ok(r_[i][k])) return false;
591 }
592 return true;
593 }
594
595 int batches() const { return (nstations_ && nclasses_) ? static_cast<int>(q_[0][0].size()) : 0; }
596
597private:
598 void reset_batch(const Accum& acc, double now) {
599 batch_start_ = now;
600 for (std::size_t i = 0; i < nstations_; ++i)
601 for (std::size_t k = 0; k < nclasses_; ++k) {
602 start_qt_[i][k] = acc.tot_qlen[i][k];
603 start_bt_[i][k] = acc.tot_busy[i][k];
604 start_cmp_[i][k] = acc.completed[i][k];
605 start_rsum_[i][k] = acc.resp_sum[i][k];
606 start_rcnt_[i][k] = acc.resp_cnt[i][k];
607 }
608 }
609
610 bool metric_ok(const std::vector<double>& b) const {
611 const std::size_t n = b.size();
612 if (n < 2) return false;
613 double sum = 0.0;
614 for (double x : b) sum += x;
615 const double mean = sum / n;
616 // A relative precision against a zero mean is a division, not a bound.
617 if (std::fabs(mean) < 1e-12) return true;
618 double var = 0.0;
619 for (double x : b) var += (x - mean) * (x - mean);
620 var /= (n - 1);
621 const double se = std::sqrt(var / n);
622 const double half = t_critical(level_, static_cast<int>(n) - 1) * se;
623 return half / std::fabs(mean) <= tol_;
624 }
625
626 bool enabled_ = false;
627 double tol_ = 0.05, level_ = 0.95, batch_start_ = 0.0;
628 int min_batches_ = 20;
629 std::uint64_t interval_ = 1;
630 std::size_t nstations_ = 0, nclasses_ = 0;
631 std::vector<std::vector<std::vector<double>>> q_, u_, r_, t_;
632 std::vector<std::vector<double>> start_qt_, start_bt_, start_cmp_, start_rsum_, start_rcnt_;
633};
634
635/**
636 * Fill the half-width matrices of `res` from the post-warmup observation
637 * series. Transcribes `computeOBMConfidenceIntervals`.
638 *
639 * The three series are not treated alike, and that is the reference's design:
640 *
641 * - QUEUE LENGTH has one observation per interval already, so it batches
642 * directly;
643 * - THROUGHPUT is stored CUMULATIVE, so it is differenced into per-interval
644 * rates first -- batching the cumulative counts would estimate the variance
645 * of a random walk;
646 * - UTILIZATION is not measured separately at all. Its interval is the
647 * throughput's, carried through the utilization law U = T*S/c (`u_law`, the
648 * S/c of each row, with c the peak capacity and 1 at a delay), as MATLAB's
649 * law and the JAR's TNCI/(mu*c) read it, not through the measured UN/TN;
650 * - RESPONSE TIME is not batched: as the reference's `responseTimeTally`, it is
651 * the iid interval t(n-1) * sd / sqrt(n) over the tallied response times,
652 * reported only above 30 of them, over the same tally `RN` is the mean of.
653 *
654 * A series shorter than `ciminobs` yields NO interval rather than a wide one:
655 * a half-width computed from a handful of batches is not conservative, it is
656 * arbitrary.
657 */
658inline void batch_means_ci(const Observations& obs, const Truncation& tr, const Accum& acc,
659 const LdesOptions& o, LdesResult& res, const Matrix<double>& u_law,
660 const std::vector<std::vector<bool>>* lost_unserved = nullptr) {
661 if (o.cimethod == "none" || !(o.confint > 0.0) || obs.time.empty()) return;
662 const std::size_t M = obs.nstations, K = obs.nclasses;
663 res.QNCI = Matrix<double>(M, K, 0.0);
664 res.UNCI = Matrix<double>(M, K, 0.0);
665 res.RNCI = Matrix<double>(M, K, 0.0);
666 res.TNCI = Matrix<double>(M, K, 0.0);
667 const std::size_t t0 = tr.applied ? tr.index : 0;
668
669 for (std::size_t i = 0; i < M; ++i) {
670 for (std::size_t r = 0; r < K; ++r) {
671 if (t0 >= obs.qlen[i][r].size()) continue;
672 const std::vector<double> q(obs.qlen[i][r].begin() + static_cast<std::ptrdiff_t>(t0),
673 obs.qlen[i][r].end());
674 if (q.size() >= static_cast<std::size_t>(o.ciminobs)) {
675 const int b = std::max(o.ciminbatch, static_cast<int>(std::sqrt(
676 static_cast<double>(q.size()))));
677 const StatTriple s = ci_statistics(q, b, o);
678 if (s.ok) res.QNCI(i, r) = t_critical(o.confint, s.df) * s.stderr_;
679 }
680
681 const bool little = lost_unserved != nullptr && (*lost_unserved)[i][r];
682 const double n = acc.resp_cnt[i][r];
683 if (!little && n > 30.0) {
684 const double var = (acc.resp_sq[i][r] - acc.resp_sum[i][r] * acc.resp_sum[i][r] / n) / (n - 1.0);
685 if (var > 0.0)
686 res.RNCI(i, r) = t_critical(o.confint, static_cast<int>(n) - 1) * std::sqrt(var / n);
687 }
688
689 std::vector<double> rates;
690 for (std::size_t k = t0 + 1; k < obs.cmp[i][r].size(); ++k) {
691 const double dt = obs.time[k] - obs.time[k - 1];
692 if (dt > 0.0) rates.push_back((obs.cmp[i][r][k] - obs.cmp[i][r][k - 1]) / dt);
693 }
694 if (rates.size() >= static_cast<std::size_t>(o.ciminobs)) {
695 const int b = std::max(o.ciminbatch, static_cast<int>(std::sqrt(
696 static_cast<double>(rates.size()))));
697 const StatTriple s = ci_statistics(rates, b, o);
698 if (s.ok) {
699 const double half = t_critical(o.confint, s.df) * s.stderr_;
700 res.TNCI(i, r) = half;
701 if (u_law.rows() > i && u_law(i, r) > 0.0) res.UNCI(i, r) = half * u_law(i, r);
702 }
703 }
704 // RN = QN/TN where jobs left unserved, so its half-width is the delta-method one from QNCI and TNCI, as
705 // the JAR propagates it, not the served-job tally's.
706 if (little && res.QN(i, r) > 0.0 && res.TN(i, r) > 0.0) {
707 const double rq = res.QNCI(i, r) / res.QN(i, r), rt = res.TNCI(i, r) / res.TN(i, r);
708 res.RNCI(i, r) = (res.QN(i, r) / res.TN(i, r)) * std::sqrt(rq * rq + rt * rt);
709 }
710 }
711 }
712
713 // HOW LONG THE RUN SHOULD HAVE BEEN, when the caller asked for it. The
714 // half-widths just computed pin the ASYMPTOTIC variance of each estimator,
715 // which is the quantity a run length is planned from -- not the stationary
716 // variance, which on M/M/1 differs from it by a factor blowing up like
717 // (1-rho)^-2.
718 if (o.run_length_plan_precision > 0.0) {
719 // The ACTUAL number of events simulated where the result carries it,
720 // not the budget: LDES stops early on convergence, and planning from a
721 // budget it never spent would overstate N and so overstate sigma^2.
722 const double used = res.total_simulated_events > 0
723 ? static_cast<double>(res.total_simulated_events)
724 : static_cast<double>(o.events > 0 ? o.events : o.samples);
725 if (used > 0.0) {
727 res.QN, res.QNCI, used, o.run_length_plan_precision, o.confint);
728 res.has_run_length_plan = true;
729 }
730 }
731}
732
733} // namespace engine
734} // namespace ldes
735} // namespace line
736
737#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:524
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:574
std::uint64_t interval() const
Definition ldes_stats.h:548
void init(std::size_t M, std::size_t K, const LdesOptions &o, std::uint64_t max_events)
Definition ldes_stats.h:526
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:551
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:328
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:423
StatTriple ci_statistics(const std::vector< double > &obs, int batch_size, const LdesOptions &o)
Dispatch on cimethod.
Definition ldes_stats.h:502
void batch_means_ci(const Observations &obs, const Truncation &tr, const Accum &acc, const LdesOptions &o, LdesResult &res, const Matrix< double > &u_law, const std::vector< std::vector< bool > > *lost_unserved=nullptr)
Fill the half-width matrices of res from the post-warmup observation series.
Definition ldes_stats.h:658
StatTriple obm_statistics(const std::vector< double > &obs, int batch_size, double overlap)
Overlapping batch means.
Definition ldes_stats.h:356
double t_critical(double level, int df)
The two-sided t critical value at confidence level with df degrees of freedom: the EXACT Student-t qu...
Definition ldes_stats.h:398
double overlap_adjustment(double overlap)
The variance inflation of OVERLAPPING batch means.
Definition ldes_stats.h:321
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:159
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.
double sim_norminv(double p)
Standard normal quantile function.
Definition sim_dist.h:62
double sim_tinv(double p, double nu)
Quantile function of Student's t distribution.
Definition sim_dist.h:77
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
Normal and Student t quantiles used by the output-analysis routines.
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 > TN
Matrix< double > UNCI
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:47
std::vector< std::vector< double > > resp_sq
Sum of squared response times, for the iid interval the reference takes off its response-time Tally.
Definition ldes_stats.h:125
void tally_resp(std::size_t i, std::size_t r, double x)
Tally one response time x at (i,r): the sum, its square and the count move together.
Definition ldes_stats.h:127
std::vector< std::vector< double > > held
Callers parked on their server awaiting a synchronous REPLY (sn.syncreply), per (station,...
Definition ldes_stats.h:108
Accum(std::size_t M, std::size_t K)
Definition ldes_stats.h:48
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:80
std::vector< std::vector< double > > busy
Definition ldes_stats.h:109
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:122
std::vector< std::vector< double > > last_qlen
Definition ldes_stats.h:111
std::vector< std::vector< double > > resp_cnt
Definition ldes_stats.h:123
std::vector< std::vector< double > > qlen
Definition ldes_stats.h:99
std::vector< std::vector< double > > tot_qlen
Definition ldes_stats.h:110
std::vector< std::vector< double > > completed
Definition ldes_stats.h:123
std::vector< std::vector< double > > resp_sum
Definition ldes_stats.h:123
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:148
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:66
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:93
std::vector< std::vector< double > > tot_busy
Definition ldes_stats.h:110
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:140
std::vector< std::vector< double > > last_busy
Definition ldes_stats.h:111
std::vector< double > util_peak
Definition ldes_stats.h:122
The event-spaced observation series MSER-5 and the CI both read.
Definition ldes_stats.h:202
Truncation truncate() const
The truncation point, on the AGGREGATE queue length first.
Definition ldes_stats.h:260
std::vector< std::vector< double > > last_qt
Definition ldes_stats.h:297
std::vector< std::vector< std::vector< double > > > bt
Definition ldes_stats.h:296
std::vector< std::vector< std::vector< double > > > qlen
Definition ldes_stats.h:296
std::vector< std::vector< std::vector< double > > > cmp
Definition ldes_stats.h:296
std::vector< char > in_mser
Whether station i feeds the truncation criterion; every one is still recorded.
Definition ldes_stats.h:299
std::vector< double > time
Definition ldes_stats.h:295
void collect(const Accum &acc, double now)
Record one observation.
Definition ldes_stats.h:227
std::vector< std::vector< std::vector< double > > > drp
Definition ldes_stats.h:296
std::vector< std::vector< std::vector< double > > > qt
Definition ldes_stats.h:296
Observations(std::size_t M, std::size_t K, bool mser_on, int batch)
Definition ldes_stats.h:203
std::vector< std::vector< std::vector< double > > > arv
Definition ldes_stats.h:296
Grand mean, standard error and degrees of freedom of a batch-means estimate.
Definition ldes_stats.h:305
Where the warmup ended, and whether a truncation was applied at all.
Definition ldes_stats.h:195
double warmup_end
the instant of that observation
Definition ldes_stats.h:197
std::size_t index
observation index of the truncation point
Definition ldes_stats.h:196