LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
pfqn_stdf.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_STDF_H
6#define LINE_API_PFQN_STDF_H
7
8/**
9 * @file
10 * @ingroup api_pfqn
11 * Sojourn-time distribution at multiserver FCFS stations of a closed
12 * product-form network (J. McKenna, JACM 1987).
13 *
14 * Templated port of matlab/src/api/pfqn/pfqn_stdf.m. There is no JAR
15 * counterpart, so MATLAB is the only reference.
16 *
17 * Method. A class-r job arriving at station k sees, by the arrival theorem, the
18 * network at population N - e_r. Conditional on finding n jobs already there,
19 * its sojourn time is the sum of its own service and of the residual work of
20 * the queue ahead, whose distribution at a station with S(k) servers is the
21 * convolution
22 *
23 * h_k(t | n) = Exp(rate_k) n < S(k)
24 * h_k(t | n) = Exp(rate_k) + Erlang_{n-S(k)+1}(S(k) rate_k) n >= S(k),
25 *
26 * built here as MAPs and evaluated with map_cdf. Writing G_krt for the
27 * transform of the sojourn CDF, the paper sums h_k(t | |nvec|) F_k(nvec)
28 * G_k(N - e_r - nvec) over the whole population lattice. The reference keeps
29 * that form as dead commented-out code and executes instead the equivalent
30 * RECURSIVE form for load-dependent models, which is what this port implements:
31 * the aggregate constant is re-evaluated on a rate lattice tilted by the ratio
32 * of successive CDF levels,
33 *
34 * gamma_k(t, n) = mu_k(n) h_k(t | n-1) / h_k(t | n),
35 *
36 * shifted by pfqn_mushift so that station k is one job ahead, giving
37 *
38 * H_krt = h_k(t | 0) G_{-k}(N - e_r)
39 * + sum_s L(k,s) h_k(t | 0) / gamma_k(t,1) Y_ks(t),
40 * F(t) = min(1, H_krt / G(N - e_r)),
41 *
42 * with Y_ks(t) the constant of the full model at population N - e_r - e_s on
43 * the tilted lattice. The single-station case (M == 1) is dispatched to
44 * pfqn_comomrm_ld, everything else to pfqn_mvald, exactly as the reference
45 * dispatches it.
46 *
47 * Guards reproduced verbatim from the reference:
48 * - a time point equal to 0 is replaced by GlobalConstants.FineTol, because
49 * h_k(t | n) is not well defined there. FineTol is 1e-8, set by
50 * matlab/lineStart.m (FINE_TOL); it is NOT 1e-12;
51 * - a not-a-number H_krt becomes FineTol;
52 * - the result is clamped from above by min(1, .). pfqn_stdf_heur does NOT
53 * apply that clamp and can therefore report values above one; the asymmetry
54 * is real and is reproduced in both ports;
55 * - an FCFS station whose per-class service rates differ by more than FineTol
56 * is an invalid model and is rejected.
57 *
58 * Numerical stability. The reference warns when pfqn_mvald reports an unstable
59 * marginal; this port returns that flag on the result instead of writing to a
60 * log, since the port has no logging channel.
61 *
62 * Arithmetic: INEXACT BY CONSTRUCTION, gated on has_transcendental. map_cdf is
63 * a matrix exponential, the normalizing constants are combined in the log
64 * domain, and the zero-time and not-a-number guards are floating-point
65 * substitutions with no meaning in an exact field. Note also that pfqn_mvald
66 * and pfqn_comomrm_ld report their logarithms as double, so the log-domain part
67 * of this computation carries double precision whatever T is; T still governs
68 * the CDF evaluation, the tilted rate lattice and the constants themselves.
69 */
70
71#include <cmath>
72#include <cstddef>
73#include <vector>
74
80#include "line/num/number.h"
81#include "line/util/error.h"
82#include "line/util/matrix.h"
83
84namespace line {
85namespace pfqn {
86
87/** GlobalConstants.FineTol, as set by matlab/lineStart.m. */
88static const double kStdfFineTol = 1e-8;
89
90/**
91 * Result of pfqn_stdf / pfqn_stdf_heur, mirroring the MATLAB cell array RD.
92 * RD[k][r] is a (T x 2) matrix whose first column is the sojourn-time CDF and
93 * whose second column is the (guarded) time set, empty where the reference
94 * leaves the cell unset.
95 */
96template <class T>
97struct StdfResult {
98 std::vector<std::vector<Matrix<T>>> RD;
99 std::vector<T> tset; ///< the time set after the zero guard
100 bool isNumStable = true; ///< false once an aggregate solve reported instability
101};
102
103namespace detail {
104
105/**
106 * Drop one row from a matrix (MATLAB's L(setdiff(1:M,k),:)). Dropping the only
107 * row yields a 0 x cols matrix, not a 0 x 0 one: pfqn_mvald validates its rate
108 * lattice by column count even with no stations, so the column count has to
109 * survive.
110 */
111template <class T>
112Matrix<T> stdf_drop_row(const Matrix<T>& A, std::size_t k) {
113 Matrix<T> B(A.rows() - 1, A.cols());
114 std::size_t o = 0;
115 for (std::size_t i = 0; i < A.rows(); ++i) {
116 if (i == k) continue;
117 for (std::size_t j = 0; j < A.cols(); ++j) B(o, j) = A(i, j);
118 ++o;
119 }
120 return B;
121}
122
123/** The multiserver rate lattice mu(k,n) = min(S(k), n), n = 1, ..., sum(N). */
124template <class T>
125Matrix<T> stdf_mu(const std::vector<int>& S, int Nt) {
126 Matrix<T> mu(S.size(), static_cast<std::size_t>(Nt));
127 for (std::size_t k = 0; k < S.size(); ++k)
128 for (int n = 1; n <= Nt; ++n)
129 mu(k, static_cast<std::size_t>(n - 1)) =
130 num_traits<T>::from_int(S[k] < n ? S[k] : n);
131 return mu;
132}
133
134/** The time set with every zero replaced by FineTol. */
135template <class T>
136std::vector<T> stdf_guard_tset(const std::vector<T>& tset) {
137 const T zero = num_traits<T>::from_int(0);
138 const T fine = num_traits<T>::from_double(kStdfFineTol);
139 std::vector<T> out = tset;
140 for (std::size_t t = 0; t < out.size(); ++t) {
141 if (out[t] < zero) throw InputError("pfqn_stdf: negative evaluation time");
142 if (out[t] == zero) out[t] = fine;
143 }
144 return out;
145}
146
147/**
148 * The log of the normalizing constant of a load-dependent closed model,
149 * dispatched as the reference dispatches it: pfqn_comomrm_ld when there is at
150 * most one queueing station, pfqn_mvald otherwise. Reports the stability flag
151 * that only pfqn_mvald can lower.
152 */
153template <class T>
154double stdf_lg(const Matrix<T>& L, const std::vector<int>& N, const Matrix<T>& Z,
155 const Matrix<T>& mu, bool singleStation, bool* stable) {
156 if (singleStation) return pfqn_comomrm_ld(L, N, Z, mu).lG;
157 const MvaLdResult<T> r = pfqn_mvald(L, N, Z, mu);
158 if (!r.isNumStable && stable != nullptr) *stable = false;
159 return r.lG;
160}
161
162/**
163 * CDF of a hypoexponential with the given stage rates, evaluated WITHOUT
164 * forming 1 - survival.
165 *
166 * WHY THIS EXISTS. map_cdf returns F(t) = 1 - pie exp(D0 t) e. For s in
167 * [1/2, 2] the subtraction is exact by Sterbenz, so all of F's error is the
168 * error already in s, which is of order p u whatever the size of F. At the
169 * small probe times the sojourn-time recursion visits, F itself is far below
170 * p u and the returned value is pure round-off: the deepest level CDFs come
171 * back as +-2.2e-16 where the true values are 1e-25 or smaller, and the tilted
172 * rate lattice, which divides by them, is then meaningless. Measured against a
173 * 50-digit reference at t = 1e-6 the complement form is 7.6e-05 relative wrong
174 * in the port and 2.1e-05 in MATLAB, at a level eight orders above eps and
175 * nowhere near any noise floor.
176 *
177 * THE METHOD. Uniformize the pure-birth stage process at Lambda = max r_i and
178 * carry the absorbing state explicitly: a_n, the probability that all p stages
179 * are done within n uniformized steps, is nondecreasing in [0, 1] and is
180 * accumulated from nonnegative flow, and
181 *
182 * F(t) = sum_{n >= p} Poisson(n; Lambda t) a_n,
183 *
184 * a sum of nonnegative terms with no cancellation anywhere. Every stage
185 * transition probability r_i / Lambda lies in [0, 1] and the self-loop
186 * 1 - r_i / Lambda is exact for the largest rate and benign otherwise. The
187 * series is truncated on a bound for the Poisson tail, itself a positive
188 * quantity, so the truncation error is controlled rather than assumed.
189 *
190 * This is the same construction the JAR reaches for by a different route
191 * (Foxglynn uniformization in Map_cdf, taken when D0 has no negative
192 * off-diagonal entry). The C++ map_cdf header records that path as existing
193 * "only for speed"; that is measurably wrong, and it is also the accurate
194 * path at small t.
195 *
196 * DELIBERATE DIVERGENCE FROM MATLAB, authorized: where this disagrees with the
197 * reference in this regime, the reference is the one that is wrong.
198 */
199template <class T>
200T stdf_hypoexp_cdf(const std::vector<T>& r, const T& t) {
201 const T zero = num_traits<T>::from_int(0);
202 const T one = num_traits<T>::from_int(1);
203 const std::size_t p = r.size();
204 T lambda = r[0];
205 for (std::size_t i = 1; i < p; ++i)
206 if (lambda < r[i]) lambda = r[i];
207 const T x = T(lambda * t);
208
209 std::vector<T> step(p);
210 for (std::size_t i = 0; i < p; ++i) step[i] = T(r[i] / lambda);
211
212 std::vector<T> v(p, zero);
213 v[0] = one;
214 T a = zero;
215 using std::exp; // ADL takes the T-native exponential, not a double one
216 T pois = exp(T(zero - x));
217 T F = zero;
218 const T tol = num_traits<T>::from_double(1e-20);
219 // p stages cannot complete in fewer than p uniformized steps, so a_n is
220 // zero until then and the sum only starts contributing at n = p.
221 for (std::size_t n = 0; n <= 100000; ++n) {
222 if (n > 0) {
223 const T out = T(v[p - 1] * step[p - 1]);
224 for (std::size_t i = p - 1; i >= 1; --i)
225 v[i] = T(v[i] * T(one - step[i])) + T(v[i - 1] * step[i - 1]);
226 v[0] = T(v[0] * T(one - step[0]));
227 a += out;
228 pois = T(pois * x) / num_traits<T>::from_int(static_cast<long>(n));
229 }
230 F += T(pois * a);
231 if (n >= p) {
232 const T nx = num_traits<T>::from_int(static_cast<long>(n) + 2);
233 if (x < nx) {
234 // the Poisson tail beyond n, bounded by its geometric majorant
235 const T next = T(pois * x) / num_traits<T>::from_int(static_cast<long>(n) + 1);
236 const T tail = T(next / T(one - T(x / nx)));
237 if (tail <= T(F * tol) || tail == zero) return F;
238 }
239 }
240 }
241 throw NumericError("pfqn_stdf: the level CDF series did not converge");
242}
243
244/**
245 * The level CDF h_k(t | n): Exp(rate) below the server count, and
246 * Exp(rate) + Erlang_{n-S+1}(S rate) at or above it.
247 *
248 * THE TWO FORMS ARE NOT A FALLBACK PAIR: each is the accurate one in its own
249 * regime, and the switch is placed where they exchange that role. The series
250 * sums about Lambda t nonnegative terms, so its relative error grows with the
251 * term count while the complement's shrinks as F leaves the round-off floor;
252 * they cross where F stops being small, that is at Lambda t = p, the mean of
253 * the stage process. Below it the complement returns round-off and the series
254 * is exact to the last digit; above it the series is a few ulp low where the
255 * complement is exact -- measured, at Lambda t = 44.8, as F = 1 - 2.2e-16
256 * against a true value that rounds to exactly one.
257 */
258template <class T>
259T stdf_level_cdf(const T& rate, int S, int n, const T& t) {
260 std::vector<T> r;
261 r.push_back(rate);
262 if (n >= S) {
263 const T sr = T(num_traits<T>::from_int(S) * rate);
264 for (int j = 0; j < n - S + 1; ++j) r.push_back(sr);
265 }
266 T lambda = r[0];
267 for (std::size_t i = 1; i < r.size(); ++i)
268 if (lambda < r[i]) lambda = r[i];
269 if (T(lambda * t) <= num_traits<T>::from_int(static_cast<long>(r.size())))
270 return stdf_hypoexp_cdf(r, t);
271 const T one = num_traits<T>::from_int(1);
272 mam::Map<T> h;
273 if (n < S) {
274 h = mam::map_exponential_mean(T(one / rate));
275 } else {
276 std::vector<mam::Map<T>> parts;
277 parts.push_back(mam::map_exponential_mean(T(one / rate)));
278 parts.push_back(mam::map_erlang(
279 T(num_traits<T>::from_int(n - S + 1) / (num_traits<T>::from_int(S) * rate)),
280 static_cast<unsigned>(n - S + 1)));
281 h = mam::map_sumind(parts);
282 }
283 std::vector<T> one_t;
284 one_t.push_back(t);
285 return mam::map_cdf(h, one_t)[0];
286}
287
288/** The tilted rate lattice gamma(t, .) and its mushifted, truncated form. */
289template <class T>
290void stdf_gamma(const Matrix<T>& mu, const Matrix<T>& hkc, std::size_t t, std::size_t k, int sumNr,
291 bool truncate, Matrix<T>& gammat, Matrix<T>& gammak) {
292 gammat = mu;
293 for (int m = 1; m <= sumNr; ++m)
294 gammat(k, static_cast<std::size_t>(m - 1)) =
295 mu(k, static_cast<std::size_t>(m - 1)) *
296 hkc(t, static_cast<std::size_t>(m - 1)) / hkc(t, static_cast<std::size_t>(m));
297 gammak = pfqn_mushift(gammat, k);
298 if (!truncate) return;
299 // column-truncation width rationale: see _kb/03-api-layer.md (cpp port notes: pfqn)
300 const std::size_t keep = sumNr >= 1 ? static_cast<std::size_t>(sumNr - 1) : 0;
301 Matrix<T> g(gammak.rows(), keep);
302 for (std::size_t i = 0; i < gammak.rows(); ++i)
303 for (std::size_t j = 0; j < keep; ++j) g(i, j) = gammak(i, j);
304 gammak = g;
305}
306
307} // namespace detail
308
309/**
310 * Sojourn-time distribution at the listed FCFS stations.
311 *
312 * @param L (M x R) service demands
313 * @param N (R) closed population vector
314 * @param Z (K x R) think times, summed over rows; may be empty
315 * @param S (M) server counts
316 * @param fcfsNodes 0-based indices of the FCFS stations to analyze
317 * @param rates (M x R) service rates; the rates of an analyzed FCFS station
318 * must agree across classes to within FineTol
319 * @param tset evaluation times; a zero entry is replaced by FineTol
320 */
321template <class T>
322StdfResult<T> pfqn_stdf(const Matrix<T>& L, const std::vector<int>& N, const Matrix<T>& Z,
323 const std::vector<int>& S, const std::vector<std::size_t>& fcfsNodes,
324 const Matrix<T>& rates, const std::vector<T>& tset) {
326 "pfqn_stdf requires transcendental arithmetic: the level CDFs are matrix "
327 "exponentials and the normalizing constants are combined in the log domain");
328
329 const std::size_t M = L.rows();
330 const std::size_t R = L.cols();
331 if (R != N.size()) throw InputError("pfqn_stdf: L and N disagree on the class count");
332 if (S.size() != M) throw InputError("pfqn_stdf: S has the wrong length");
333 if (rates.rows() != M || rates.cols() != R)
334 throw InputError("pfqn_stdf: rates has the wrong shape");
335 for (std::size_t k = 0; k < M; ++k)
336 if (S[k] < 1) throw InputError("pfqn_stdf: the server count must be at least one");
337
338 int Nt = 0;
339 for (int n : N) {
340 if (n < 0) throw InputError("pfqn_stdf: negative population");
341 Nt += n;
342 }
343 if (Nt < 1) throw InputError("pfqn_stdf: the population must be at least one");
344
345 const T zero = num_traits<T>::from_int(0);
346 const T one = num_traits<T>::from_int(1);
348 const std::size_t nt = tset.size();
349
350 const Matrix<T> mu = detail::stdf_mu<T>(S, Nt);
351 const std::vector<T> tv = detail::stdf_guard_tset(tset);
352
353 StdfResult<T> res;
354 res.tset = tv;
355 res.RD.assign(M, std::vector<Matrix<T>>(R));
356
357 const bool singleStation = (M == 1);
358
359 for (std::size_t ki = 0; ki < fcfsNodes.size(); ++ki) {
360 const std::size_t k = fcfsNodes[ki];
361 if (k >= M) throw InputError("pfqn_stdf: FCFS station index out of range");
362
363 T lo = rates(k, 0), hi = rates(k, 0);
364 for (std::size_t r = 1; r < R; ++r) {
365 if (rates(k, r) < lo) lo = rates(k, r);
366 if (rates(k, r) > hi) hi = rates(k, r);
367 }
368 if (T(hi - lo) > fine)
369 throw InputError(
370 "pfqn_stdf: the FCFS station has distinct per-class service rates, the model is "
371 "invalid");
372 if (!(rates(k, 0) > zero))
373 throw InputError("pfqn_stdf: the FCFS service rate must be strictly positive");
374
375 // Level CDFs h_k(t | n), n = 0, ..., sum(N), computed without forming
376 // 1 - survival: the tilted lattice divides by these and the complement
377 // returns round-off at the probe times the recursion visits.
378 Matrix<T> hkc(nt, static_cast<std::size_t>(Nt) + 1);
379 for (int n = 0; n <= Nt; ++n)
380 for (std::size_t t = 0; t < nt; ++t)
381 hkc(t, static_cast<std::size_t>(n)) =
382 detail::stdf_level_cdf(rates(k, 0), S[k], n, tv[t]);
383
384 const Matrix<T> Lk = detail::stdf_drop_row(L, k);
385 const Matrix<T> muk = detail::stdf_drop_row(mu, k);
386
387 for (std::size_t r = 0; r < R; ++r) {
388 if (!(L(k, r) > fine)) continue;
389 std::vector<int> Nr = N;
390 Nr[r] -= 1;
391 int sumNr = 0;
392 for (int n : Nr) sumNr += n;
393
394 const double lGr = detail::stdf_lg(L, Nr, Z, mu, singleStation, &res.isNumStable);
395 const double lGk = detail::stdf_lg(Lk, Nr, Z, muk, singleStation, &res.isNumStable);
396
397 Matrix<T> RD(nt, 2);
398 for (std::size_t t = 0; t < nt; ++t) RD(t, 1) = tv[t];
399
400 Matrix<T> gammat, gammak;
401 for (std::size_t t = 0; t < nt; ++t) {
402 detail::stdf_gamma(mu, hkc, t, k, sumNr, true, gammat, gammak);
403 T H = hkc(t, 0) * num_traits<T>::from_double(std::exp(lGk));
404 for (std::size_t s = 0; s < R; ++s) {
405 if (Nr[s] <= 0) continue;
406 std::vector<int> Nrs = Nr;
407 Nrs[s] -= 1;
408 const double lY =
409 detail::stdf_lg(L, Nrs, Z, gammak, singleStation, &res.isNumStable);
410 H += L(k, s) * hkc(t, 0) / gammat(k, 0) *
411 num_traits<T>::from_double(std::exp(lY));
412 }
413 if (!(H == H)) H = fine; // the reference's isnan guard
414 const double lH = num_traits<T>::log_as_double(H);
415 const T v = num_traits<T>::from_double(std::exp(lH - lGr));
416 RD(t, 0) = v > one ? one : v; // min(1, .), which the heuristic omits
417 }
418 res.RD[k][r] = RD;
419 }
420 }
421 return res;
422}
423
424} // namespace pfqn
425} // namespace line
426
427#endif // LINE_API_PFQN_STDF_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
The exception types the port throws.
Cumulative distribution of the inter-arrival time of a MAP.
MAP constructors and structural transformations.
Dense matrix and non-owning view.
Map< T > map_erlang(const T &mean, unsigned k)
Erlang-k renewal MAP with the given mean (map_erlang.m).
Map< T > map_exponential_mean(const T &mean)
Poisson process with the given mean inter-arrival time (map_exponential.m).
Map< T > map_sumind(const std::vector< Map< T > > &maps)
Sum of independent, not necessarily identical MAPs: after each component completes,...
std::vector< T > map_cdf(const Map< T > &m, const std::vector< T > &points)
Cumulative distribution of the inter-arrival time at the given points.
Definition map_cdf.h:63
StdfResult< T > pfqn_stdf(const Matrix< T > &L, const std::vector< int > &N, const Matrix< T > &Z, const std::vector< int > &S, const std::vector< std::size_t > &fcfsNodes, const Matrix< T > &rates, const std::vector< T > &tset)
Sojourn-time distribution at the listed FCFS stations.
Definition pfqn_stdf.h:322
MvaLdResult< T > pfqn_mvald(const Matrix< T > &L, const std::vector< int > &N, const Matrix< T > &Z, const Matrix< T > &mu, bool stabilize=true)
Exact MVA for a closed network of load-dependent stations.
Definition pfqn_mvams.h:133
Matrix< T > pfqn_mushift(const Matrix< T > &mu, const std::vector< std::size_t > &iset)
Shift the load-dependent service-rate lattice of selected stations.
static const double kStdfFineTol
GlobalConstants.FineTol, as set by matlab/lineStart.m.
Definition pfqn_stdf.h:88
ComomRmResult< T > pfqn_comomrm_ld(const Matrix< T > &L, const std::vector< int > &N, const Matrix< T > &Z, const Matrix< T > &mu)
CoMoM for the repairman model with an arbitrary LOAD-DEPENDENT rate lattice at the single queueing st...
Number-type abstraction for the templated API port.
CoMoM for the repairman model with an arbitrary LOAD-DEPENDENT rate lattice at the single queueing st...
Shift the load-dependent service-rate lattice of selected stations.
Exact Mean Value Analysis for mixed open/closed networks with multiserver stations.
Result of pfqn_stdf / pfqn_stdf_heur, mirroring the MATLAB cell array RD.
Definition pfqn_stdf.h:97
std::vector< std::vector< Matrix< T > > > RD
Definition pfqn_stdf.h:98
bool isNumStable
false once an aggregate solve reported instability
Definition pfqn_stdf.h:100
std::vector< T > tset
the time set after the zero guard
Definition pfqn_stdf.h:99