1function [SensTable, sens] = getSensitivityTable(self, varargin)
2% GETSENSITIVITYTABLE Performance sensitivities with respect to service rates.
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.
9% Two branches produce the derivatives, selected automatically:
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.
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.
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'
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.
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.
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.');
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}));
55 options.(name) = varargin{a+1};
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''.');
62if ~ismember(options.scheme, {
'forward',
'central'})
63 line_error(mfilename,
'The scheme must be ''forward'' or ''central''.');
66sn = self.model.getStruct();
68queueIndices = find(sn.nodetype == NodeType.Queue);
69Mq = numel(queueIndices);
71exactAvailable = self.supportsExactSensitivity();
72[inScope, scopeMsg] = i_exactScope(sn);
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)));
81 line_error(mfilename, scopeMsg);
87 useExact = exactAvailable && inScope;
92 [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask, sens] = ...
93 i_sensitivityExact(sn, queueIndices, R);
96 [dTput_self, dRespT_self, dQLen_self, dUtil_self, mask] = ...
97 i_sensitivityFD(self, sn, queueIndices, R, options);
102dTput = []; dRespT = []; dQLen = []; dUtil = [];
104 node = queueIndices(ist);
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>
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);
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.
129 [~, ~, ~, ~, ~, S, ~] = sn_get_product_form_params(sn);
131 msg =
'getSensitivityTable could not extract the product-form parameters of this model.';
134if any(S(isfinite(S)) > 1)
135 msg = 'getSensitivityTable supports single-server stations only.';
139if any(isinf(N)) && any(isfinite(N))
140 msg = 'getSensitivityTable does not yet support mixed (open+closed) networks.';
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.
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);
165isOpen = any(isinf(N));
166Mq = numel(queueIndices);
170dRespT_self = zeros(Mq, R);
171dQLen_self = zeros(Mq, R);
172dUtil_self = zeros(Mq, R);
173dTput_self = zeros(Mq, R);
177 sIdx = sn.nodeToStation(queueIndices(ist));
179 rates(ist, r) = sn.rates(sIdx, r);
180 mask(ist, r) = isfinite(rates(ist, r)) && rates(ist, r) > 0 && D(ist, r) > 0;
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);
190lambdac = zeros(1, C);
193 cls = find(sn.chains(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));
202 % ---- closed branch: differentiated MVA at chain level -------------
203 sens = pfqn_sens(Dc, Nc, Zc);
206 if ~mask(ist, r),
continue; end
208 if Dc(ist, c) <= 0,
continue; end
209 rate = rates(ist, r);
211 visits = Dir * rate; % chain-normalized visit ratio
213 chain = -Dir / rate; % dDc(i,c)/dmu(i,r)
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;
223 dQir = dalpha * Qc + alpha * dQc;
226 dTput_self(ist, r) = dTir;
227 dQLen_self(ist, r) = dQir;
228 dUtil_self(ist, r) = dXc * Dir + Xc * chain;
230 % Per-visit response time by Little
's law, R = Q/T.
231 dRespT_self(ist, r) = (dQir * Tir - Qir * dTir) / Tir^2;
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.
245 rho(ist, r) = lambdac(chainOf(r)) * D(ist, 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)
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
');
279 if i_isSimulation(self)
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).
');
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;
298visited = i_visitMask(sn);
300 sIdx = sn.nodeToStation(queueIndices(ist));
302 rate = sn.rates(sIdx, r);
303 mask(ist, r) = isfinite(rate) && rate > 0 && visited(sIdx, r);
307dRespT_self = zeros(Mq, R);
308dQLen_self = zeros(Mq, R);
309dUtil_self = zeros(Mq, R);
310dTput_self = zeros(Mq, R);
312[Q0, U0, R0, T0] = i_solveOnce(self);
315 sIdx = sn.nodeToStation(queueIndices(ist));
316 station = self.model.stations{sIdx};
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));
324 i_setService(self.model, station, jobclass, dist_scale_rate(base, 1 + h));
325 [Qp, Up, Rp, Tp] = i_solveOnce(self);
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;
332 Qm = Q0; Um = U0; Rm = R0; Tm = T0;
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;
341 clear restore; % restores the unperturbed service process
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();
355function i_setService(model, station, jobclass, distrib)
356station.setService(jobclass, distrib);
357model.refreshProcesses();
360function i_restoreService(model, station, jobclass, distrib)
361station.setService(jobclass, distrib);
362model.refreshProcesses();
365function tf = i_isSimulation(self)
366tf = ismember(class(self), {'SolverSSA
', 'SolverJMT
', 'SolverLDES
'});
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
373visited = false(sn.nstations, sn.nclasses);
376 if isempty(V), continue; end
377 visited = visited | (V > GlobalConstants.Zero);