LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
getSensitivityTable.m
1function [SensTable, sens] = getSensitivityTable(self, varargin)
2% GETSENSITIVITYTABLE Performance sensitivities with respect to service rates.
3%
4% [SENSTABLE, SENS] = GETSENSITIVITYTABLE(SELF) returns a table with one row
5% per (Station, JobClass) giving the derivative of that row's mean performance
6% measures with respect to that station-class service RATE:
7% dTput_dRate, dRespT_dRate, dQLen_dRate, dUtil_dRate.
8%
9% Two branches produce the derivatives, selected automatically:
10%
11% 'exact' Analytic differentiation of a product-form recursion, exact to
12% machine precision and cheaper than a single extra solve. Closed
13% networks use pfqn_sens (differentiated MVA), open networks the
14% closed-form BCMP sensitivities (their stations decouple). Rate
15% derivatives follow the chain rule d(.)/d(rate) = -(L/rate) d(.)/dL,
16% since L(i,r) = visits(i,r)/rate(i,r). Available only on the solvers
17% that evaluate that recursion, SolverMVA and SolverNC, and only for
18% single-server queues plus an optional delay, with mixed
19% (open+closed) models excluded.
20%
21% 'fd' Forward or central finite differences on the CALLING solver's own
22% predictions: the service process at (station,class) is rate-scaled
23% by (1+h), the same solver with the same options is re-run, and the
24% difference quotient is formed. This costs 1+M*R solves (forward) or
25% 2*M*R (central), and it is the only branch that applies to
26% non-product-form models, so it is what every solver other than
27% SolverMVA and SolverNC uses.
28%
29% [...] = GETSENSITIVITYTABLE(SELF, 'method', M, 'step', H, 'scheme', S) sets
30% M in {'auto','exact','fd'} default 'auto': exact where available and in
31% scope, finite differences otherwise
32% H relative step of the rate perturbation, default 1e-4 for deterministic
33% solvers and 1e-2 for the simulators, whose Monte Carlo error would
34% otherwise dominate the difference quotient
35% S in {'forward','central'} default 'forward'
36%
37% SENS is the pfqn_sens struct on the closed exact branch, and empty on the
38% open exact branch and on the finite-difference branch. The branch actually
39% taken is reported in SENSTABLE.Properties.UserData.method.
40%
41% Simulation solvers must be run with common random numbers for the difference
42% quotient to be meaningful: the same options, and hence the same seed, are
43% reused for the base and the perturbed runs. An unset seed is pinned before
44% the sweep so that the runs remain paired.
45
46options = struct('method', 'auto', 'step', [], 'scheme', 'forward');
47if mod(numel(varargin), 2) ~= 0
48 line_error(mfilename, 'Options must be given as name-value pairs.');
49end
50for a = 1:2:numel(varargin)
51 name = lower(varargin{a});
52 if ~isfield(options, name)
53 line_error(mfilename, sprintf('Unknown option ''%s''.', varargin{a}));
54 end
55 options.(name) = varargin{a+1};
56end
57if ischar(options.method), options.method = lower(options.method); end
58if ischar(options.scheme), options.scheme = lower(options.scheme); end
59if ~ismember(options.method, {'auto', 'exact', 'fd'})
60 line_error(mfilename, 'The method must be one of ''auto'', ''exact'', ''fd''.');
61end
62if ~ismember(options.scheme, {'forward', 'central'})
63 line_error(mfilename, 'The scheme must be ''forward'' or ''central''.');
64end
65
66sn = self.model.getStruct();
67R = sn.nclasses;
68queueIndices = find(sn.nodetype == NodeType.Queue);
69Mq = numel(queueIndices);
70
71exactAvailable = self.supportsExactSensitivity();
72[inScope, scopeMsg] = i_exactScope(sn);
73switch options.method
74 case 'exact'
75 if ~exactAvailable
76 line_error(mfilename, sprintf(['Exact analytic sensitivities differentiate a ', ...
77 'product-form recursion and are available on SolverMVA and SolverNC only; ', ...
78 '%s must use ''fd''.'], class(self)));
79 end
80 if ~inScope
81 line_error(mfilename, scopeMsg);
82 end
83 useExact = true;
84 case 'fd'
85 useExact = false;
86 otherwise
87 useExact = exactAvailable && inScope;
88end
89
90sens = [];
91if useExact
92 [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask, sens] = ...
93 i_sensitivityExact(sn, queueIndices, R);
94 methodUsed = 'exact';
95else
96 [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask] = ...
97 i_sensitivityFD(self, sn, queueIndices, R, options);
98 methodUsed = 'fd';
99end
100
101Station = {}; JobClass = {};
102dTput = []; dRespT = []; dQLen = []; dUtil = [];
103for ist = 1:Mq
104 node = queueIndices(ist);
105 for r = 1:R
106 if ~mask(ist, r), continue; end
107 Station{end+1, 1} = sn.nodenames{node}; %#ok<AGROW>
108 JobClass{end+1, 1} = sn.classnames{r}; %#ok<AGROW>
109 dTput(end+1, 1) = dTput_self(ist, r); %#ok<AGROW>
110 dRespT(end+1, 1) = dRespT_self(ist, r); %#ok<AGROW>
111 dQLen(end+1, 1) = dQLen_self(ist, r); %#ok<AGROW>
112 dUtil(end+1, 1) = dUtil_self(ist, r); %#ok<AGROW>
113 end
114end
115
116SensTable = table(Station, JobClass, dTput, dRespT, dQLen, dUtil, ...
117 'VariableNames', {'Station', 'JobClass', 'dTput_dRate', ...
118 'dRespT_dRate', 'dQLen_dRate', 'dUtil_dRate'});
119SensTable.Properties.UserData = struct('method', methodUsed);
120end
121
122function [inScope, msg] = i_exactScope(sn)
123% Scope of the analytic branch: single-server stations (a delay, with infinite
124% servers, is allowed) and not a mixed open-and-closed model. Class switching is
125% supported: the branch aggregates classes into chains before differentiating.
126inScope = false;
127msg = '';
128try
129 [~, ~, ~, ~, ~, S, ~] = sn_get_product_form_params(sn);
130catch
131 msg = 'getSensitivityTable could not extract the product-form parameters of this model.';
132 return;
133end
134if any(S(isfinite(S)) > 1)
135 msg = 'getSensitivityTable supports single-server stations only.';
136 return;
137end
138N = sn.njobs;
139if any(isinf(N)) && any(isfinite(N))
140 msg = 'getSensitivityTable does not yet support mixed (open+closed) networks.';
141 return;
142end
143inScope = true;
144end
145
146function [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask, sens] = ...
147 i_sensitivityExact(sn, queueIndices, R)
148% Analytic branch: differentiated MVA (closed) or closed-form BCMP (open), both
149% evaluated at CHAIN level and then disaggregated back to the classes.
150%
151% A product-form model is solved per chain, not per class: a chain carries the
152% population and the arrival rate, and a class is a share of it. With class
153% switching the two differ, the whole chain population sitting on the reference
154% class, so the recursion must see the chain demands
155% Dc(i,c) = sum_{r in c} D(i,r), Nc(c) = sum_{r in c} N(r), Zc likewise.
156% The parameter of the table is still the per-class rate mu(i,r), which enters
157% exactly one chain demand, giving the chain rule
158% d(.)/dmu(i,r) = -(D(i,r)/mu(i,r)) d(.)/dDc(i,c).
159% The per-class measures are then composed from the chain ones, which is also
160% what fixes the visit ratios: a class throughput at a station is X_c*v(i,r) and
161% a per-visit response time is Q(i,r)/T(i,r), whereas the chain quantities are
162% per chain and per visit-chain respectively.
163[lambda, D, Np, Z, ~, ~, ~] = sn_get_product_form_params(sn);
164N = sn.njobs;
165isOpen = any(isinf(N));
166Mq = numel(queueIndices);
167C = sn.nchains;
168
169sens = [];
170dRespT_self = zeros(Mq, R);
171dQLen_self = zeros(Mq, R);
172dUtil_self = zeros(Mq, R);
173dTput_self = zeros(Mq, R);
174mask = false(Mq, R);
175rates = zeros(Mq, R);
176for ist = 1:Mq
177 sIdx = sn.nodeToStation(queueIndices(ist));
178 for r = 1:R
179 rates(ist, r) = sn.rates(sIdx, r);
180 mask(ist, r) = isfinite(rates(ist, r)) && rates(ist, r) > 0 && D(ist, r) > 0;
181 end
182end
183
184% chainOf(r) is the chain class r belongs to; Dc, Zc, Nc and lambdac aggregate
185% the per-class quantities over the classes of each chain.
186chainOf = zeros(1, R);
187Dc = zeros(Mq, C);
188Zc = zeros(1, C);
189Nc = zeros(1, C);
190lambdac = zeros(1, C);
191Zrow = sum(Z, 1);
192for c = 1:C
193 cls = find(sn.chains(c, :));
194 chainOf(cls) = c;
195 Dc(:, c) = sum(D(:, cls), 2);
196 Zc(c) = sum(Zrow(cls));
197 Nc(c) = sum(Np(cls(isfinite(Np(cls)))));
198 lambdac(c) = sum(lambda(cls));
199end
200
201if ~isOpen
202 % ---- closed branch: differentiated MVA at chain level -------------
203 sens = pfqn_sens(Dc, Nc, Zc);
204 for ist = 1:Mq
205 for r = 1:R
206 if ~mask(ist, r), continue; end
207 c = chainOf(r);
208 if Dc(ist, c) <= 0, continue; end
209 rate = rates(ist, r);
210 Dir = D(ist, r);
211 visits = Dir * rate; % chain-normalized visit ratio
212 p = (ist-1)*C + c;
213 chain = -Dir / rate; % dDc(i,c)/dmu(i,r)
214 Xc = sens.X(c);
215 Qc = sens.Q(ist, c);
216 dXc = sens.dX(c, p) * chain;
217 dQc = sens.dQ(ist, c, p) * chain;
218 % Class share of the chain queue at this station, and its own
219 % dependence on the rate.
220 alpha = Dir / Dc(ist, c);
221 dalpha = chain * (Dc(ist, c) - Dir) / Dc(ist, c)^2;
222 Qir = alpha * Qc;
223 dQir = dalpha * Qc + alpha * dQc;
224 Tir = Xc * visits;
225 dTir = dXc * visits;
226 dTput_self(ist, r) = dTir;
227 dQLen_self(ist, r) = dQir;
228 dUtil_self(ist, r) = dXc * Dir + Xc * chain;
229 if Tir > 0
230 % Per-visit response time by Little's law, R = Q/T.
231 dRespT_self(ist, r) = (dQir * Tir - Qir * dTir) / Tir^2;
232 end
233 end
234 end
235else
236 % ---- open branch: exact closed-form BCMP --------------------------
237 % rho(i,r) = lambdac(c)*D(i,r) with c the chain of r; U(i) = sum_r rho(i,r).
238 % The stations decouple, so only the own service rate mu(i,r) moves the
239 % measures at (i,r). The throughput T(i,r) = lambdac(c)*v(i,r) is fixed by
240 % the arrival rate, hence its rate derivative is exactly zero.
241 rho = zeros(Mq, R);
242 for ist = 1:Mq
243 for r = 1:R
244 if D(ist, r) > 0
245 rho(ist, r) = lambdac(chainOf(r)) * D(ist, r);
246 end
247 end
248 end
249 Ui = sum(rho, 2);
250 for ist = 1:Mq
251 denom = 1 - Ui(ist);
252 for r = 1:R
253 if ~mask(ist, r), continue; end
254 rate = rates(ist, r);
255 svct = 1 / rate; % per-visit service time
256 drho = -rho(ist, r) / rate;
257 dU = drho; % own class only
258 dsvct = -svct / rate;
259 dRespT_self(ist, r) = (dsvct * denom + svct * dU) / denom^2;
260 dQLen_self(ist, r) = (drho * denom + rho(ist, r) * dU) / denom^2;
261 dUtil_self(ist, r) = drho;
262 dTput_self(ist, r) = 0; % open throughput = lambda*visits (fixed)
263 end
264 end
265end
266end
267
268function [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask] = ...
269 i_sensitivityFD(self, sn, queueIndices, R, options)
270% Finite-difference branch: re-run the calling solver on rate-perturbed copies
271% of the model. The perturbation is a pure time scaling of the service process,
272% so the shape of the distribution, and in particular its SCV, is preserved and
273% only the rate moves.
274Mq = numel(queueIndices);
275central = strcmp(options.scheme, 'central');
276
277h = options.step;
278if isempty(h)
279 if i_isSimulation(self)
280 h = 1e-2;
281 else
282 h = 1e-4;
283 end
284end
285if ~isnumeric(h) || ~isscalar(h) || ~isfinite(h) || h <= 0 || h >= 1
286 line_error(mfilename, 'The finite-difference step must be a scalar in (0,1).');
287end
288if i_isSimulation(self)
289 % Common random numbers: the base and perturbed runs must share a seed,
290 % otherwise the difference quotient measures Monte Carlo noise.
291 if ~isfield(self.options, 'seed') || isempty(self.options.seed) || ...
292 ~isfinite(self.options.seed)
293 self.options.seed = 23000;
294 end
295end
296
297mask = false(Mq, R);
298visited = i_visitMask(sn);
299for ist = 1:Mq
300 sIdx = sn.nodeToStation(queueIndices(ist));
301 for r = 1:R
302 rate = sn.rates(sIdx, r);
303 mask(ist, r) = isfinite(rate) && rate > 0 && visited(sIdx, r);
304 end
305end
306
307dRespT_self = zeros(Mq, R);
308dQLen_self = zeros(Mq, R);
309dUtil_self = zeros(Mq, R);
310dTput_self = zeros(Mq, R);
311
312[Q0, U0, R0, T0] = i_solveOnce(self);
313
314for ist = 1:Mq
315 sIdx = sn.nodeToStation(queueIndices(ist));
316 station = self.model.stations{sIdx};
317 for r = 1:R
318 if ~mask(ist, r), continue; end
319 jobclass = self.model.classes{r};
320 rate = sn.rates(sIdx, r);
321 base = station.getService(jobclass);
322 restore = onCleanup(@() i_restoreService(self.model, station, jobclass, base));
323
324 i_setService(self.model, station, jobclass, dist_scale_rate(base, 1 + h));
325 [Qp, Up, Rp, Tp] = i_solveOnce(self);
326
327 if central
328 i_setService(self.model, station, jobclass, dist_scale_rate(base, 1 - h));
329 [Qm, Um, Rm, Tm] = i_solveOnce(self);
330 denom = 2 * rate * h;
331 else
332 Qm = Q0; Um = U0; Rm = R0; Tm = T0;
333 denom = rate * h;
334 end
335
336 dTput_self(ist, r) = (Tp(sIdx, r) - Tm(sIdx, r)) / denom;
337 dRespT_self(ist, r) = (Rp(sIdx, r) - Rm(sIdx, r)) / denom;
338 dQLen_self(ist, r) = (Qp(sIdx, r) - Qm(sIdx, r)) / denom;
339 dUtil_self(ist, r) = (Up(sIdx, r) - Um(sIdx, r)) / denom;
340
341 clear restore; % restores the unperturbed service process
342 end
343end
344end
345
346function [QN, UN, RN, TN] = i_solveOnce(self)
347% Solve with a fresh instance of the calling solver class, carrying its options
348% over so that seeds, tolerances and method selection are those of the caller.
349% A fresh instance is used because a solver caches its results, and reset()
350% alone would not discard a warm start.
351solver = feval(class(self), self.model, self.options);
352[QN, UN, RN, TN] = solver.getAvg();
353end
354
355function i_setService(model, station, jobclass, distrib)
356station.setService(jobclass, distrib);
357model.refreshProcesses();
358end
359
360function i_restoreService(model, station, jobclass, distrib)
361station.setService(jobclass, distrib);
362model.refreshProcesses();
363end
364
365function tf = i_isSimulation(self)
366tf = ismember(class(self), {'SolverSSA', 'SolverJMT', 'SolverLDES'});
367end
368
369function visited = i_visitMask(sn)
370% A (nstations x nclasses) mask of the station-class pairs that carry visits,
371% used in place of the demand matrix, which a non-product-form model may not
372% admit.
373visited = false(sn.nstations, sn.nclasses);
374for c = 1:sn.nchains
375 V = sn.visits{c};
376 if isempty(V), continue; end
377 visited = visited | (V > GlobalConstants.Zero);
378end
379end
Definition Station.m:245