LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_qna.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_MVA_SOLVER_QNA_H
6#define LINE_SOLVERS_MVA_SOLVER_QNA_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * QNA, the two-moment open-network decomposition analyzer.
12 *
13 * Templated port of `matlab/src/solvers/MVA/solver_qna.m`, which implements
14 * Section 7.2.3 of N. Gautam, "Analysis of Queues", CRC Press 2012, with
15 * corrections the reference records as agreed with the author.
16 *
17 * The structure is decomposition-aggregation. Each sweep
18 *
19 * 1. SUPERPOSES the flows into every station: a1(i,r) is the arrival rate of
20 * class r at station i and a2(i,r) the squared coefficient of variation of
21 * that superposed stream, both accumulated over the routing;
22 * 2. SOLVES each station in isolation -- an infinite server passes its input
23 * SCV straight through, a PS station takes a geometric-bound queue length,
24 * and an FCFS station takes the multiserver GI/G/c waiting-time
25 * approximation with the Whitt alpha(m) correction;
26 * 3. SPLITS the departure stream along each outgoing arc, `f2 = 1 + p (d2-k)`
27 * with k the round-robin split degree (`npfqn_traffic_split_rr`): at k = 1
28 * this is the exact SCV of a Bernoulli-thinned renewal stream, and at k > 1
29 * that of a one-in-k deterministic dispatch, which is less variable;
30 *
31 * and `da_fpi` drives the sweeps to a fixed point on the queue lengths.
32 *
33 * SCOPE. OPEN CHAINS ONLY. The reference reaches `State.toMarginal` to seed a
34 * closed chain's queue length (`solver_qna.m:94`), which needs a state-encoding
35 * layer this port does not have, and `SolverMVA.listValidMethods` advertises
36 * `qna` only for a fully open model in the first place. A closed chain is
37 * refused by name rather than seeded with a guess.
38 *
39 * REFERENCE INDEXING, REPRODUCED AND CHECKED. `solver_qna.m` indexes `sn.rt`,
40 * which is STATEFUL-indexed, with STATION indices, and reads `sn.njobs`, which
41 * is CLASS-indexed, with a CHAIN index. Both are silently correct exactly when
42 * the stations coincide with the stateful nodes and the chains with the
43 * classes, which is the shape of the models the method is advertised for. This
44 * port checks that instead of assuming it, and refuses by name otherwise -- the
45 * alternative is reading an unrelated row and returning a number.
46 *
47 * SELF-LOOPING CLASSES. The reference special-cases `sn.isslc` in three places.
48 * This port has no SelfLoopingClass (`JobClassType` is OPEN or CLOSED only), so
49 * those branches are unreachable and are not transcribed; a class type this
50 * port cannot build cannot reach here.
51 *
52 * Arithmetic: TRANSCENDENTAL. `da_fpi` stops on a tolerance, and the alpha(m)
53 * correction takes a real power of the utilization.
54 */
55
56#include <cmath>
57#include <string>
58#include <vector>
59
60#include "line/api/da/da_fpi.h"
65
66namespace line {
67namespace mva {
68
69/**
70 * Port of `solver_qna.m`.
71 *
72 * @param L the model; open chains only
73 * @param opt tol drives the saturation test, iter_max / iter_tol the sweeps
74 */
75template <class T>
77 using qn::NodeType;
79 if constexpr (!num_traits<T>::has_transcendental) {
80 (void)L;
81 (void)opt;
82 throw UnsupportedError(
83 "solver_qna: the decomposition sweeps stop on a tolerance and the multiserver "
84 "correction takes a real power, so exact rational arithmetic cannot run it");
85 } else {
86 const T zero = num_traits<T>::from_int(0);
87 const T one = num_traits<T>::from_int(1);
88 const std::size_t M = L.nstations, K = L.nclasses, C = L.nchains;
89
90 for (const auto& c : L.classes)
91 if (std::isfinite(c.population))
92 throw UnsupportedError(
93 "solver_qna: QNA is an open-network decomposition; a closed chain needs the "
94 "state-encoding layer the reference seeds it from, which is not ported");
95 if (L.nof_stateful() != M)
96 throw UnsupportedError(
97 "solver_qna: the reference indexes the stateful-indexed sn.rt with station indices, "
98 "which is only correct when every stateful node is a station; this model has " +
99 std::to_string(L.nof_stateful()) + " stateful nodes and " + std::to_string(M) +
100 " stations");
101 if (C != K)
102 throw UnsupportedError(
103 "solver_qna: the reference reads the class-indexed sn.njobs with a chain index, which "
104 "is only correct when each chain holds exactly one class");
105 // One predicate for the gate and the run: qn::mva_feature_set withholds 'qna'
106 // for a discipline the station update has no arm for, and the update refuses
107 // it by name rather than leaving that station's row of Q, U, R and T at zero
108 // and returning the table as a solution.
109 {
110 const std::string qna_reason = mva_qna_scheduling_reason(L);
111 if (!qna_reason.empty()) throw UnsupportedError(qna_reason);
112 }
113
114 // S = 1/rates, with a NaN scv read as zero, as the reference does
115 Matrix<T> S(M, K, zero), scv(M, K, zero);
116 for (std::size_t i = 0; i < M; ++i)
117 for (std::size_t r = 0; r < K; ++r) {
118 const T mu = L.rates(i, r);
119 S(i, r) = (mu > zero) ? T(one / mu) : zero;
120 const double v = num_traits<T>::to_double(L.scv(i, r));
121 scv(i, r) = std::isnan(v) ? zero : L.scv(i, r);
122 }
123
124 // V(i,k): the visits summed over chains, at station level
125 Matrix<T> V(M, K, zero);
126 for (std::size_t c = 0; c < C; ++c)
127 for (std::size_t i = 0; i < M; ++i)
128 for (std::size_t k = 0; k < K; ++k)
129 V(i, k) = T(V(i, k) + L.visits[c](L.stateful_of_station(i + 1) - 1, k));
130
131 const Matrix<T>& rt = L.rt;
132 auto RT = [&](std::size_t i, std::size_t r, std::size_t j, std::size_t s) -> T {
133 return rt(i * K + r, j * K + s);
134 };
135
136 // The deterministic (round-robin) split degrees, 1 where the split is
137 // Markovian; a flow thinned out of a one-in-k dispatch is the k-fold
138 // convolution, whose SCV at d2 = 1 is 1 + p (1 - k), below the renewal 1.
140
141 // f2(i,r -> j,s): the SCV of each flow, initialised on every arc that does
142 // not end at a Source
143 Matrix<T> f2(M * K, M * K, zero);
144 std::vector<bool> is_source(M, false);
145 for (std::size_t i = 0; i < M; ++i)
146 is_source[i] = (L.stations[i].nodetype == NodeType::Source);
147 for (std::size_t i = 0; i < M; ++i)
148 for (std::size_t j = 0; j < M; ++j) {
149 if (is_source[j]) continue;
150 for (std::size_t r = 0; r < K; ++r)
151 for (std::size_t s = 0; s < K; ++s)
152 if (RT(i, r, j, s) > zero)
153 f2(i * K + r, j * K + s) =
154 T(one + RT(i, r, j, s) * T(one - kRR(i, r)));
155 }
156
157 // per-chain arrival rate and the SCV of the superposed source stream
158 std::vector<T> lambda(C, zero), d2c(C, zero);
159 Matrix<T> Tp(M, K, zero), Q(M, K, zero), U(M, K, zero), Rt(M, K, zero);
160 for (std::size_t c = 0; c < C; ++c) {
161 const std::size_t ref = L.classes[L.inchain[c][0] - 1].refstat;
162 std::vector<T> lam_in, scv_in;
163 for (std::size_t k : L.inchain[c]) {
164 lam_in.push_back(L.rates(ref - 1, k - 1));
165 scv_in.push_back(scv(ref - 1, k - 1));
166 }
167 T s = zero;
168 for (const T& x : lam_in)
169 if (std::isfinite(num_traits<T>::to_double(x))) s += x;
170 lambda[c] = s;
171 d2c[c] = da::da_traffic_superpos(lam_in, scv_in);
172 for (std::size_t a = 0; a < L.inchain[c].size(); ++a)
173 Tp(ref - 1, L.inchain[c][a] - 1) = lam_in[a];
174 }
175 // d2(i): the SCV of the departure stream of station i. Every reference
176 // station is seeded with the SAME rate-weighted mean over the chains, which
177 // is what the reference's `d2(refStatIdx) = d2c*lambda'/sum(lambda)` writes
178 // once per chain.
179 std::vector<T> d2(M, zero);
180 {
181 T num = zero, den = zero;
182 for (std::size_t c = 0; c < C; ++c) {
183 num += T(d2c[c] * lambda[c]);
184 den += lambda[c];
185 }
186 const T seed = (den > zero) ? T(num / den) : zero;
187 for (std::size_t c = 0; c < C; ++c)
188 d2[L.classes[L.inchain[c][0] - 1].refstat - 1] = seed;
189 }
190
191 Matrix<T> a1(M, K, zero), a2(M, K, zero);
192 const T tol = num_traits<T>::from_double(opt.tol);
193
194 // One decomposition sweep. `da_fpi` iterates it on the flattened queue
195 // lengths; every other quantity is carried in the enclosing scope, as the
196 // reference carries it in the enclosing function's workspace.
197 auto sweep = [&](const std::vector<T>& qin,
198 std::size_t itnum) -> std::pair<std::vector<T>, std::vector<T>> {
199 for (std::size_t i = 0; i < M; ++i)
200 for (std::size_t k = 0; k < K; ++k) Q(i, k) = qin[i * K + k];
201
202 // THE RENORMALISATION IS LOAD BEARING, AND IT IS WHAT ENDS THE LOOP.
203 // The reference rescales each chain's queue lengths to its population,
204 // `Q(:,c) = njobs(c) * Q(:,c) / sum(Q(:,c))`. On an OPEN chain njobs is
205 // infinite and the initial iterate is zero, so this is Inf * 0 / 0 =
206 // NaN; the reference point handed back to da_fpi is therefore NaN, the
207 // convergence measure is NaN, and `da_nanstop` -- which the reference
208 // sets precisely to reproduce its legacy while-loop -- stops the
209 // iteration after ONE sweep. QNA as the reference ships it does not
210 // iterate its fixed point on an open model, and the numbers it reports
211 // are the first sweep's, computed with every flow SCV still at its
212 // initial 1. Reproduced rather than corrected: skipping the NaN lets
213 // the sweeps run to their actual fixed point and changes every result
214 // (Q1 = 0.75 instead of the reference's 0.875 on the Erlang tandem).
215 std::vector<T> qref(M * K, zero);
216 for (std::size_t c = 0; c < C; ++c) {
217 const double nc = L.classes[c].population;
218 T colsum = zero;
219 for (std::size_t i = 0; i < M; ++i) colsum += Q(i, c);
220 for (std::size_t i = 0; i < M; ++i)
223 }
224 for (std::size_t i = 0; i < M; ++i)
225 for (std::size_t k = 0; k < K; ++k) qref[i * K + k] = Q(i, k);
226
227 if (itnum == 1)
228 for (std::size_t c = 0; c < C; ++c)
229 for (std::size_t m = 0; m < M; ++m)
230 for (std::size_t k : L.inchain[c])
231 Tp(m, k - 1) = T(V(m, k - 1) * lambda[c]);
232
233 // ---- superposition ------------------------------------------------
234 for (std::size_t i = 0; i < M; ++i) {
235 T lambda_i = zero;
236 for (std::size_t k = 0; k < K; ++k) lambda_i += Tp(i, k);
237 for (std::size_t r = 0; r < K; ++r) {
238 a1(i, r) = zero;
239 a2(i, r) = zero;
240 }
241 for (std::size_t j = 0; j < M; ++j)
242 for (std::size_t r = 0; r < K; ++r)
243 for (std::size_t s = 0; s < K; ++s) {
244 const T p = RT(j, s, i, r);
245 if (!(p > zero)) continue;
246 a1(i, r) = T(a1(i, r) + Tp(j, s) * p);
247 if (lambda_i > zero)
248 a2(i, r) = T(a2(i, r) + T(one / lambda_i) * f2(j * K + s, i * K + r) *
249 Tp(j, s) * p);
250 }
251 }
252
253 // ---- solve each station in isolation ------------------------------
254 for (std::size_t i = 0; i < M; ++i) {
255 if (L.stations[i].nodetype == NodeType::Join) continue; // no-op, as in the reference
256 switch (L.stations[i].sched) {
257 case SchedStrategy::INF: {
258 // departure SCV of a delay: MATLAB writes d2(ist,s)=a2(ist,s)
259 // but every downstream read is the scalar d2(ist), i.e. the
260 // FIRST class column a2(ist,1); JAR reads the same. Use a2(i,0).
261 d2[i] = a2(i, 0);
262 for (std::size_t k = 0; k < K; ++k) {
263 Tp(i, k) = a1(i, k);
264 Q(i, k) = T(Tp(i, k) * S(i, k) * V(i, k));
265 U(i, k) = Q(i, k);
266 Rt(i, k) = Tp(i, k) > zero ? T(Q(i, k) / Tp(i, k)) : zero;
267 }
268 break;
269 }
270 case SchedStrategy::PS: {
271 for (std::size_t c = 0; c < C; ++c) {
272 for (std::size_t k : L.inchain[c]) {
273 Tp(i, k - 1) = T(lambda[c] * V(i, k - 1));
274 U(i, k - 1) = T(S(i, k - 1) * Tp(i, k - 1));
275 }
276 T usum = zero;
277 for (std::size_t k = 0; k < K; ++k) usum += U(i, k);
278 const T uden = usum < T(one - tol) ? usum : T(one - tol);
279 // Nc is infinite on an open chain, so U^(Nc+1) vanishes
280 // for a stable station and the geometric bound is
281 // U/(1-Uden); the reference writes the finite-population
282 // form, whose limit this is.
283 for (std::size_t k : L.inchain[c]) {
284 Q(i, k - 1) = T(one - uden) > zero
285 ? T(U(i, k - 1) / T(one - uden))
286 : zero;
287 Rt(i, k - 1) =
288 Tp(i, k - 1) > zero ? T(Q(i, k - 1) / Tp(i, k - 1)) : zero;
289 }
290 }
291 break;
292 }
293 case SchedStrategy::FCFS: {
294 using std::pow;
295 using std::sqrt;
297 std::vector<T> mu(K, zero), rho_cls(K, zero);
298 T lambda_i = zero;
299 for (std::size_t r = 0; r < K; ++r) {
300 const double m = num_traits<T>::to_double(L.rates(i, r));
301 mu[r] = std::isnan(m) ? zero : L.rates(i, r);
302 const T den = T(ftol + mu[r]);
303 rho_cls[r] = den > zero ? T(a1(i, r) / den) : zero;
304 if (std::isnan(num_traits<T>::to_double(rho_cls[r]))) rho_cls[r] = zero;
305 lambda_i += a1(i, r);
306 }
307 const T mi = num_traits<T>::from_double(L.stations[i].nservers);
308 T rho = zero;
309 for (std::size_t r = 0; r < K; ++r) rho += rho_cls[r];
310 rho = T(rho / mi);
311 if (rho < T(one - tol)) {
312 // Whitt's alpha(m): the heavier form above rho = 0.7
313 const T alpha = num_traits<T>::to_double(rho) > 0.7
314 ? T(T(pow(rho, mi) + rho) / num_traits<T>::from_int(2))
315 : T(pow(rho, T(T(mi + one) / num_traits<T>::from_int(2))));
316 const T mubar = rho > zero ? T(lambda_i / rho) : zero;
317 T c2 = T(-one);
318 for (std::size_t r = 0; r < K; ++r) {
319 if (!(mu[r] > zero) || !(lambda_i > zero)) continue;
320 const T q = T(mubar / mi / mu[r]);
321 c2 += T(a1(i, r) / lambda_i * q * q * T(scv(i, r) + one));
322 }
323 T a2sum = zero;
324 for (std::size_t r = 0; r < K; ++r) a2sum += a2(i, r);
325 const T Wiq = mubar > zero
326 ? T(T(alpha / mubar) * T(one / T(one - rho)) *
327 T(T(a2sum + c2) / num_traits<T>::from_int(2)))
328 : zero;
329 for (std::size_t k = 0; k < K; ++k)
330 Q(i, k) = mu[k] > zero ? T(a1(i, k) / mu[k] + a1(i, k) * Wiq) : zero;
331 d2[i] = T(one + T(rho * rho * T(c2 - one) / sqrt(mi)) +
332 T(T(one - rho * rho) * T(a2sum - one)));
333 } else {
334 // saturated: the reference parks the queue length at the
335 // class population, which is infinite on an open class
336 for (std::size_t k = 0; k < K; ++k)
337 Q(i, k) = num_traits<T>::from_double(L.classes[k].population);
338 d2[i] = one;
339 }
340 for (std::size_t k = 0; k < K; ++k) {
341 Tp(i, k) = a1(i, k);
342 U(i, k) = T(Tp(i, k) * S(i, k) / mi);
343 Rt(i, k) = Tp(i, k) > zero ? T(Q(i, k) / Tp(i, k)) : zero;
344 }
345 break;
346 }
347 default:
348 // EXT (a Source or a Sink) carries no queue; every other
349 // discipline is one the reference's switch does not handle,
350 // and falling through would leave the station unsolved.
351 if (L.stations[i].sched != SchedStrategy::EXT)
352 throw UnsupportedError(
353 std::string("solver_qna: no isolated-station solution for ") +
354 lang::sched_to_text(L.stations[i].sched) + " scheduling");
355 break;
356 }
357 }
358
359 // ---- splitting ----------------------------------------------------
360 for (std::size_t i = 0; i < M; ++i)
361 for (std::size_t j = 0; j < M; ++j) {
362 if (is_source[j]) continue;
363 for (std::size_t r = 0; r < K; ++r)
364 for (std::size_t s = 0; s < K; ++s)
365 if (RT(i, r, j, s) > zero)
366 // k-fold convolution then Bernoulli thinning at
367 // q = k p: C^2 = 1 + p (d2 - k), the Markovian
368 // 1 + p (d2 - 1) at k = 1
369 f2(i * K + r, j * K + s) =
370 T(one + RT(i, r, j, s) * T(d2[i] - kRR(i, r)));
371 }
372
373 std::vector<T> qnew(M * K, zero);
374 for (std::size_t i = 0; i < M; ++i)
375 for (std::size_t k = 0; k < K; ++k) qnew[i * K + k] = Q(i, k);
376 return std::make_pair(qnew, qref);
377 };
378
380 // the legacy while-loop ran one extra sweep at the cap, and exited on a
381 // non-finite convergence measure
382 fo.iter_max = static_cast<std::size_t>(opt.iter_max) + 1;
383 fo.iter_tol = opt.iter_tol;
384 fo.nanstop = true;
385 const da::FpiResult<T> fr = da::da_fpi<T>(sweep, std::vector<T>(M * K, zero), fo);
386 for (std::size_t i = 0; i < M; ++i)
387 for (std::size_t k = 0; k < K; ++k) Q(i, k) = fr.x[i * K + k];
388
389 // an infinite server's utilization IS its queue length
390 for (std::size_t i = 0; i < M; ++i)
391 if (L.stations[i].sched == SchedStrategy::INF)
392 for (std::size_t k = 0; k < K; ++k) U(i, k) = Q(i, k);
393
394 MvaSolution<T> out;
395 out.Q = Q;
396 out.U = U;
397 out.R = Rt;
398 out.Tp = Tp;
399 out.C.assign(K, zero);
400 out.X.assign(K, zero);
401 for (std::size_t k = 0; k < K; ++k)
402 for (std::size_t i = 0; i < M; ++i) out.C[k] += Rt(i, k);
403 // the reference takes |Q| and maps every NaN to zero on the way out
404 auto clean = [](Matrix<T>& A) {
405 for (std::size_t i = 0; i < A.rows(); ++i)
406 for (std::size_t j = 0; j < A.cols(); ++j)
407 if (std::isnan(num_traits<T>::to_double(A(i, j))))
408 A(i, j) = num_traits<T>::from_int(0);
409 };
410 for (std::size_t i = 0; i < out.Q.rows(); ++i)
411 for (std::size_t j = 0; j < out.Q.cols(); ++j)
412 if (out.Q(i, j) < zero) out.Q(i, j) = T(-out.Q(i, j));
413 clean(out.Q);
414 clean(out.U);
415 clean(out.R);
416 for (std::size_t k = 0; k < K; ++k)
417 if (std::isnan(num_traits<T>::to_double(out.C[k]))) out.C[k] = zero;
418 out.method = "qna";
419 out.iter = static_cast<int>(fr.iterations);
420 out.lG = 0.0;
421 return out;
422 }
423}
424
425} // namespace mva
426} // namespace line
427
428#endif // LINE_SOLVERS_MVA_SOLVER_QNA_H
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
std::size_t stateful_of_station(std::size_t st) const
std::size_t nof_stateful() const
Matrix< T > rt
sn.rt and sn.rtnodes: the class-expanded routing.
std::vector< JobClass > classes
std::vector< Station< T > > stations
stations[k-1] is the k-th station
Matrix< T > rates
(nstations x nclasses) service rates and SCVs, with a PARALLEL disabled flag instead of MATLAB's NaN ...
std::vector< std::vector< std::size_t > > inchain
1-based class indices per chain
std::vector< Matrix< T > > visits
(nchains) each (nstateful x nclasses)
Damped fixed-point iteration, the shared driver of the decomposition algorithms.
Superposition of independent renewal flows (Whitt's QNA stationary-interval method).
The option and result types every MVA analyzer shares.
T da_traffic_superpos(const std::vector< T > &lambda, const std::vector< T > &a2)
Superposition of independent renewal flows (Whitt's QNA stationary-interval method).
FpiResult< T > da_fpi(const std::function< std::pair< std::vector< T >, std::vector< T > >(const std::vector< T > &, std::size_t)> &iterfun, const std::vector< T > &x0, const FpiOptions &options=FpiOptions())
Damped fixed-point iteration, the shared driver of the decomposition algorithms.
Definition da_fpi.h:92
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
MvaSolution< T > solver_qna(const qn::NetworkStruct< T > &L, const MvaOptions &opt)
Port of solver_qna.m.
Definition solver_qna.h:76
std::string mva_qna_scheduling_reason(const qn::NetworkStruct< T > &L)
QNA's station update has an arm for INF, PS and FCFS and none for any other discipline,...
Definition mva_types.h:273
Matrix< T > npfqn_traffic_split_rr(const qn::NetworkStruct< T > &sn)
Port of npfqn_traffic_split_rr.m.
A queueing network and its refreshed NetworkStruct.
Deterministic (round-robin) split degrees of every station-class departure stream.
Options mirroring the fields MATLAB reads off the options struct.
Definition da_fpi.h:50
std::size_t iter_max
Definition da_fpi.h:51
bool nanstop
stop when the increment norm is not finite
Definition da_fpi.h:55
std::size_t iterations
Definition da_fpi.h:78
std::vector< T > x
Definition da_fpi.h:77
static constexpr double FineTol
Definition lang_types.h:668
The options SolverMVA reads.
Definition mva_types.h:31
Class-level results, the [Q,U,R,T,C,X] of the MATLAB analyzers.
Definition mva_types.h:96
std::vector< T > X
Definition mva_types.h:98
double lG
log of the normalizing constant, the reference's lG.
Definition mva_types.h:118
std::vector< T > C
Definition mva_types.h:98