LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
pfqn_sjn.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_API_PFQN_SJN_H
6#define LINE_API_PFQN_SJN_H
7
8/**
9 * @file
10 * @ingroup api_pfqn
11 * Closed networks with non-preemptive shortest-job-next (SJN/SJF) stations.
12 *
13 * Port of matlab/src/api/pfqn/pfqn_mvasjn.m and pfqn_amvasjn.m together with
14 * the six private helpers they share (sjn_args, sjn_fit, sjn_setup, sjn_quad,
15 * sjn_station, sjn_cap), cross-checked against
16 * jar/src/main/java/jline/api/pfqn/mva/{Pfqn_mvasjn,Pfqn_amvasjn,SjnSupport}.java.
17 *
18 * THE EQUATION. A tagged customer whose service requirement is x waits, by the
19 * arrival theorem, for the residual life of the job in service, for the work of
20 * the queued jobs that will be served before it, and for the work of the jobs
21 * that overtake it while it waits (Kant 1992, eqs. 1-7):
22 *
23 * W(x,n) = [ (1+CV^2) s U(n-1)/2 + X(n-1) phi(x,n-1) ] / [ 1 - X(n-1) theta(x) ]
24 * theta(x) = int_0^x t f(t) dt, phi(x,n) = int_0^x W(t,n) t f(t) dt
25 * R(n) = s + int_0^inf W(x,n) f(x) dx
26 *
27 * W(.,n) needs only phi(.,n-1), so the profile is carried alongside the
28 * population recursion. `pfqn_mvasjn` does exactly that over the whole lattice;
29 * `pfqn_amvasjn` replaces the lattice by a Schweitzer closure applied to the
30 * SIZE-RESOLVED queue length lam_k W_k(x) f_k(x) rather than to its integral,
31 * which is why the two share `sjn_station` unchanged and differ only in the
32 * deflation vector beta handed to it.
33 *
34 * THE SIZE DENSITY IS NOT AN INPUT. Only its mean and SCV are, and the density
35 * is reconstructed by the two-moment branching-Erlang fit the reference
36 * prescribes. That is what makes theta and the tail integrals closed form and
37 * lets the x-integrals run on a FIXED grid: W(.,n) is needed again at the next
38 * population step, so a rule that samples at arbitrary abscissae cannot be
39 * used. Beyond the grid edge Lx the profile is closed by the analytic tail
40 * W(x,n) = a - b exp(-c (x - Lx)) of eqs. (11)-(14).
41 *
42 * ARITHMETIC: DOUBLE, NOT TEMPLATED. Unlike its pfqn neighbours this header is
43 * not generic in the number type. The recursion evaluates the REGULARIZED
44 * INCOMPLETE GAMMA in both its branches, for which the port has no T-generic
45 * implementation, and its accuracy is set by a 33-point Simpson grid rather
46 * than by the arithmetic, so a wider T would buy nothing. Callers gate on
47 * num_traits<T>::has_transcendental and convert at the boundary; the exact
48 * (Rational) path refuses SJN by name in solver_mva_sjn.h.
49 *
50 * WHY NOT REUSE mam::gammainc_lower. It returns only P(a,x). The tail
51 * correction needs Q(a,x) at magnitudes around 1e-300, where 1 - P is exactly
52 * zero, so an upper branch that is computed rather than subtracted is
53 * mandatory. `detail::gammainc` below returns both from the one continued
54 * fraction, which is also what MATLAB's gammainc(...,'upper') does.
55 *
56 * WARNINGS BECOME FLAGS. The reference calls line_warning when the utilization
57 * cap binds and when the fixed point runs out of iterations. This port has no
58 * warning channel, so `SjnResult::capped` and `SjnResult::converged` carry the
59 * same information to the caller. The hard `line_error` cases stay exceptions.
60 *
61 * Reference: K. Kant, "MVA approximations for SJN scheduling", Performance
62 * Evaluation 15(1):41-61, 1992.
63 */
64
65#include <algorithm>
66#include <cmath>
67#include <cstddef>
68#include <limits>
69#include <string>
70#include <vector>
71
73#include "line/util/error.h"
74#include "line/util/matrix.h"
75
76namespace line {
77namespace pfqn {
78
79/** Options of the SJN solvers, the fields sjn_args fills in. */
80struct SjnOptions {
81 std::size_t ns = 32; ///< grid subdivisions, must be even
82 double Lfactor = 8.0; ///< grid extent in units of the largest mean service time
83 std::vector<int> prio; ///< distinct levels, 1 = highest; empty for the pooled reading
84 double tol = 1e-8;
85 std::size_t iter_max = 1000;
86 double umax = 0.999; ///< utilization cap, strictly below one
87};
88
89/** The conditional waiting time profile at one SJN station, the reference's WX. */
90struct SjnProfile {
91 std::size_t station = 0; ///< 1-based row index into L
92 std::vector<double> x; ///< the grid
93 Matrix<double> W; ///< (ngrid x R)
94 Matrix<double> tail; ///< (R x 3), the tail parameters a, b, c
95};
96
97/** Return block of pfqn_mvasjn and pfqn_amvasjn. */
98struct SjnResult {
99 std::vector<double> XN; ///< (R)
100 Matrix<double> QN, UN, CN; ///< (M x R)
101 std::vector<SjnProfile> WX;
102 std::size_t iter = 1;
103 bool converged = true; ///< always true for the lattice recursion
104 bool capped = false; ///< the utilization cap was binding somewhere
105};
106
107/**
108 * The conditional waiting time equation has no solution at some population.
109 *
110 * Raised by pfqn_mvasjn only: the cap rescales the profile that the next
111 * population step reads back, so the correction compounds along the lattice
112 * and the recursion oscillates. pfqn_amvasjn solves the same profile by a
113 * fixed point and does cap, the iteration being self-consistent.
114 */
116public:
117 explicit SjnStarvationError(const std::string& what) : NumericError(what) {}
118};
119
120namespace detail {
121
122/**
123 * Regularized incomplete gamma, both branches at once.
124 *
125 * Series below the transition point, continued fraction above it (Numerical
126 * Recipes 6.2). The branch that the chosen expansion computes DIRECTLY is
127 * returned without a subtraction, so the small one keeps its relative accuracy
128 * down to the underflow limit, which the tail integrals depend on.
129 */
130struct GammaInc {
131 double p; ///< lower P(a,x)
132 double q; ///< upper Q(a,x)
133};
134
135inline GammaInc gammainc(double a, double x) {
136 if (a <= 0.0) throw InputError("pfqn_sjn: the incomplete gamma needs a positive order");
137 if (x < 0.0) throw InputError("pfqn_sjn: the incomplete gamma needs a non-negative argument");
138 if (x == 0.0) return GammaInc{0.0, 1.0};
139 const double gln = std::lgamma(a);
140 const double lead = std::exp(-x + a * std::log(x) - gln);
141 if (x < a + 1.0) {
142 double ap = a, del = 1.0 / a, sum = del;
143 for (int n = 0; n < 1000; ++n) {
144 ap += 1.0;
145 del *= x / ap;
146 sum += del;
147 if (std::fabs(del) < std::fabs(sum) * 1e-16) break;
148 }
149 const double p = sum * lead;
150 return GammaInc{p, 1.0 - p};
151 }
152 const double tiny = 1e-300;
153 double b = x + 1.0 - a, c = 1.0 / tiny, d = 1.0 / b, h = d;
154 for (int i = 1; i <= 1000; ++i) {
155 const double an = -static_cast<double>(i) * (static_cast<double>(i) - a);
156 b += 2.0;
157 d = an * d + b;
158 if (std::fabs(d) < tiny) d = tiny;
159 c = b + an / c;
160 if (std::fabs(c) < tiny) c = tiny;
161 d = 1.0 / d;
162 const double del = d * c;
163 h *= del;
164 if (std::fabs(del - 1.0) < 1e-16) break;
165 }
166 const double q = lead * h;
167 return GammaInc{1.0 - q, q};
168}
169
170/** Erlang mixture fitted to a mean and an SCV, the reference's sjn_fit. */
171struct SjnFit {
172 std::vector<double> w;
173 std::vector<int> k;
174 std::vector<double> mu;
175 bool empty() const { return w.empty(); }
176};
177
178/**
179 * Branching Erlang (Erlang(k-1) and Erlang(k) sharing a rate) below CV^2 = 1,
180 * balanced-means hyperexponential above it, exponential at it.
181 */
182inline SjnFit sjn_fit(double s, double cv2) {
183 SjnFit f;
184 if (s <= 0.0) return f;
185 if (cv2 < 0.0) throw InputError("pfqn_sjn: negative squared coefficient of variation");
186 if (std::fabs(cv2 - 1.0) < 1e-8) {
187 f.w = {1.0};
188 f.k = {1};
189 f.mu = {1.0 / s};
190 } else if (cv2 < 1.0) {
191 const double kd = std::ceil(1.0 / cv2);
192 const double p = (kd * cv2 - std::sqrt(kd * (1.0 + cv2) - kd * kd * cv2)) / (1.0 + cv2);
193 const double mu = (kd - p) / s;
194 f.w = {p, 1.0 - p};
195 f.k = {static_cast<int>(kd) - 1, static_cast<int>(kd)};
196 f.mu = {mu, mu};
197 } else {
198 const double p = 0.5 * (1.0 + std::sqrt((cv2 - 1.0) / (cv2 + 1.0)));
199 f.w = {p, 1.0 - p};
200 f.k = {1, 1};
201 f.mu = {2.0 * p / s, 2.0 * (1.0 - p) / s};
202 }
203 return f;
204}
205
206/** Density of the mixture on a grid, the reference's sjn_quad('pdf'). */
207inline std::vector<double> sjn_pdf(const SjnFit& f, const std::vector<double>& x) {
208 // realmin, not the smallest denormal: MATLAB's max(x,realmin) is the model.
209 const double floorx = std::numeric_limits<double>::min();
210 std::vector<double> y(x.size(), 0.0);
211 for (std::size_t j = 0; j < f.w.size(); ++j) {
212 const double kd = static_cast<double>(f.k[j]), mu = f.mu[j];
213 for (std::size_t i = 0; i < x.size(); ++i)
214 y[i] += f.w[j] * std::exp(kd * std::log(mu) +
215 (kd - 1.0) * std::log(std::max(x[i], floorx)) - mu * x[i] -
216 std::lgamma(kd));
217 }
218 return y;
219}
220
221/** int_0^x t f(t) dt in closed form, the reference's sjn_quad('theta'). */
222inline std::vector<double> sjn_theta(const SjnFit& f, const std::vector<double>& x) {
223 std::vector<double> y(x.size(), 0.0);
224 for (std::size_t j = 0; j < f.w.size(); ++j) {
225 const double kd = static_cast<double>(f.k[j]), mu = f.mu[j];
226 for (std::size_t i = 0; i < x.size(); ++i)
227 y[i] += f.w[j] * (kd / mu) * gammainc(kd + 1.0, mu * x[i]).p;
228 }
229 return y;
230}
231
232/** int_x^inf f(t) dt in closed form, the reference's sjn_quad('ccdf'). */
233inline double sjn_ccdf(const SjnFit& f, double x) {
234 double y = 0.0;
235 for (std::size_t j = 0; j < f.w.size(); ++j)
236 y += f.w[j] * gammainc(static_cast<double>(f.k[j]), f.mu[j] * x).q;
237 return y;
238}
239
240/**
241 * int_Lx^inf t^order exp(-c (t-Lx)) f(t) dt, the reference's sjn_quad('tailmom').
242 * Evaluated in logarithms so that exp(c Lx) cannot overflow against an
243 * underflowing incomplete gamma.
244 */
245inline double sjn_tailmom(const SjnFit& f, double Lx, double c, int order) {
246 double y = 0.0;
247 for (std::size_t j = 0; j < f.w.size(); ++j) {
248 const double kd = static_cast<double>(f.k[j]), mu = f.mu[j];
249 const double rate = mu + c;
250 const double g = gammainc(kd + static_cast<double>(order), rate * Lx).q;
251 if (g <= 0.0) continue;
252 double lg = c * Lx + kd * std::log(mu / rate) + std::log(g);
253 if (order == 1) lg += std::log(kd / rate);
254 y += f.w[j] * std::exp(lg);
255 }
256 return y;
257}
258
259/** Composite Simpson over an even number of subdivisions. */
260inline double sjn_simpson(const std::vector<double>& y, double dx) {
261 const std::size_t n = y.size();
262 double odd = 0.0, even = 0.0;
263 for (std::size_t i = 1; i + 1 < n; i += 2) odd += y[i];
264 for (std::size_t i = 2; i + 1 < n; i += 2) even += y[i];
265 return dx / 3.0 * (y[0] + y[n - 1] + 4.0 * odd + 2.0 * even);
266}
267
268/**
269 * Cumulative Simpson: full panels at the even nodes and a half panel at the odd
270 * ones, so the primitive is available at EVERY grid node. Quadrature at
271 * arbitrary abscissae could not provide that, the profile being needed again at
272 * the next population step.
273 */
274inline std::vector<double> sjn_cumsimpson(const std::vector<double>& y, double dx) {
275 const std::size_t n = y.size();
276 std::vector<double> I(n, 0.0);
277 for (std::size_t i = 2; i < n; i += 2)
278 I[i] = I[i - 2] + dx / 3.0 * (y[i - 2] + 4.0 * y[i - 1] + y[i]);
279 for (std::size_t i = 1; i < n; i += 2) {
280 if (i + 1 < n)
281 I[i] = I[i - 1] + dx / 12.0 * (5.0 * y[i - 1] + 8.0 * y[i] - y[i + 1]);
282 else
283 I[i] = I[i - 1] + dx / 12.0 * (-y[i - 2] + 8.0 * y[i - 1] + 5.0 * y[i]);
284 }
285 return I;
286}
287
288/** Grid of one SJN station and the population-independent integrals over it. */
289struct SjnGrid {
290 double Lx = 0.0, dx = 0.0;
291 std::vector<double> x;
292 Matrix<double> f, theta; ///< (ngrid x R)
293 std::vector<double> tail0, tail1;
294 std::vector<SjnFit> fit;
295};
296
297/** Port of sjn_setup. */
298inline SjnGrid sjn_setup(const std::vector<double>& S, const std::vector<double>& scv,
299 std::size_t ns, double Lfactor) {
300 const std::size_t R = S.size(), ngrid = ns + 1;
301 double smax = 0.0;
302 for (double s : S) smax = std::max(smax, s);
303 if (smax <= 0.0)
304 throw InputError("pfqn_sjn: the station has zero service demand in every class");
305 SjnGrid G;
306 G.Lx = Lfactor * smax;
307 G.dx = G.Lx / static_cast<double>(ns);
308 G.x.assign(ngrid, 0.0);
309 for (std::size_t i = 0; i < ngrid; ++i) G.x[i] = static_cast<double>(i) * G.dx;
310 G.f = Matrix<double>(ngrid, R, 0.0);
311 G.theta = Matrix<double>(ngrid, R, 0.0);
312 G.tail0.assign(R, 0.0);
313 G.tail1.assign(R, 0.0);
314 G.fit.assign(R, SjnFit());
315 for (std::size_t r = 0; r < R; ++r) {
316 G.fit[r] = sjn_fit(S[r], scv[r]);
317 if (G.fit[r].empty()) continue;
318 const std::vector<double> fr = sjn_pdf(G.fit[r], G.x);
319 const std::vector<double> tr = sjn_theta(G.fit[r], G.x);
320 for (std::size_t i = 0; i < ngrid; ++i) {
321 G.f(i, r) = fr[i];
322 G.theta(i, r) = tr[i];
323 }
324 G.tail0[r] = sjn_ccdf(G.fit[r], G.Lx);
325 G.tail1[r] = S[r] - tr[ngrid - 1];
326 }
327 return G;
328}
329
330/** State of one SJN station at the reference population, the reference's `st`. */
331struct SjnState {
332 std::vector<double> lam, U, Q;
333 const Matrix<double>* W = nullptr; ///< (ngrid x R)
334 const Matrix<double>* phi = nullptr; ///< (ngrid x R)
335 std::vector<double> phiinf;
336};
337
338/** Outcome of one evaluation of the conditional waiting time equation. */
339struct SjnStationResult {
340 double C = 0.0;
341 std::vector<double> W, phi;
342 double phiinf = 0.0;
343 double tail[3] = {0.0, 0.0, 0.0};
344};
345
346/** `m` is 0-based here; the message reports the reference's 1-based index. */
347inline NumericError sjn_singular(std::size_t m) {
348 return NumericError(
349 "pfqn_sjn: the SJN recursion at station " + std::to_string(m + 1) +
350 " has no solution: the work brought by jobs no longer than the tagged one saturates the "
351 "server, at which point long jobs starve and the arrival theorem no longer holds. Reduce "
352 "the load at that station or model it with SolverCTMC or SolverLDES");
353}
354
355/**
356 * Port of sjn_station: one evaluation of the SJN conditional waiting time
357 * equation for a tagged customer of class r at station m.
358 *
359 * lam_k W_k(x) f_k(x) is the density, in the job size x, of the queued class-k
360 * customers, so deflating it by beta_k turns the same equation into either the
361 * exact recursion (beta = 1, the state already being the one at n - e_r) or the
362 * Schweitzer closure (beta_r = (N_r-1)/N_r, the state being the one at N).
363 */
364inline SjnStationResult sjn_station(std::size_t m, std::size_t r, const SjnGrid& G,
365 const std::vector<double>& S, const std::vector<double>& scv,
366 const std::vector<double>& V, const SjnState& st,
367 const std::vector<double>& beta, bool useprio,
368 const std::vector<int>& prio) {
369 const std::size_t R = S.size(), ngrid = G.x.size();
370 std::vector<double> lamb(R), Ub(R), Qb(R);
371 double RL = 0.0;
372 for (std::size_t k = 0; k < R; ++k) {
373 lamb[k] = beta[k] * st.lam[k];
374 Ub[k] = beta[k] * st.U[k];
375 Qb[k] = beta[k] * st.Q[k];
376 RL += (1.0 + scv[k]) * S[k] * Ub[k] / 2.0;
377 }
378 std::vector<double> num(ngrid), den(ngrid);
379 double numinf = 0.0, deninf = 0.0;
380 if (useprio) {
381 double base = RL, uhi = 0.0;
382 for (std::size_t k = 0; k < R; ++k)
383 if (prio[k] < prio[r]) {
384 base += S[k] * (Qb[k] - Ub[k]);
385 uhi += Ub[k];
386 }
387 for (std::size_t i = 0; i < ngrid; ++i) {
388 num[i] = base + lamb[r] * (*st.phi)(i, r);
389 den[i] = 1.0 - uhi - lamb[r] * G.theta(i, r);
390 }
391 numinf = base + lamb[r] * st.phiinf[r];
392 deninf = 1.0 - uhi - lamb[r] * S[r];
393 } else {
394 double usum = 0.0, phiinfsum = 0.0;
395 for (std::size_t k = 0; k < R; ++k) {
396 usum += lamb[k] * S[k];
397 phiinfsum += lamb[k] * st.phiinf[k];
398 }
399 for (std::size_t i = 0; i < ngrid; ++i) {
400 double n = RL, d = 1.0;
401 for (std::size_t k = 0; k < R; ++k) {
402 n += lamb[k] * (*st.phi)(i, k);
403 d -= lamb[k] * G.theta(i, k);
404 }
405 num[i] = n;
406 den[i] = d;
407 }
408 numinf = RL + phiinfsum;
409 deninf = 1.0 - usum;
410 }
411 for (std::size_t i = 0; i < ngrid; ++i)
412 if (den[i] <= 0.0) throw sjn_singular(m);
413 if (deninf <= 0.0) throw sjn_singular(m);
414
415 SjnStationResult out;
416 out.W.assign(ngrid, 0.0);
417 for (std::size_t i = 0; i < ngrid; ++i) out.W[i] = num[i] / den[i];
418 const double Winf = numinf / deninf;
419 // eq. (14), generalised by differentiating the recursion at the grid edge
420 double slope;
421 if (useprio) {
422 slope = G.Lx * lamb[r] * G.f(ngrid - 1, r) *
423 ((*st.W)(ngrid - 1, r) + out.W[ngrid - 1]) / den[ngrid - 1];
424 } else {
425 double acc = 0.0;
426 for (std::size_t k = 0; k < R; ++k)
427 acc += lamb[k] * G.f(ngrid - 1, k) * ((*st.W)(ngrid - 1, k) + out.W[ngrid - 1]);
428 slope = G.Lx * acc / den[ngrid - 1];
429 }
430 double a = Winf, b = Winf - out.W[ngrid - 1], c;
431 if (b <= 0.0) {
432 b = 0.0;
433 c = 0.0;
434 } else if (slope < 0.0) {
435 throw NumericError("pfqn_sjn: the conditional waiting time at SJN station " +
436 std::to_string(m + 1) +
437 " decreases in the job size, which the discipline forbids: the "
438 "recursion has become numerically unstable");
439 } else {
440 c = slope / b;
441 }
442 out.tail[0] = a;
443 out.tail[1] = b;
444 out.tail[2] = c;
445
446 std::vector<double> integrand(ngrid);
447 for (std::size_t i = 0; i < ngrid; ++i) integrand[i] = out.W[i] * G.x[i] * G.f(i, r);
448 out.phi = sjn_cumsimpson(integrand, G.dx);
449 out.phiinf = out.phi[ngrid - 1] + a * G.tail1[r] - b * sjn_tailmom(G.fit[r], G.Lx, c, 1);
450 for (std::size_t i = 0; i < ngrid; ++i) integrand[i] = out.W[i] * G.f(i, r);
451 const double Wbar =
452 sjn_simpson(integrand, G.dx) + a * G.tail0[r] - b * sjn_tailmom(G.fit[r], G.Lx, c, 0);
453 out.C = V[r] * (S[r] + Wbar);
454 return out;
455}
456
457/** Throughputs implied by the residence times, keeping Little's law exact. */
458inline std::vector<double> sjn_thru(const Matrix<double>& C, const std::vector<double>& N,
459 const std::vector<double>& Z) {
460 const std::size_t M = C.rows(), R = N.size();
461 std::vector<double> X(R, 0.0);
462 for (std::size_t r = 0; r < R; ++r) {
463 if (N[r] <= 0.0) continue;
464 double den = Z[r];
465 for (std::size_t m = 0; m < M; ++m) den += C(m, r);
466 X[r] = N[r] / den;
467 }
468 return X;
469}
470
471/** Outcome of the utilization cap. */
472struct SjnCapResult {
473 std::vector<double> X, kappa;
474 bool bound = false;
475};
476
477/**
478 * Port of sjn_cap: enforce U <= umax at every SJN station by inflating its
479 * waiting time, and return the throughputs the capped residence times imply.
480 *
481 * The SJN response time equation is an open-system one: its denominator is
482 * 1 - U(x), and it has no solution once that reaches one. A closed network
483 * never reaches it in reality, but the approximation can, because it
484 * underestimates the residence time at a congested SJN station and the
485 * resulting throughput then exceeds the station capacity. What is imposed is
486 * the utilization law sum_r X_r L_mr <= umax, an exact property of the network
487 * and not a property of the approximation. It acts on the EXCESS C - L, never
488 * on the throughput, so that X (Z + sum_m C) = N still holds exactly and no
489 * jobs are lost.
490 */
491inline SjnCapResult sjn_cap(Matrix<double>& C, const Matrix<double>& L,
492 const std::vector<double>& N, const std::vector<double>& Z,
493 const std::vector<std::size_t>& sjnset, double umax) {
494 const std::size_t nsjn = sjnset.size(), R = N.size();
495 SjnCapResult out;
496 out.kappa.assign(nsjn, 1.0);
497 out.X = sjn_thru(C, N, Z);
498 if (nsjn == 0) return out;
499 if (umax >= 1.0)
500 throw InputError("pfqn_sjn: the utilization cap must be strictly below one, the response "
501 "time equation is singular at one");
502 // rho at station m had the excess been inflated by kappa, C left untouched
503 const auto rho_at = [&](std::size_t m, const std::vector<double>& Wq, double kappa) {
504 std::vector<double> saved(R);
505 for (std::size_t r = 0; r < R; ++r) {
506 saved[r] = C(m, r);
507 C(m, r) = L(m, r) + kappa * Wq[r];
508 }
509 const std::vector<double> X = sjn_thru(C, N, Z);
510 double rho = 0.0;
511 for (std::size_t r = 0; r < R; ++r) rho += X[r] * L(m, r);
512 for (std::size_t r = 0; r < R; ++r) C(m, r) = saved[r];
513 return rho;
514 };
515 for (int sweep = 0; sweep < 20; ++sweep) {
516 bool viol = false;
517 for (std::size_t q = 0; q < nsjn; ++q) {
518 const std::size_t m = sjnset[q];
519 double rho = 0.0;
520 for (std::size_t r = 0; r < R; ++r) rho += out.X[r] * L(m, r);
521 if (rho <= umax) continue;
522 viol = true;
523 out.bound = true;
524 std::vector<double> Wq(R);
525 for (std::size_t r = 0; r < R; ++r) Wq[r] = C(m, r) - L(m, r);
526 double hi = 2.0;
527 while (rho_at(m, Wq, hi) > umax) {
528 hi *= 2.0;
529 if (hi > 1e12)
530 throw NumericError(
531 "pfqn_sjn: station " + std::to_string(m + 1) +
532 " cannot be brought under the utilization cap by any waiting time: its "
533 "service demands alone saturate it at this population");
534 }
535 double lo = 1.0;
536 for (int b = 0; b < 200; ++b) {
537 const double mid = (lo + hi) / 2.0;
538 if (rho_at(m, Wq, mid) > umax)
539 lo = mid;
540 else
541 hi = mid;
542 }
543 out.kappa[q] *= hi;
544 for (std::size_t r = 0; r < R; ++r) C(m, r) = L(m, r) + hi * Wq[r];
545 out.X = sjn_thru(C, N, Z);
546 }
547 if (!viol) return out;
548 }
549 throw NumericError("pfqn_sjn: the utilization cap did not settle across the SJN stations");
550}
551
552/** Normalised arguments, the reference's sjn_args. */
553struct SjnArgs {
554 std::size_t M = 0, R = 0;
555 std::vector<double> N, Z;
556 Matrix<double> scv, V, S;
557 std::vector<std::size_t> sjnset; ///< 0-based rows of L
558 SjnOptions opt;
559};
560
561/** Port of sjn_args. */
562inline SjnArgs sjn_args(const Matrix<double>& L, const std::vector<double>& N,
563 const std::vector<double>& Z, const Matrix<double>& scv,
564 const std::vector<std::size_t>& sjnset, const Matrix<double>& V,
565 const SjnOptions& options) {
566 SjnArgs a;
567 a.M = L.rows();
568 a.R = L.cols();
569 a.opt = options;
570 if (N.size() != a.R)
571 throw InputError("pfqn_sjn: demand matrix and population vector have different number of "
572 "classes");
573 a.N.assign(a.R, 0.0);
574 for (std::size_t r = 0; r < a.R; ++r) {
575 a.N[r] = std::round(N[r]);
576 if (a.N[r] < 0.0) throw InputError("pfqn_sjn: negative class populations");
577 }
578 a.Z = Z.empty() ? std::vector<double>(a.R, 0.0) : Z;
579 if (a.Z.size() != a.R) throw InputError("pfqn_sjn: Z has the wrong length");
580 a.scv = scv.empty() ? Matrix<double>(a.M, a.R, 1.0) : scv;
581 if (a.scv.rows() != a.M || a.scv.cols() != a.R)
582 throw InputError("pfqn_sjn: scv has the wrong shape");
583 a.sjnset = sjnset;
584 for (std::size_t i = 0; i < a.sjnset.size(); ++i) {
585 if (a.sjnset[i] >= a.M)
586 throw InputError("pfqn_sjn: sjnset contains a station index outside 1..M");
587 for (std::size_t j = i + 1; j < a.sjnset.size(); ++j)
588 if (a.sjnset[i] == a.sjnset[j])
589 throw InputError("pfqn_sjn: sjnset repeats a station index");
590 }
591 a.V = V.empty() ? Matrix<double>(a.M, a.R, 1.0) : V;
592 if (a.V.rows() != a.M || a.V.cols() != a.R)
593 throw InputError("pfqn_sjn: V has the wrong shape");
594 // the job size the discipline compares is one VISIT's service time, not the
595 // demand accumulated over all visits
596 a.S = Matrix<double>(a.M, a.R, 0.0);
597 for (std::size_t m = 0; m < a.M; ++m)
598 for (std::size_t r = 0; r < a.R; ++r)
599 if (a.V(m, r) > 0.0) a.S(m, r) = L(m, r) / a.V(m, r);
600 if (a.opt.ns == 0 || a.opt.ns % 2 != 0)
601 throw InputError("pfqn_sjn: options.ns must be even, composite Simpson integrates over "
602 "panels of two subdivisions");
603 if (a.opt.umax <= 0.0 || a.opt.umax >= 1.0)
604 throw InputError("pfqn_sjn: options.umax must lie strictly between zero and one");
605 if (!a.opt.prio.empty()) {
606 if (a.opt.prio.size() != a.R)
607 throw InputError("pfqn_sjn: options.prio must have one priority level per class");
608 for (std::size_t i = 0; i < a.R; ++i)
609 for (std::size_t j = i + 1; j < a.R; ++j)
610 if (a.opt.prio[i] == a.opt.prio[j])
611 throw InputError("pfqn_sjn: options.prio must assign distinct levels, ties "
612 "across classes are not covered by the SJN priority "
613 "equations");
614 }
615 return a;
616}
617
618} // namespace detail
619
620/**
621 * Exact-lattice MVA for closed networks with SJN stations, the unidirectional
622 * scheme of Kant 1992.
623 *
624 * The recursion is explicit -- W(.,n) needs only phi(.,n-1) -- so the profile
625 * is carried alongside the population recursion and the whole lattice of
626 * prod(N+1) states is stepped through. Two multiclass readings of "shortest
627 * job" are selected by options.prio: POOLED (empty, the default) compares the
628 * jobs of every class by size directly and collapses to eq. (6) for a single
629 * class; PRIORITY (distinct levels, 1 = highest) is method A of eq. (21), SJN
630 * applying only within a class. Method B, which evaluates the denominator at
631 * the non-integral population n - Q(n), is not implemented in either codebase.
632 *
633 * @param L (M x R) demands at the queueing stations
634 * @param N (R) populations
635 * @param Z (R) think times, empty for none
636 * @param scv (M x R) squared coefficients of variation, empty for ones
637 * @param sjnset 0-based rows of L that schedule by SJN
638 * @param V (M x R) visit ratios, empty for ones
639 * @param options quadrature grid, tolerances and iteration caps of the recursion
640 */
641inline SjnResult pfqn_mvasjn(const Matrix<double>& L, const std::vector<double>& N,
642 const std::vector<double>& Z, const Matrix<double>& scv,
643 const std::vector<std::size_t>& sjnset, const Matrix<double>& V,
644 const SjnOptions& options) {
645 const detail::SjnArgs a = detail::sjn_args(L, N, Z, scv, sjnset, V, options);
646 const std::size_t M = a.M, R = a.R, nsjn = a.sjnset.size();
647 const bool useprio = !a.opt.prio.empty();
648 const std::size_t ns = a.opt.ns, ngrid = ns + 1;
649
650 std::vector<detail::SjnGrid> G(nsjn);
651 for (std::size_t q = 0; q < nsjn; ++q) {
652 std::vector<double> Sq(R), scvq(R);
653 for (std::size_t r = 0; r < R; ++r) {
654 Sq[r] = a.S(a.sjnset[q], r);
655 scvq[r] = a.scv(a.sjnset[q], r);
656 }
657 G[q] = detail::sjn_setup(Sq, scvq, ns, a.opt.Lfactor);
658 }
659
660 // MATLAB reaches the same limit as an out-of-memory on prod(N+1); say so
661 // rather than wrap the index arithmetic silently
662 double lattice = 1.0;
663 for (std::size_t r = 0; r < R; ++r) lattice *= a.N[r] + 1.0;
664 if (lattice > 1e9)
665 throw InputError("pfqn_mvasjn: the population lattice has " + std::to_string(lattice) +
666 " states and does not fit; use pfqn_amvasjn (method 'amva')");
667 std::vector<std::size_t> stride(R, 1);
668 std::size_t npop = 1;
669 for (std::size_t r = 0; r < R; ++r) {
670 stride[r] = npop;
671 npop *= static_cast<std::size_t>(a.N[r]) + 1;
672 }
673
674 // one slab per lattice point: X, [Q,U,C] per station, and per SJN station
675 // the profile W, its primitive phi, phi at infinity and the tail parameters
676 std::vector<double> Xp(npop * R, 0.0), Qp(npop * M * R, 0.0), Up(npop * M * R, 0.0),
677 Cp(npop * M * R, 0.0);
678 std::vector<Matrix<double>> Wp(nsjn * npop), Pp(nsjn * npop);
679 std::vector<double> Ip(nsjn * npop * R, 0.0), Tp(nsjn * npop * R * 3, 0.0);
680 for (auto& m : Wp) m = Matrix<double>(ngrid, R, 0.0);
681 for (auto& m : Pp) m = Matrix<double>(ngrid, R, 0.0);
682
683 SjnResult res;
684 Matrix<double> Call(M, R, 0.0);
685 for (std::size_t idx = 1; idx < npop; ++idx) {
686 std::vector<double> n(R, 0.0);
687 for (std::size_t r = 0; r < R; ++r)
688 n[r] = static_cast<double>((idx / stride[r]) %
689 (static_cast<std::size_t>(a.N[r]) + 1));
690 Call.fill(0.0);
691 for (std::size_t r = 0; r < R; ++r) {
692 if (n[r] == 0.0) continue;
693 const std::size_t iprev = idx - stride[r];
694 for (std::size_t m = 0; m < M; ++m) {
695 std::size_t q = nsjn;
696 for (std::size_t t = 0; t < nsjn; ++t)
697 if (a.sjnset[t] == m) q = t;
698 if (q == nsjn) {
699 double qsum = 0.0;
700 for (std::size_t k = 0; k < R; ++k) qsum += Qp[(iprev * M + m) * R + k];
701 Call(m, r) = L(m, r) * (1.0 + qsum);
702 continue;
703 }
704 // the population step already supplies the neighbouring profile,
705 // so no deflation is needed
706 const std::vector<double> beta(R, 1.0);
707 detail::SjnState st;
708 st.lam.assign(R, 0.0);
709 st.U.assign(R, 0.0);
710 st.Q.assign(R, 0.0);
711 st.phiinf.assign(R, 0.0);
712 for (std::size_t k = 0; k < R; ++k) {
713 st.lam[k] = Xp[iprev * R + k] * a.V(m, k);
714 st.U[k] = Up[(iprev * M + m) * R + k];
715 st.Q[k] = Qp[(iprev * M + m) * R + k];
716 st.phiinf[k] = Ip[(q * npop + iprev) * R + k];
717 }
718 st.W = &Wp[q * npop + iprev];
719 st.phi = &Pp[q * npop + iprev];
720 std::vector<double> Sm(R), scvm(R), Vm(R);
721 for (std::size_t k = 0; k < R; ++k) {
722 Sm[k] = a.S(m, k);
723 scvm[k] = a.scv(m, k);
724 Vm[k] = a.V(m, k);
725 }
726 const detail::SjnStationResult sr =
727 detail::sjn_station(m, r, G[q], Sm, scvm, Vm, st, beta, useprio, a.opt.prio);
728 Call(m, r) = sr.C;
729 for (std::size_t i = 0; i < ngrid; ++i) {
730 Wp[q * npop + idx](i, r) = sr.W[i];
731 Pp[q * npop + idx](i, r) = sr.phi[i];
732 }
733 Ip[(q * npop + idx) * R + r] = sr.phiinf;
734 for (int t = 0; t < 3; ++t)
735 Tp[((q * npop + idx) * R + r) * 3 + t] = sr.tail[t];
736 }
737 }
738 const detail::SjnCapResult cap = detail::sjn_cap(Call, L, n, a.Z, a.sjnset, a.opt.umax);
739 if (cap.bound) {
740 // the cap has invalidated the profile the next population step reads back
741 std::string npos;
742 for (std::size_t r = 0; r < R; ++r)
743 npos += (r ? " " : "") + std::to_string(static_cast<long long>(n[r]));
744 throw SjnStarvationError(
745 "pfqn_mvasjn: the utilization cap of " + std::to_string(a.opt.umax) +
746 " was binding at an SJN station at population [" + npos +
747 "]: the station is in the starvation regime, where the conditional waiting time "
748 "equation has no solution and the population lattice no valid continuation. Use "
749 "the Schweitzer fixed point (pfqn_amvasjn, method 'amva'), SolverCTMC or "
750 "SolverLDES.");
751 }
752 for (std::size_t r = 0; r < R; ++r) Xp[idx * R + r] = cap.X[r];
753 for (std::size_t m = 0; m < M; ++m)
754 for (std::size_t r = 0; r < R; ++r) {
755 Cp[(idx * M + m) * R + r] = Call(m, r);
756 Qp[(idx * M + m) * R + r] = cap.X[r] * Call(m, r);
757 Up[(idx * M + m) * R + r] = cap.X[r] * L(m, r);
758 }
759 }
760
761 const std::size_t last = npop - 1;
762 res.XN.assign(R, 0.0);
763 res.QN = Matrix<double>(M, R, 0.0);
764 res.UN = Matrix<double>(M, R, 0.0);
765 res.CN = Matrix<double>(M, R, 0.0);
766 for (std::size_t r = 0; r < R; ++r) res.XN[r] = Xp[last * R + r];
767 for (std::size_t m = 0; m < M; ++m)
768 for (std::size_t r = 0; r < R; ++r) {
769 res.QN(m, r) = Qp[(last * M + m) * R + r];
770 res.UN(m, r) = Up[(last * M + m) * R + r];
771 res.CN(m, r) = Cp[(last * M + m) * R + r];
772 }
773 res.WX.resize(nsjn);
774 for (std::size_t q = 0; q < nsjn; ++q) {
775 res.WX[q].station = a.sjnset[q] + 1;
776 res.WX[q].x = G[q].x;
777 res.WX[q].W = Wp[q * npop + last];
778 res.WX[q].tail = Matrix<double>(R, 3, 0.0);
779 for (std::size_t r = 0; r < R; ++r)
780 for (int t = 0; t < 3; ++t)
781 res.WX[q].tail(r, t) = Tp[((q * npop + last) * R + r) * 3 + t];
782 }
783 return res;
784}
785
786/**
787 * Schweitzer fixed point counterpart of pfqn_mvasjn.
788 *
789 * The closure is applied to the SIZE-RESOLVED queue length lam_k W_k(x) f_k(x)
790 * rather than to its integral: removing one customer of class r scales the
791 * class-r density by (N_r-1)/N_r and leaves the other classes unchanged.
792 * Integrating over x recovers the usual Schweitzer rule, so the closure is the
793 * exact analogue of the one used at the ordinary stations. Cost per iteration
794 * is O(M R ns) against the prod(N+1) M R ns of the lattice, and the population
795 * may be arbitrarily large. What is given up is the population dependence of
796 * the SHAPE of W(x): its level may scale but its shape is fixed, whereas the
797 * true profile stiffens with the load. The error therefore concentrates at high
798 * utilization, where the SJN approximation is already weakest.
799 */
800inline SjnResult pfqn_amvasjn(const Matrix<double>& L, const std::vector<double>& N,
801 const std::vector<double>& Z, const Matrix<double>& scv,
802 const std::vector<std::size_t>& sjnset, const Matrix<double>& V,
803 const SjnOptions& options) {
804 const detail::SjnArgs a = detail::sjn_args(L, N, Z, scv, sjnset, V, options);
805 const std::size_t M = a.M, R = a.R, nsjn = a.sjnset.size();
806 const bool useprio = !a.opt.prio.empty();
807 const std::size_t ns = a.opt.ns, ngrid = ns + 1;
808
809 std::vector<detail::SjnGrid> G(nsjn);
810 for (std::size_t q = 0; q < nsjn; ++q) {
811 std::vector<double> Sq(R), scvq(R);
812 for (std::size_t r = 0; r < R; ++r) {
813 Sq[r] = a.S(a.sjnset[q], r);
814 scvq[r] = a.scv(a.sjnset[q], r);
815 }
816 G[q] = detail::sjn_setup(Sq, scvq, ns, a.opt.Lfactor);
817 }
818
819 // start from the product-form Schweitzer solution: a light-load guess would
820 // put the deflated utilization above one, where the SJN denominator has no
821 // solution at all
822 const AmvaResult<double> bs =
823 pfqn_bs(L, a.N, a.Z, std::vector<AmvaSched>(), a.opt.tol, a.opt.iter_max);
824 SjnResult res;
825 res.XN = bs.XN;
826 res.QN = bs.QN;
827 res.UN = bs.UN;
828 res.CN = bs.RN;
829
830 std::vector<Matrix<double>> W(nsjn, Matrix<double>(ngrid, R, 0.0));
831 std::vector<Matrix<double>> P(nsjn, Matrix<double>(ngrid, R, 0.0));
832 Matrix<double> Iinf(nsjn, R, 0.0);
833 std::vector<Matrix<double>> Tail(nsjn, Matrix<double>(R, 3, 0.0));
834
835 std::size_t it = 0;
836 bool converged = false;
837 double delta = 0.0;
838 while (!converged && it < a.opt.iter_max) {
839 ++it;
840 Matrix<double> Cit = res.CN;
841 std::vector<Matrix<double>> Wit = W, Pit = P, Tit = Tail;
842 Matrix<double> Iit = Iinf;
843 for (std::size_t r = 0; r < R; ++r) {
844 if (a.N[r] == 0.0) continue;
845 std::vector<double> beta(R, 1.0);
846 beta[r] = (a.N[r] - 1.0) / a.N[r];
847 for (std::size_t m = 0; m < M; ++m) {
848 std::size_t q = nsjn;
849 for (std::size_t t = 0; t < nsjn; ++t)
850 if (a.sjnset[t] == m) q = t;
851 if (q == nsjn) {
852 double qsum = 0.0;
853 for (std::size_t k = 0; k < R; ++k) qsum += beta[k] * res.QN(m, k);
854 Cit(m, r) = L(m, r) * (1.0 + qsum);
855 continue;
856 }
857 detail::SjnState st;
858 st.lam.assign(R, 0.0);
859 st.U.assign(R, 0.0);
860 st.Q.assign(R, 0.0);
861 st.phiinf.assign(R, 0.0);
862 for (std::size_t k = 0; k < R; ++k) {
863 st.lam[k] = res.XN[k] * a.V(m, k);
864 st.U[k] = res.UN(m, k);
865 st.Q[k] = res.QN(m, k);
866 st.phiinf[k] = Iinf(q, k);
867 }
868 st.W = &W[q];
869 st.phi = &P[q];
870 std::vector<double> Sm(R), scvm(R), Vm(R);
871 for (std::size_t k = 0; k < R; ++k) {
872 Sm[k] = a.S(m, k);
873 scvm[k] = a.scv(m, k);
874 Vm[k] = a.V(m, k);
875 }
876 const detail::SjnStationResult sr =
877 detail::sjn_station(m, r, G[q], Sm, scvm, Vm, st, beta, useprio, a.opt.prio);
878 Cit(m, r) = sr.C;
879 for (std::size_t i = 0; i < ngrid; ++i) {
880 Wit[q](i, r) = sr.W[i];
881 Pit[q](i, r) = sr.phi[i];
882 }
883 Iit(q, r) = sr.phiinf;
884 for (int t = 0; t < 3; ++t) Tit[q](r, t) = sr.tail[t];
885 }
886 }
887 const detail::SjnCapResult cap = detail::sjn_cap(Cit, L, a.N, a.Z, a.sjnset, a.opt.umax);
888 if (cap.bound) {
889 res.capped = true;
890 for (std::size_t q = 0; q < nsjn; ++q) {
891 const double kq = cap.kappa[q];
892 for (std::size_t i = 0; i < ngrid; ++i)
893 for (std::size_t r = 0; r < R; ++r) {
894 Wit[q](i, r) *= kq;
895 Pit[q](i, r) *= kq;
896 }
897 for (std::size_t r = 0; r < R; ++r) {
898 Iit(q, r) *= kq;
899 Tit[q](r, 0) *= kq;
900 Tit[q](r, 1) *= kq;
901 }
902 }
903 }
904 Matrix<double> Qit(M, R, 0.0), Uit(M, R, 0.0);
905 for (std::size_t m = 0; m < M; ++m)
906 for (std::size_t r = 0; r < R; ++r) {
907 Qit(m, r) = cap.X[r] * Cit(m, r);
908 Uit(m, r) = cap.X[r] * L(m, r);
909 }
910 delta = 0.0;
911 for (std::size_t m = 0; m < M; ++m)
912 for (std::size_t r = 0; r < R; ++r)
913 delta = std::max(delta, std::fabs(Qit(m, r) - res.QN(m, r)));
914 for (std::size_t q = 0; q < nsjn; ++q)
915 for (std::size_t i = 0; i < ngrid; ++i)
916 for (std::size_t r = 0; r < R; ++r)
917 delta = std::max(delta, std::fabs(Wit[q](i, r) - W[q](i, r)));
918 res.XN = cap.X;
919 res.QN = Qit;
920 res.UN = Uit;
921 res.CN = Cit;
922 W = Wit;
923 P = Pit;
924 Iinf = Iit;
925 Tail = Tit;
926 converged = delta < a.opt.tol;
927 }
928 res.iter = it;
929 res.converged = converged;
930
931 res.WX.resize(nsjn);
932 for (std::size_t q = 0; q < nsjn; ++q) {
933 res.WX[q].station = a.sjnset[q] + 1;
934 res.WX[q].x = G[q].x;
935 res.WX[q].W = W[q];
936 res.WX[q].tail = Tail[q];
937 }
938 return res;
939}
940
941} // namespace pfqn
942} // namespace line
943
944#endif // LINE_API_PFQN_SJN_H
InputError(const std::string &what)
Definition error.h:39
void fill(const T &x)
Definition matrix.h:108
NumericError(const std::string &what)
Definition error.h:45
SjnStarvationError(const std::string &what)
Definition pfqn_sjn.h:117
The exception types the port throws.
Dense matrix and non-owning view.
@ Tail
the geometric single-class identity
AmvaResult< T > pfqn_bs(const Matrix< T > &L, const std::vector< T > &N, const std::vector< T > &Z, const std::vector< AmvaSched > &type, double tol=1e-6, std::size_t maxiter=1000, const Matrix< T > &QN0=Matrix< T >())
Bard-Schweitzer approximate MVA.
Definition pfqn_bs.h:73
SjnResult pfqn_amvasjn(const Matrix< double > &L, const std::vector< double > &N, const std::vector< double > &Z, const Matrix< double > &scv, const std::vector< std::size_t > &sjnset, const Matrix< double > &V, const SjnOptions &options)
Schweitzer fixed point counterpart of pfqn_mvasjn.
Definition pfqn_sjn.h:800
SjnResult pfqn_mvasjn(const Matrix< double > &L, const std::vector< double > &N, const std::vector< double > &Z, const Matrix< double > &scv, const std::vector< std::size_t > &sjnset, const Matrix< double > &V, const SjnOptions &options)
Exact-lattice MVA for closed networks with SJN stations, the unidirectional scheme of Kant 1992.
Definition pfqn_sjn.h:641
Bard-Schweitzer approximate MVA.
Matrix< T > RN
(M x R) residence time
Definition pfqn_bs.h:52
std::vector< T > XN
(R) throughput
Definition pfqn_bs.h:49
Matrix< T > UN
(M x R) utilization
Definition pfqn_bs.h:51
Matrix< T > QN
(M x R) queue length
Definition pfqn_bs.h:50
Options of the SJN solvers, the fields sjn_args fills in.
Definition pfqn_sjn.h:80
std::size_t ns
grid subdivisions, must be even
Definition pfqn_sjn.h:81
double umax
utilization cap, strictly below one
Definition pfqn_sjn.h:86
double Lfactor
grid extent in units of the largest mean service time
Definition pfqn_sjn.h:82
std::vector< int > prio
distinct levels, 1 = highest; empty for the pooled reading
Definition pfqn_sjn.h:83
std::size_t iter_max
Definition pfqn_sjn.h:85
The conditional waiting time profile at one SJN station, the reference's WX.
Definition pfqn_sjn.h:90
Matrix< double > tail
(R x 3), the tail parameters a, b, c
Definition pfqn_sjn.h:94
Matrix< double > W
(ngrid x R)
Definition pfqn_sjn.h:93
std::vector< double > x
the grid
Definition pfqn_sjn.h:92
std::size_t station
1-based row index into L
Definition pfqn_sjn.h:91
Return block of pfqn_mvasjn and pfqn_amvasjn.
Definition pfqn_sjn.h:98
Matrix< double > CN
(M x R)
Definition pfqn_sjn.h:100
Matrix< double > QN
Definition pfqn_sjn.h:100
Matrix< double > UN
Definition pfqn_sjn.h:100
std::vector< double > XN
(R)
Definition pfqn_sjn.h:99
std::vector< SjnProfile > WX
Definition pfqn_sjn.h:101
bool capped
the utilization cap was binding somewhere
Definition pfqn_sjn.h:104
bool converged
always true for the lattice recursion
Definition pfqn_sjn.h:103