LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_sens_table.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_SENS_SOLVER_SENS_TABLE_H
6#define LINE_SOLVERS_SENS_SOLVER_SENS_TABLE_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Performance sensitivities with respect to service rates.
12 *
13 * Port of `matlab/src/solvers/@@NetworkSolver/getSensitivityTable.m`. One row
14 * per (station, class) carrying dTput/dRate, dRespT/dRate, dQLen/dRate and
15 * dUtil/dRate, produced by one of two branches:
16 *
17 * `exact` Analytic differentiation of a product-form recursion. A closed
18 * model goes through `pfqn_sens` (differentiated MVA) evaluated AT
19 * CHAIN LEVEL, an open one through the closed-form BCMP derivatives,
20 * whose stations decouple. Exact to working precision and cheaper
21 * than one extra solve, but defined only where the recursion is:
22 * single-server stations plus delays, and not a mixed model.
23 *
24 * `fd` Forward or central differences on the CALLER'S OWN solver. The
25 * service at (station, class) is rate-scaled by (1 +/- h), the same
26 * solve is re-run and the quotient formed. It costs 1 + M*R solves
27 * (forward) or 2*M*R (central) and is the only branch that applies to
28 * a model with no product form.
29 *
30 * WHY THE SOLVER IS A CALLBACK. The reference reaches this through a method on
31 * NetworkSolver, so `self` names both the model and the engine; there is no
32 * solver base class here, and the finite-difference branch needs to re-run
33 * THE SAME engine with THE SAME options -- an MVA table produced by perturbing
34 * a model that is then solved by CTMC is not a sensitivity of anything. The
35 * callback is that engine, already bound to the struct passed in, and it is
36 * called after this code has written the perturbed service into it.
37 *
38 * A SIMULATION SOLVER MUST BE RUN WITH COMMON RANDOM NUMBERS, or the quotient
39 * measures Monte Carlo error rather than a derivative. There is no seed to pin
40 * here -- the callback owns its options -- so `SensOptions::simulation` only
41 * widens the default step to 1e-2, and a caller wiring a stochastic engine in
42 * is responsible for handing it a fixed seed.
43 */
44
45#include <cmath>
46#include <cstddef>
47#include <functional>
48#include <limits>
49#include <string>
50#include <vector>
51
55#include "line/num/number.h"
58#include "line/util/error.h"
59#include "line/util/matrix.h"
60
61namespace line {
62namespace sens {
63
64/** The name-value contract of getSensitivityTable. */
66 std::string method = "auto"; ///< auto | exact | fd
67 std::string scheme = "forward"; ///< forward | central
68 /** Relative step of the rate perturbation; negative selects the default. */
69 double step = -1.0;
70 /** True when the callback is a simulator, which widens the default step. */
71 bool simulation = false;
72};
73
74/** One (station, class) row of the table. */
75template <class T>
76struct SensRow {
77 std::string station, jobclass;
79};
80
81/** What the table carries, plus the branch that produced it. */
82template <class T>
83struct SensTable {
84 std::vector<SensRow<T>> rows;
85 std::string method; ///< "exact" or "fd", the branch actually taken
86 /** The analytic Jacobian, set only on the CLOSED exact branch. */
87 bool has_jacobian = false;
89};
90
91namespace detail {
92
93/**
94 * Scope of the analytic branch: single-server stations (a delay is allowed) and
95 * not a mixed open-and-closed model. Class switching IS supported -- the branch
96 * aggregates classes into chains before differentiating.
97 */
98template <class T>
99bool sens_exact_in_scope(const qn::NetworkStruct<T>& sn, std::string& why) {
101 try {
103 } catch (const std::exception&) {
104 why = "the product-form parameters of this model could not be extracted";
105 return false;
106 }
107 for (double s : p.S)
108 if (std::isfinite(s) && s > 1.0) {
109 why = "exact sensitivities support single-server stations only";
110 return false;
111 }
112 bool any_open = false, any_closed = false;
113 for (double n : p.N) {
114 if (std::isinf(n)) any_open = true;
115 else any_closed = true;
116 }
117 if (any_open && any_closed) {
118 why = "exact sensitivities do not yet support mixed (open+closed) networks";
119 return false;
120 }
121 return true;
122}
123
124/** (nstations x nclasses) mask of the pairs that carry visits. */
125template <class T>
126std::vector<std::vector<bool>> sens_visit_mask(const qn::NetworkStruct<T>& sn) {
127 std::vector<std::vector<bool>> out(sn.nstations, std::vector<bool>(sn.nclasses, false));
128 for (std::size_t c = 0; c < sn.nchains; ++c) {
129 if (sn.visits[c].empty()) continue;
130 for (std::size_t i = 1; i <= sn.nstations; ++i) {
131 // Resolved through the node, not stateful_of_station: that helper
132 // THROWS on a station with no stateful node, and a mask is not the
133 // place to discover one.
134 const std::size_t nd = sn.node_of_station(i);
135 const std::size_t sf = nd ? sn.stateful_index(nd) : 0;
136 if (sf == 0) continue;
137 for (std::size_t r = 0; r < sn.nclasses; ++r)
138 if (num_traits<T>::to_double(sn.visits[c](sf - 1, r)) > lang::GlobalConstants::Zero)
139 out[i - 1][r] = true;
140 }
141 }
142 return out;
143}
144
145} // namespace detail
146
147/**
148 * Build the sensitivity table of `sn` under `solve`.
149 *
150 * `exact_available` is the reference's `supportsExactSensitivity()`: true for
151 * the engines that evaluate the product-form recursion this branch
152 * differentiates (MVA and NC), false for every other. Asking for `exact` where
153 * it is false is an error rather than a silent downgrade, exactly as in the
154 * reference, because the two branches answer to different precision.
155 *
156 * `solve` is called with `sn` already carrying whatever perturbation this code
157 * has written, and must report the metrics of that struct.
158 */
159template <class T>
161 bool exact_available,
162 const std::function<mva::MvaSolution<T>()>& solve) {
163 using lang::Distrib;
164 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
165 if (opt.method != "auto" && opt.method != "exact" && opt.method != "fd")
166 throw InputError("getSensitivityTable: the method must be 'auto', 'exact' or 'fd'");
167 if (opt.scheme != "forward" && opt.scheme != "central")
168 throw InputError("getSensitivityTable: the scheme must be 'forward' or 'central'");
169
170 std::string why;
171 const bool in_scope = detail::sens_exact_in_scope(sn, why);
172 bool use_exact;
173 if (opt.method == "exact") {
174 if (!exact_available)
175 throw UnsupportedError(
176 "getSensitivityTable: exact analytic sensitivities differentiate a product-form "
177 "recursion and are available on the MVA and NC engines only; use 'fd'");
178 if (!in_scope) throw UnsupportedError("getSensitivityTable: " + why);
179 use_exact = true;
180 } else if (opt.method == "fd") {
181 use_exact = false;
182 } else {
183 use_exact = exact_available && in_scope;
184 }
185
187 const std::size_t R = sn.nclasses, Mq = p.queue_stations.size(), C = sn.nchains;
188
189 std::vector<std::vector<bool>> mask(Mq, std::vector<bool>(R, false));
190 Matrix<T> dT(Mq, R, zero), dR(Mq, R, zero), dQ(Mq, R, zero), dU(Mq, R, zero);
191 SensTable<T> out;
192
193 if (use_exact) {
194 out.method = "exact";
195 bool is_open = false;
196 for (double n : p.N)
197 if (std::isinf(n)) is_open = true;
198
199 Matrix<T> rates(Mq, R, zero);
200 for (std::size_t i = 0; i < Mq; ++i) {
201 const std::size_t st = p.queue_stations[i];
202 for (std::size_t r = 0; r < R; ++r) {
203 rates(i, r) = sn.rates(st - 1, r);
204 const double rd = num_traits<T>::to_double(rates(i, r));
205 mask[i][r] = std::isfinite(rd) && rd > 0.0 && p.D(i, r) > zero;
206 }
207 }
208
209 // chainOf(r), and the per-chain aggregates the recursion is evaluated on:
210 // a product-form model is solved per CHAIN, and with class switching a
211 // class is only a share of one.
212 std::vector<std::size_t> chain_of(R, 0);
213 Matrix<T> Dc(Mq, C, zero);
214 std::vector<T> Zc(C, zero);
215 std::vector<int> Nc(C, 0);
216 std::vector<T> lambdac(C, zero);
217 for (std::size_t c = 0; c < C; ++c) {
218 double nsum = 0.0;
219 for (std::size_t r = 0; r < R; ++r) {
220 if (!sn.chains[c][r]) continue;
221 chain_of[r] = c;
222 for (std::size_t i = 0; i < Mq; ++i) Dc(i, c) = T(Dc(i, c) + p.D(i, r));
223 for (std::size_t z = 0; z < p.Z.rows(); ++z) Zc[c] = T(Zc[c] + p.Z(z, r));
224 if (std::isfinite(p.N[r])) nsum += p.N[r];
225 lambdac[c] = T(lambdac[c] + p.lambda[r]);
226 }
227 Nc[c] = static_cast<int>(nsum + 0.5);
228 }
229
230 if (!is_open) {
231 const pfqn::SensResult<T> s = pfqn::pfqn_sens(Dc, Nc, Zc);
232 out.has_jacobian = true;
233 out.jacobian = s;
234 for (std::size_t i = 0; i < Mq; ++i)
235 for (std::size_t r = 0; r < R; ++r) {
236 if (!mask[i][r]) continue;
237 const std::size_t c = chain_of[r];
238 if (!(Dc(i, c) > zero)) continue;
239 const T rate = rates(i, r);
240 const T Dir = p.D(i, r);
241 const T visits = T(Dir * rate); // chain-normalized visit ratio
242 const std::size_t pidx = i * C + c;
243 const T chain = T(-Dir / rate); // dDc(i,c)/dmu(i,r)
244 const T Xc = s.XN[c];
245 const T Qc = s.QN(i, c);
246 const T dXc = T(s.dX(c, pidx) * chain);
247 const T dQc = T(s.dQ[pidx](i, c) * chain);
248 // Class share of the chain queue here, and its own rate dependence.
249 const T alpha = T(Dir / Dc(i, c));
250 const T dalpha = T(chain * (Dc(i, c) - Dir) / (Dc(i, c) * Dc(i, c)));
251 const T Qir = T(alpha * Qc);
252 const T dQir = T(dalpha * Qc + alpha * dQc);
253 const T Tir = T(Xc * visits);
254 const T dTir = T(dXc * visits);
255 dT(i, r) = dTir;
256 dQ(i, r) = dQir;
257 dU(i, r) = T(dXc * Dir + Xc * chain);
258 // Per-visit response time by Little's law, R = Q/T.
259 if (Tir > zero) dR(i, r) = T((dQir * Tir - Qir * dTir) / (Tir * Tir));
260 }
261 } else {
262 // The stations decouple, so only the own service rate moves the
263 // measures at (i, r); the throughput is fixed by the arrival rate.
264 Matrix<T> rho(Mq, R, zero);
265 std::vector<T> Ui(Mq, zero);
266 for (std::size_t i = 0; i < Mq; ++i)
267 for (std::size_t r = 0; r < R; ++r) {
268 if (p.D(i, r) > zero) rho(i, r) = T(lambdac[chain_of[r]] * p.D(i, r));
269 Ui[i] = T(Ui[i] + rho(i, r));
270 }
271 for (std::size_t i = 0; i < Mq; ++i) {
272 const T denom = T(one - Ui[i]);
273 for (std::size_t r = 0; r < R; ++r) {
274 if (!mask[i][r]) continue;
275 const T rate = rates(i, r);
276 const T svct = T(one / rate); // per-visit service time
277 const T drho = T(-rho(i, r) / rate);
278 const T dUi = drho; // own class only
279 const T dsvct = T(-svct / rate);
280 dR(i, r) = T((dsvct * denom + svct * dUi) / (denom * denom));
281 dQ(i, r) = T((drho * denom + rho(i, r) * dUi) / (denom * denom));
282 dU(i, r) = drho;
283 dT(i, r) = zero; // open throughput = lambda*visits, fixed
284 }
285 }
286 }
287 } else {
288 out.method = "fd";
289 const bool central = opt.scheme == "central";
290 double h = opt.step;
291 if (!(h > 0.0)) h = opt.simulation ? 1e-2 : 1e-4;
292 if (!std::isfinite(h) || h <= 0.0 || h >= 1.0)
293 throw InputError("getSensitivityTable: the finite-difference step must be in (0,1)");
294 const T hT = num_traits<T>::from_double(h);
295
296 const std::vector<std::vector<bool>> visited = detail::sens_visit_mask(sn);
297 for (std::size_t i = 0; i < Mq; ++i) {
298 const std::size_t st = p.queue_stations[i];
299 for (std::size_t r = 0; r < R; ++r) {
300 const double rd = num_traits<T>::to_double(sn.rates(st - 1, r));
301 mask[i][r] = std::isfinite(rd) && rd > 0.0 && visited[st - 1][r];
302 }
303 }
304
305 const mva::MvaSolution<T> base = solve();
306 for (std::size_t i = 0; i < Mq; ++i) {
307 const std::size_t st = p.queue_stations[i];
308 for (std::size_t r = 0; r < R; ++r) {
309 if (!mask[i][r]) continue;
310 const T rate = sn.rates(st - 1, r);
311 const Distrib<T> saved = sn.service[st - 1][r];
312
313 sn.set_service(st, r + 1, lang::dist_scale_rate(saved, T(one + hT)));
314 sn.refresh_rates();
315 const mva::MvaSolution<T> up = solve();
316
317 mva::MvaSolution<T> down = base;
318 T denom = T(rate * hT);
319 if (central) {
320 sn.set_service(st, r + 1, lang::dist_scale_rate(saved, T(one - hT)));
321 sn.refresh_rates();
322 down = solve();
323 denom = T(num_traits<T>::from_int(2) * rate * hT);
324 }
325
326 dT(i, r) = T((up.Tp(st - 1, r) - down.Tp(st - 1, r)) / denom);
327 dR(i, r) = T((up.R(st - 1, r) - down.R(st - 1, r)) / denom);
328 dQ(i, r) = T((up.Q(st - 1, r) - down.Q(st - 1, r)) / denom);
329 dU(i, r) = T((up.U(st - 1, r) - down.U(st - 1, r)) / denom);
330
331 // Restore before moving on: the sweep perturbs one pair at a
332 // time and every later quotient is taken at the base point.
333 sn.set_service(st, r + 1, saved);
334 sn.refresh_rates();
335 }
336 }
337 }
338
339 for (std::size_t i = 0; i < Mq; ++i) {
340 const std::size_t st = p.queue_stations[i];
341 const std::size_t nd = sn.node_of_station(st);
342 for (std::size_t r = 0; r < R; ++r) {
343 if (!mask[i][r]) continue;
344 SensRow<T> row;
345 row.station = nd > 0 ? sn.nodes[nd - 1].name : sn.stations[st - 1].name;
346 row.jobclass = sn.classes[r].name;
347 row.dTput = dT(i, r);
348 row.dRespT = dR(i, r);
349 row.dQLen = dQ(i, r);
350 row.dUtil = dU(i, r);
351 out.rows.push_back(row);
352 }
353 }
354 return out;
355}
356
357} // namespace sens
358} // namespace line
359
360#endif // LINE_SOLVERS_SENS_SOLVER_SENS_TABLE_H
InputError(const std::string &what)
Definition error.h:39
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
Rate-scaled copy of a distribution, preserving its shape.
The exception types the port throws.
Dense matrix and non-owning view.
The option and result types every MVA analyzer shares.
Distrib< T > dist_scale_rate(const Distrib< T > &d, const T &factor)
The law of X / factor, in the same family as d.
PfParams< T > sn_get_product_form_params(const qn::NetworkStruct< T > &sn)
Port of sn_get_product_form_params.
SensResult< T > pfqn_sens(const Matrix< T > &L, const std::vector< int > &N, const std::vector< T > &Z, const std::vector< int > &mi)
Exact analytic derivatives of the mean performance measures {X,Q,U,R} of a closed product-form (BCMP)...
Definition pfqn_sens.h:348
SensTable< T > solver_sensitivity_table(qn::NetworkStruct< T > &sn, const SensOptions &opt, bool exact_available, const std::function< mva::MvaSolution< T >()> &solve)
Build the sensitivity table of sn under solve.
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
A queueing network and its refreshed NetworkStruct.
Number-type abstraction for the templated API port.
Exact analytic derivatives of the mean performance measures {X,Q,U,R} of a closed product-form (BCMP)...
Port of matlab/src/api/sn/sn_get_product_form_params.m: the CLASS-level product-form parameters.
static constexpr double Zero
Definition lang_types.h:670
Class-level results, the [Q,U,R,T,C,X] of the MATLAB analyzers.
Definition mva_types.h:96
The [lambda,D,N,Z,mu,S,V] of the reference.
std::vector< double > S
(Mq) server counts
Matrix< T > Z
(max(1,Mz) x R) demand at the delay stations
std::vector< double > N
(R) population, infinite on an open class
Matrix< T > D
(Mq x R) demand at the queueing stations
std::vector< std::size_t > queue_stations
(Mq) 1-based station indices
std::vector< T > lambda
(R) arrival rate, zero on a closed class
Matrix< T > QN
(M x R) mean queue length
Definition pfqn_sens.h:66
Matrix< T > dX
(R x P) dX(r)/dparam(p)
Definition pfqn_sens.h:72
std::vector< T > XN
(R) throughput
Definition pfqn_sens.h:65
std::vector< Matrix< T > > dQ
(P) matrices M x R, dQ[p](i,r)
Definition pfqn_sens.h:73
The name-value contract of getSensitivityTable.
bool simulation
True when the callback is a simulator, which widens the default step.
double step
Relative step of the rate perturbation; negative selects the default.
std::string scheme
forward | central
std::string method
auto | exact | fd
One (station, class) row of the table.
What the table carries, plus the branch that produced it.
std::vector< SensRow< T > > rows
pfqn::SensResult< T > jacobian
std::string method
"exact" or "fd", the branch actually taken
bool has_jacobian
The analytic Jacobian, set only on the CLOSED exact branch.