LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
solver_mam_ag.m
1function [QN, UN, RN, TN, CN, XN, iter] = solver_mam_ag(sn, options)
2% SOLVER_MAM_AG AG methods for SolverMAM
3%
4% [QN, UN, RN, TN, CN, XN, ITER] = SOLVER_MAM_AG(SN, OPTIONS)
5%
6% Uses RCAT (Reversed Compound Agent Theorem) to find product-form
7% solutions for queueing networks.
8%
9% Methods:
10% 'inap' - Iterative Numerical Approximation Procedure (default, fast)
11% 'inapplus' - Improved INAP with weighted rates (no normalization)
12% 'inapinf' - INAP with matrix-geometric solution of the isolated open
13% components (no state-space truncation), per Marin, Rota Bulo,
14% Balsamo, "A Numerical Algorithm for the Decomposition of
15% Cooperating Structured Markov Processes", MASCOTS 2012.
16% 'exact' - Not available (autocat moved to line-legacy.git)
17%
18% Copyright (c) 2012-2025, Imperial College London
19% All rights reserved.
20
21M = sn.nstations;
22K = sn.nclasses;
23
24% Set default max states for truncation
25if isfield(options, 'config') && isfield(options.config, 'maxStates')
26 maxStates = options.config.maxStates;
27else
28 maxStates = 100;
29end
30
31% Set default tolerances
32if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
33 tol = options.iter_tol;
34else
35 tol = 1e-6;
36end
37
38if isfield(options, 'iter_max') && ~isempty(options.iter_max)
39 maxiter = options.iter_max;
40else
41 maxiter = 1000;
42end
43
44% Build RCAT model from network structure
45[R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates);
46
47% Check if we have a valid model
48numProcesses = max(processMap(:));
49numActions = size(AP, 1);
50
51% Return early only if no processes found
52if numProcesses == 0
53 line_warning(mfilename, 'Network could not be mapped to RCAT format (no processes found).\n');
54 QN = zeros(M, K);
55 UN = zeros(M, K);
56 RN = zeros(M, K);
57 TN = zeros(M, K);
58 CN = zeros(1, K);
59 XN = zeros(1, K);
60 iter = 0;
61 return;
62end
63
64% If no actions but we have processes, solve using local rates only
65% This handles single-queue G-networks (Source -> Queue -> Sink)
66if numActions == 0
67 % No inter-station actions: solve equilibrium using only L matrices
68 x = [];
69 pi = cell(1, numProcesses);
70 Q = cell(1, numProcesses);
71 for p = 1:numProcesses
72 L = R{1, p}; % Local rate matrix is in R{numActions+1, p} = R{1, p} when numActions=0
73 % Convert to valid generator matrix
74 Qp = L - diag(L * ones(size(L, 1), 1));
75 Q{p} = ctmc_makeinfgen(Qp);
76 % Solve for equilibrium
77 pi{p} = ctmc_solve(Q{p});
78 end
79 iter = 0;
80 [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
81 return;
82end
83
84% Open/closed flag per process (open classes have infinite population and
85% are the ones the matrix-geometric 'inapinf' method solves without truncation).
86isOpenProc = false(1, numProcesses);
87for p = 1:numProcesses
88 [ipst, ipr] = find(processMap == p);
89 if ~isempty(ipst)
90 isOpenProc(p) = isinf(sn.njobs(ipr(1)));
91 end
92end
93
94% Choose solver method
95method = options.method;
96if strcmp(method, 'default')
97 method = 'inap';
98end
99
100% Per-process geometric-tail decay (set only by 'inapinf'); empty => metrics
101% are computed from the explicit stationary vectors pi.
102rhoProc = [];
103isGeomProc = [];
104
105switch method
106 case 'inap'
107 % Fast iterative heuristic
108 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
109
110 case 'inapplus'
111 % Improved INAP with weighted rates (no normalization)
112 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inapplus');
113
114 case 'inapinf'
115 % Matrix-geometric INAP: solve isolated open components exactly on the
116 % infinite state space (geometric tail), no truncation.
117 [x, pi, Q, iter, rhoProc, isGeomProc, rcatRes] = ...
118 inap_inf(R, AP, tol, maxiter, isOpenProc);
119 line_debug('inapinf: RCAT product-form residual = %.3e (iter=%d)', rcatRes, iter);
120
121 case 'exact'
122 % Optimization-based solver using autocat (not available in this version)
123 line_warning(mfilename, '''exact'' method not available. Falling back to inap.\n');
124 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
125
126 otherwise
127 line_error(mfilename, 'Unknown method: %s\n', method);
128end
129
130% Convert RCAT solution to LINE metrics
131[QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N, rhoProc, isGeomProc);
132
133end
134
135%% Local Functions
136
137function [x, pi, Q, iter] = inap(R, AP, tol, maxiter, method)
138% INAP Iterative Numerical Approximation Procedure for RCAT
139%
140% Methods:
141% 'inap': x(a) = mean(Aa(i,j) * pi(i) / pi(j))
142% 'inapplus': x(a) = sum(Aa(i,j) * pi(i))
143
144if nargin < 5 || isempty(method)
145 method = 'inap';
146end
147
148% Parse R and AP
149A = size(AP, 1);
150ACT = AP(:, 1);
151PSV = AP(:, 2);
152numProcesses = max(AP(:));
153
154% Extract rate matrices
155Aa = cell(1, A);
156Pb = cell(1, A);
157for a = 1:A
158 Aa{a} = R{a, 1};
159 Pb{a} = R{a, 2};
160end
161
162% Extract local rates
163L = cell(1, numProcesses);
164for k = 1:numProcesses
165 L{k} = R{A+1, k};
166end
167
168% Get state space sizes
169N = zeros(1, numProcesses);
170for k = 1:numProcesses
171 N(k) = size(L{k}, 1);
172end
173
174% Processes whose local dynamics are not birth-death. A catastrophe or a
175% batch-removal signal moves the process down by more than one level, so the
176% reversed rate of an action of that process is state-dependent and the
177% mean-of-ratios estimator of INAP has no fixed point of RCAT type (it
178% overestimates the departure rate, e.g. 2x on a tandem G-network with
179% catastrophes). For those processes the action rate is instead set by rate
180% conservation, x(a) = sum_ij Aa(i,j)*pi(i), which is the actual departure
181% rate of the active process (the INAP+ estimator). Birth-death processes,
182% including the classic single-removal negative customer, keep the standard
183% INAP estimator, which is exact for them.
184notBirthDeath = false(1, numProcesses);
185for k = 1:numProcesses
186 for n = 1:N(k)
187 for m = 1:N(k)
188 if L{k}(n, m) > 0 && (m < n - 1 || m > n + 1)
189 notBirthDeath(k) = true;
190 end
191 end
192 end
193end
194
195% Deterministic initial guess for the fixed-point iteration. A random start
196% made results non-reproducible run-to-run and divergent across back-ends;
197% the iteration converges to the same fixed point regardless.
198x = (1:A)' / (A + 1);
199
200% Compute initial equilibrium
201[pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
202
203% reversed-rate fixed point on the isolated-component equilibria, driven
204% by the generic DA driver
205fpopts = struct('iter_max', maxiter, 'iter_tol', tol);
206fpopts.config.da_norm = @pi_blocknorm;
207[~, iter, cvg] = da_fpi(@inap_sweep, pi, fpopts);
208if ~cvg
209 iter = iter + 1; % legacy while-loop exited with the counter past the cap
210end
211
212 function [xnew, xref] = inap_sweep(picur, ~)
213 xref = picur;
214
215 % Update each action rate
216 for a = 1:A
217 k = ACT(a);
218
219 if strcmp(method, 'inapplus') || notBirthDeath(k)
220 % inapplus: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i)
221 % x(a) = sum(LAMBDA) for non-zero entries
222 LAMBDA_sum = 0;
223 for i = 1:N(k)
224 for j = 1:N(k)
225 if Aa{a}(i,j) > 0
226 LAMBDA_sum = LAMBDA_sum + Aa{a}(i,j) * pi{k}(i);
227 end
228 end
229 end
230 if LAMBDA_sum > 0
231 x(a) = LAMBDA_sum;
232 end
233 else
234 % inap: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j)
235 % x(a) = mean(LAMBDA) for non-zero entries
236 LAMBDA_vec = [];
237 for i = 1:N(k)
238 for j = 1:N(k)
239 if Aa{a}(i,j) > 0 && pi{k}(j) > 0
240 LAMBDA_vec(end+1) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j);
241 end
242 end
243 end
244 if ~isempty(LAMBDA_vec)
245 x(a) = mean(LAMBDA_vec);
246 end
247 end
248 end
249
250 % Recompute equilibrium with new x
251 [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
252 xnew = pi;
253 end
254
255 function e = pi_blocknorm(xn, xr)
256 e = 0;
257 for kk = 1:numProcesses
258 e = max(e, norm(xn{kk} - xr{kk}, 1));
259 end
260 end
261
262end
263
264function [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N)
265% Compute equilibrium distribution for each process given action rates x
266
267Q = cell(1, numProcesses);
268pi = cell(1, numProcesses);
269
270for k = 1:numProcesses
271 % Start with local/hidden rates
272 Qk = L{k} - diag(L{k} * ones(N(k), 1));
273
274 % Add contributions from each action
275 for c = 1:A
276 if PSV(c) == k
277 % Process k is passive for action c: add x(c) * Pb{c}
278 Qk = Qk + x(c) * Pb{c} - diag(Pb{c} * ones(N(k), 1));
279 elseif ACT(c) == k
280 % Process k is active for action c: add Aa{c}
281 Qk = Qk + Aa{c} - diag(Aa{c} * ones(N(k), 1));
282 end
283 end
284
285 % Convert to valid infinitesimal generator
286 Q{k} = ctmc_makeinfgen(Qk);
287
288 % Solve for equilibrium distribution using birth-death recursion for
289 % tridiagonal generators (numerically stable for large state spaces),
290 % falling back to ctmc_solve otherwise
291 if is_tridiagonal(Q{k})
292 pi{k} = birth_death_solve(Q{k});
293 else
294 pi{k} = ctmc_solve(Q{k});
295 end
296end
297
298end
299
300function [x, pi, Q, iter, rhoProc, isGeomProc, rcatRes] = inap_inf(R, AP, tol, maxiter, isOpenProc)
301% INAP_QBD Matrix-geometric INAP for RCAT product forms (no truncation).
302%
303% Same fixed-point iteration over the reversed rates x_l as INAP, but each
304% isolated OPEN component is solved directly on its infinite state space by
305% a scalar matrix-geometric (QBD / catastrophe) decomposition: the marginal
306% is geometric pi_n = (1-rho) rho^n with rho the sub-unit root of the QBD
307% characteristic equation, and any catastrophe drain to the empty state is
308% folded into the local outflow (it produces no interior inflow, so the
309% geometric form is preserved). Closed components remain finite and are
310% solved with ctmc_solve. Reversed rates are updated by the weighted-mean
311% formula Eq. (4) evaluated in closed form on the geometric tail, and the
312% RCAT product-form residual (Remark 2) is returned as a diagnostic.
313%
314% Reference: A. Marin, S. Rota Bulo, S. Balsamo, "A Numerical Algorithm for
315% the Decomposition of Cooperating Structured Markov Processes", MASCOTS 2012.
316
317A = size(AP, 1);
318ACT = AP(:, 1);
319PSV = AP(:, 2);
320numProcesses = max(AP(:));
321
322% Extract rate matrices
323Aa = cell(1, A);
324Pb = cell(1, A);
325for a = 1:A
326 Aa{a} = R{a, 1};
327 Pb{a} = R{a, 2};
328end
329
330% Extract local rates
331L = cell(1, numProcesses);
332for k = 1:numProcesses
333 L{k} = R{A+1, k};
334end
335
336% State space sizes and active-transition row sums (rate of the active label
337% out of each state of the active component)
338N = zeros(1, numProcesses);
339for k = 1:numProcesses
340 N(k) = size(L{k}, 1);
341end
342aRowSum = cell(1, A);
343for a = 1:A
344 aRowSum{a} = sum(Aa{a}, 2);
345end
346
347% Deterministic initial guess (see inap): reproducible across back-ends.
348x = (1:A)' / (A + 1);
349
350[pi, Q, rhoProc, isGeomProc] = ...
351 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
352
353% reversed-rate fixed point on the isolated-component equilibria (matrix-
354% geometric variant), driven by the generic DA driver
355fpopts = struct('iter_max', maxiter, 'iter_tol', tol);
356fpopts.config.da_norm = @pi_blocknorm_trunc;
357[~, iter, cvg] = da_fpi(@inapinf_sweep, pi, fpopts);
358if ~cvg
359 iter = iter + 1; % legacy while-loop exited with the counter past the cap
360end
361rcat_residual();
362
363 function [xnew, xref] = inapinf_sweep(picur, ~)
364 xref = picur;
365
366 % Reversed-rate update, Eq. (4): x_l = pi^(alpha_l) T^(l) e.
367 for a = 1:A
368 k = ACT(a);
369 if isGeomProc(k)
370 % Geometric tail: the active label fires only in occupied states,
371 % so x_l = (per-occupied-state active rate) * P(occupied) = rate*rho.
372 occ = aRowSum{a}(min(2, N(k)));
373 x(a) = occ * rhoProc(k);
374 else
375 v = pi{k}(:);
376 x(a) = v' * aRowSum{a};
377 end
378 end
379
380 [pi, Q, rhoProc, isGeomProc] = ...
381 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
382 xnew = pi;
383 end
384
385 function e = pi_blocknorm_trunc(xn, xr)
386 e = 0;
387 for kk = 1:numProcesses
388 m = min(length(xn{kk}), length(xr{kk}));
389 e = max(e, norm(xn{kk}(1:m) - xr{kk}(1:m), 1));
390 end
391 end
392
393 function rcat_residual()
394 % RCAT product-form residual (Remark 2): max_l || pi^(alpha_l) (x_l I - T^(l)) ||,
395 % where T^(l) is the active rate matrix Aa{a}. Zero iff the reversed rate is
396 % state-independent, i.e. an exact product-form solution was found.
397 rcatRes = 0;
398 for a = 1:A
399 k = ACT(a);
400 v = pi{k}(:)';
401 resVec = x(a) * v - v * Aa{a};
402 rcatRes = max(rcatRes, norm(resVec, 2));
403 end
404 end
405
406end
407
408function [pi, Q, rhoProc, isGeomProc] = ...
409 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc)
410% Solve each isolated component given the current reversed rates x. Open
411% components are solved on the infinite state space by a scalar
412% matrix-geometric decomposition; closed (finite) components fall back to the
413% explicit finite solve.
414
415Q = cell(1, numProcesses);
416pi = cell(1, numProcesses);
417rhoProc = zeros(1, numProcesses);
418isGeomProc = false(1, numProcesses);
419
420for k = 1:numProcesses
421 Nk = N(k);
422
423 % Assemble strictly off-diagonal rate matrix for component k
424 Off = L{k} - diag(diag(L{k}));
425 for c = 1:A
426 if PSV(c) == k
427 Off = Off + x(c) * Pb{c};
428 elseif ACT(c) == k
429 Off = Off + Aa{c};
430 end
431 end
432 Off = Off - diag(diag(Off));
433
434 Qk = Off - diag(sum(Off, 2));
435 Q{k} = ctmc_makeinfgen(Qk);
436
437 solvedGeom = false;
438 if isOpenProc(k) && Nk >= 5
439 % Read the homogeneous interior rates one level below the truncation
440 % boundary (avoids the reflecting boundary artefact of Off).
441 s0 = Nk - 1; % interior state index (level s0-1)
442 row = Off(s0, :);
443 f = row(s0 + 1); % up-1 rate (arrival)
444 b = row(s0 - 1); % down-1 rate (service + single removal)
445 g0 = row(1); % drain to empty state (catastrophe)
446 % Transitions to strictly-interior lower levels (batch removal to a
447 % non-empty state) break the scalar-QBD structure; detect and defer.
448 interDown = 0;
449 if s0 - 2 >= 2
450 interDown = sum(row(2:s0-2));
451 end
452 if interDown <= 1e-11 && f > 0
453 rho = qbd_scalar_rho(f, b, g0);
454 if isfinite(rho) && rho > 0 && rho < 1 - 1e-12
455 rhoProc(k) = rho;
456 isGeomProc(k) = true;
457 pi{k} = (1 - rho) * rho .^ (0:Nk-1);
458 solvedGeom = true;
459 end
460 end
461 end
462
463 if ~solvedGeom
464 if is_tridiagonal(Q{k})
465 pi{k} = birth_death_solve(Q{k});
466 else
467 pi{k} = ctmc_solve(Q{k});
468 end
469 end
470end
471
472end
473
474function rho = qbd_scalar_rho(f, b, g)
475% Sub-unit root rho of the scalar QBD characteristic equation
476% b*rho^2 - (f+b+g)*rho + f = 0,
477% where f is the up-1 rate, b the down-1 rate, and g the extra local outflow
478% (catastrophe drain to the empty state). This is the block-size-1 instance
479% of Neuts' rate matrix R. For b == 0 the equation degenerates to the
480% catastrophe-stabilised ratio rho = f/(f+g).
481
482if b <= 1e-14
483 if f + g <= 0
484 rho = Inf;
485 else
486 rho = f / (f + g);
487 end
488 return;
489end
490
491c1 = -(f + b + g);
492disc = c1^2 - 4 * b * f;
493if disc < 0
494 rho = Inf;
495 return;
496end
497sq = sqrt(disc);
498r1 = (-c1 - sq) / (2 * b);
499r2 = (-c1 + sq) / (2 * b);
500cands = sort([r1, r2]);
501if cands(1) > 0
502 rho = cands(1);
503else
504 rho = cands(2);
505end
506
507end
508
509function [R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates)
510% BUILD_RCAT Convert LINE network structure to RCAT format
511
512if nargin < 2
513 maxStates = 100;
514end
515
516M = sn.nstations;
517K = sn.nclasses;
518rt = sn.rt; % (M*K) x (M*K) routing table
519
520% Identify station types
521sourceStations = [];
522sinkStations = [];
523queueStations = [];
524
525for ist = 1:M
526 nodeIdx = sn.stationToNode(ist);
527 if sn.nodetype(nodeIdx) == NodeType.Source
528 sourceStations(end+1) = ist;
529 elseif sn.nodetype(nodeIdx) == NodeType.Sink
530 sinkStations(end+1) = ist;
531 else
532 % Queue, Delay, or other service stations
533 queueStations(end+1) = ist;
534 end
535end
536
537% Create process mapping: each (station, class) pair at queue stations
538% Note: Signal classes (negative customers) don't create separate processes
539% as they only modify the state of positive customer processes
540processIdx = 0;
541processMap = zeros(M, K);
542for ist = queueStations
543 for r = 1:K
544 % Skip Signal classes - they don't have their own queue state
545 if sn.issignal(r)
546 continue;
547 end
548 % Check if this station serves this class
549 if ~isnan(sn.rates(ist, r)) && sn.rates(ist, r) > 0
550 processIdx = processIdx + 1;
551 processMap(ist, r) = processIdx;
552 end
553 end
554end
555numProcesses = processIdx;
556
557if numProcesses == 0
558 R = {};
559 AP = [];
560 actionMap = [];
561 N = [];
562 return;
563end
564
565% Check for signal classes - G-networks with signals
566% G-network signals (negative customers, catastrophes, batch removal) are handled by:
567% 1. Adding signal arrival effects to the L matrix for positive customer processes
568% 2. Creating actions for signal routing between stations (job removal at destination)
569
570% Determine number of states for each process
571N = zeros(1, numProcesses);
572for p = 1:numProcesses
573 [ist, r] = find(processMap == p);
574 if ~isempty(ist)
575 ist = ist(1); r = r(1);
576 if sn.njobs(r) < Inf % Closed class
577 N(p) = sn.njobs(r) + 1; % States 0, 1, ..., njobs
578 else % Open class
579 N(p) = maxStates; % Truncate at maxStates
580 end
581 end
582end
583
584% Count actions: each routing transition (i,r) -> (j,s) where P > 0
585actionIdx = 0;
586actionMap = struct('from_station', {}, 'from_class', {}, ...
587 'to_station', {}, 'to_class', {}, 'prob', {}, ...
588 'isNegative', {}, 'isCatastrophe', {}, 'removalDistribution', {});
589
590for ist = queueStations
591 for r = 1:K
592 if processMap(ist, r) > 0
593 % Check if class r is a removal signal class (NEGATIVE or
594 % CATASTROPHE; the two are distinct SignalType values, so both
595 % must be tested).
596 isNegativeClass = false;
597 isCatastropheClass = false;
598 removalDist = [];
599 if sn.issignal(r) && ~isnan(sn.signaltype{r}) && ...
600 (sn.signaltype{r} == SignalType.NEGATIVE || sn.signaltype{r} == SignalType.CATASTROPHE)
601 isNegativeClass = true;
602 % Check if class r is a catastrophe signal
603 if (isfield(sn, 'iscatastrophe') && ~isempty(sn.iscatastrophe) && sn.iscatastrophe(r) > 0) ...
604 || sn.signaltype{r} == SignalType.CATASTROPHE
605 isCatastropheClass = true;
606 end
607 % Get removal distribution for this class
608 if isfield(sn, 'signalremdist') && ~isempty(sn.signalremdist) && r <= length(sn.signalremdist)
609 removalDist = sn.signalremdist{r};
610 end
611 end
612
613 for jst = queueStations
614 for s = 1:K
615 if processMap(jst, s) > 0
616 % Get routing probability
617 prob_ij_rs = rt((ist-1)*K + r, (jst-1)*K + s);
618 if prob_ij_rs > 0 && (ist ~= jst || r ~= s)
619 % This is an action (departure from i,r triggers arrival at j,s)
620 actionIdx = actionIdx + 1;
621 actionMap(actionIdx).from_station = ist;
622 actionMap(actionIdx).from_class = r;
623 actionMap(actionIdx).to_station = jst;
624 actionMap(actionIdx).to_class = s;
625 actionMap(actionIdx).prob = prob_ij_rs;
626 actionMap(actionIdx).isNegative = isNegativeClass;
627 actionMap(actionIdx).isCatastrophe = isCatastropheClass;
628 actionMap(actionIdx).removalDistribution = removalDist;
629 end
630 end
631 end
632 end
633 end
634 end
635end
636numActions = actionIdx;
637
638% Initialize R and AP
639R = cell(numActions + 1, max(numProcesses, 2));
640if numActions > 0
641 AP = zeros(numActions, 2);
642else
643 AP = zeros(0, 2); % Empty matrix when no actions
644end
645
646% Identify sink nodes (nodetype = -1 = NodeType.Sink)
647% Use row vector to ensure for-loop doesn't execute when empty
648sinkNodes = find(sn.nodetype == NodeType.Sink)';
649if isempty(sinkNodes)
650 sinkNodes = []; % Ensure empty row vector, not column
651end
652
653% Build local/hidden rate matrices L for each process (R{end,k})
654for p = 1:numProcesses
655 [ist, r] = find(processMap == p);
656 ist = ist(1); r = r(1);
657 R{numActions + 1, p} = build_local_rates(sn, ist, r, N(p), rt, sourceStations, sinkNodes, K);
658end
659
660% Build active and passive matrices for each action
661for a = 1:numActions
662 am = actionMap(a);
663
664 % Active process (departure)
665 ist = am.from_station;
666 r = am.from_class;
667 p_active = processMap(ist, r);
668 AP(a, 1) = p_active;
669
670 % Get service rate
671 mu_ir = sn.rates(ist, r);
672 prob = am.prob;
673
674 % Active matrix: transition n -> n-1 with rate mu*prob (service completion)
675 Aa = zeros(N(p_active));
676 for n = 2:N(p_active)
677 Aa(n, n-1) = mu_ir * prob;
678 end
679 % Boundary: at max capacity. For a closed class N(p)=njobs+1 is a real
680 % population bound, so the blocked-departure self-loop is physical. For an
681 % open class the boundary is a fictitious truncation at maxStates, and the
682 % self-loop injects a spurious pi(N)/pi(N)=1 ratio that biases the INAP
683 % reversed-rate mean by mu/N (it cancels out of the generator, so it is
684 % invisible in the equilibrium).
685 if sn.njobs(r) < Inf
686 Aa(N(p_active), N(p_active)) = mu_ir * prob;
687 end
688 R{a, 1} = Aa;
689
690 % Passive process (arrival or signal effect)
691 jst = am.to_station;
692 s = am.to_class;
693 p_passive = processMap(jst, s);
694 AP(a, 2) = p_passive;
695
696 Pb = zeros(N(p_passive));
697 if am.isNegative
698 % NEGATIVE: Job removal at destination (G-network negative customer)
699 if am.isCatastrophe
700 % CATASTROPHE: All jobs are removed - all states transition to 1 (empty)
701 for n = 1:N(p_passive)
702 Pb(n, 1) = 1;
703 end
704 elseif ~isempty(am.removalDistribution)
705 % BATCH REMOVAL: Remove a random number of jobs based on distribution
706 % P[n, m] = probability of transition from n to m jobs
707 dist = am.removalDistribution;
708 for n = 1:N(p_passive)
709 if n == 1
710 % Empty queue: no effect
711 Pb(1, 1) = 1;
712 else
713 % For each possible resulting state m (from 1 to n)
714 for m = 1:n
715 k = n - m; % Number of jobs to remove to go from n to m
716 % Probability of removing exactly k jobs when queue has n-1 jobs (0-indexed)
717 if m > 1
718 % Remove exactly k jobs: P(removal = k)
719 prob = dist.evalPMF(k);
720 if prob > 0
721 Pb(n, m) = Pb(n, m) + prob;
722 end
723 else
724 % Remove all jobs (m = 1, i.e., state 0): P(removal >= n-1)
725 % = 1 - CDF(n-2) = 1 - sum_{j=0}^{n-2} P(removal = j)
726 cdfNMinus1 = 0;
727 for j = 0:(n-2)
728 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
729 end
730 probAtLeastN = 1 - cdfNMinus1;
731 if probAtLeastN > 0
732 Pb(n, 1) = Pb(n, 1) + probAtLeastN;
733 end
734 end
735 end
736 end
737 end
738 else
739 % DEFAULT: Remove exactly 1 job (original behavior)
740 % Empty queue: no effect (state 1 stays at state 1)
741 Pb(1, 1) = 1;
742 % Non-empty queues: decrement (n -> n-1)
743 for n = 2:(N(p_passive) - 1)
744 Pb(n, n-1) = 1;
745 end
746 % Boundary at max capacity: decrement
747 if N(p_passive) > 1
748 Pb(N(p_passive), N(p_passive) - 1) = 1;
749 end
750 end
751 else
752 % POSITIVE: Normal job arrival at destination
753 for n = 1:(N(p_passive) - 1)
754 Pb(n, n+1) = 1;
755 end
756 % Boundary: at max capacity
757 Pb(N(p_passive), N(p_passive)) = 1;
758 end
759 R{a, 2} = Pb;
760end
761
762end
763
764function L = build_local_rates(sn, ist, r, Np, rt, sourceStations, sinkNodes, K)
765% Build local/hidden transition matrix for process at station ist, class r
766% Note: sinkNodes contains node indices (not station indices) for Sink nodes
767
768L = zeros(Np);
769
770% External arrivals from source - separate positive, negative (single), batch, and catastrophe
771lambda_ir_pos = 0; % Positive arrivals
772lambda_ir_neg_single = 0; % Negative arrivals with single removal (default)
773lambda_ir_catastrophe = 0; % Catastrophe arrivals (remove all)
774% Batch removal arrivals: cell array of {rate, distribution} pairs
775batchArrivals = {};
776
777for isrc = sourceStations
778 for s_src = 1:K
779 % Check if source class s_src is a signal
780 isSignal = sn.issignal(s_src);
781
782 if isSignal
783 % For signals: they route to themselves (Signal -> Signal), but their effect
784 % is on positive customers at the destination station. We check if the signal
785 % routes to ANY class at this station (not just class r).
786 prob_src = 0;
787 for s_dst = 1:K
788 prob_src = prob_src + rt((isrc-1)*K + s_src, (ist-1)*K + s_dst);
789 end
790 else
791 % For regular classes: direct routing to (ist, r)
792 prob_src = rt((isrc-1)*K + s_src, (ist-1)*K + r);
793 end
794
795 if prob_src > 0 && ~isnan(sn.rates(isrc, s_src))
796 srcRate = sn.rates(isrc, s_src);
797 % Check if source class s_src is a negative or catastrophe signal
798 if isSignal && ~isnan(sn.signaltype{s_src}) && ...
799 (sn.signaltype{s_src} == SignalType.NEGATIVE || sn.signaltype{s_src} == SignalType.CATASTROPHE)
800 % Check if it's a catastrophe (either via iscatastrophe flag or signaltype)
801 isCat = (isfield(sn, 'iscatastrophe') && ~isempty(sn.iscatastrophe) && sn.iscatastrophe(s_src)) || ...
802 sn.signaltype{s_src} == SignalType.CATASTROPHE;
803 if isCat
804 lambda_ir_catastrophe = lambda_ir_catastrophe + srcRate * prob_src;
805 else
806 % Check if it has a removal distribution
807 removalDist = [];
808 if isfield(sn, 'signalremdist') && ~isempty(sn.signalremdist) && s_src <= length(sn.signalremdist)
809 removalDist = sn.signalremdist{s_src};
810 end
811 if ~isempty(removalDist)
812 batchArrivals{end+1} = {srcRate * prob_src, removalDist};
813 else
814 lambda_ir_neg_single = lambda_ir_neg_single + srcRate * prob_src;
815 end
816 end
817 else
818 lambda_ir_pos = lambda_ir_pos + srcRate * prob_src;
819 end
820 end
821 end
822end
823
824% Positive arrival transitions: n -> n+1
825if lambda_ir_pos > 0
826 for n = 1:(Np-1)
827 L(n, n+1) = lambda_ir_pos;
828 end
829end
830
831% Catastrophe arrival transitions: n -> 1 (for all n > 1)
832if lambda_ir_catastrophe > 0
833 for n = 2:Np
834 L(n, 1) = L(n, 1) + lambda_ir_catastrophe;
835 end
836end
837
838% Batch removal arrival transitions: n -> m at rate λ * P(remove n-m) for m < n
839for b = 1:length(batchArrivals)
840 rate = batchArrivals{b}{1};
841 dist = batchArrivals{b}{2};
842 for n = 2:Np
843 for m = 1:n
844 k = n - m; % Number of jobs to remove
845 if m > 1
846 % Remove exactly k jobs: P(removal = k)
847 prob = dist.evalPMF(k);
848 else
849 % Remove all jobs (m = 1): P(removal >= n-1)
850 cdfNMinus1 = 0;
851 for j = 0:(n-2)
852 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
853 end
854 prob = 1 - cdfNMinus1;
855 end
856 if prob > 0
857 L(n, m) = L(n, m) + rate * prob;
858 end
859 end
860 end
861end
862
863% Single removal negative arrival transitions: n -> n-1 (only if queue non-empty)
864if lambda_ir_neg_single > 0
865 for n = 2:Np
866 L(n, n-1) = L(n, n-1) + lambda_ir_neg_single;
867 end
868end
869
870% Service rate at this station
871mu_ir = sn.rates(ist, r);
872if ~isnan(mu_ir) && mu_ir > 0
873 % Departures to sink (use rtnodes with node indices)
874 % Get the node index for this station
875 nodeIdx = sn.stationToNode(ist);
876
877 prob_sink = 0;
878 if isfield(sn, 'rtnodes') && ~isempty(sn.rtnodes)
879 nNodes = length(sn.nodetype);
880 for jsnk = sinkNodes
881 for s = 1:K
882 % rtnodes indices: (nodeIdx-1)*K + classIdx
883 fromIdx = (nodeIdx - 1) * K + r;
884 toIdx = (jsnk - 1) * K + s;
885 if fromIdx <= size(sn.rtnodes, 1) && toIdx <= size(sn.rtnodes, 2)
886 prob_sink = prob_sink + sn.rtnodes(fromIdx, toIdx);
887 end
888 end
889 end
890 end
891
892 % Self-routing (stays at same station, same class)
893 prob_self = rt((ist-1)*K + r, (ist-1)*K + r);
894
895 % Combined local departure rate
896 local_departure_rate = mu_ir * prob_sink;
897
898 % Departure transitions: n -> n-1 (add to existing negative arrival effects)
899 for n = 2:Np
900 L(n, n-1) = L(n, n-1) + local_departure_rate;
901 end
902
903 % Self-service transitions: n -> n (diagonal, for phase transitions)
904 for n = 2:Np
905 L(n, n) = mu_ir * prob_self;
906 end
907end
908
909end
910
911function [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N, rhoProc, isGeomProc)
912% RCAT_METRICS Convert RCAT solution to LINE performance metrics
913%
914% When RHOPROC/ISGEOMPROC are supplied (matrix-geometric 'inapinf' method),
915% processes flagged geometric use the exact closed-form moments of the
916% infinite geometric marginal instead of the truncated explicit vector pi.
917
918if nargin < 8, rhoProc = []; end
919if nargin < 9, isGeomProc = []; end
920
921M = sn.nstations;
922K = sn.nclasses;
923
924QN = zeros(M, K);
925UN = zeros(M, K);
926RN = zeros(M, K);
927TN = zeros(M, K);
928
929% Compute metrics for each (station, class) pair
930for ist = 1:M
931 for r = 1:K
932 p = processMap(ist, r);
933 if p > 0 && ~isempty(pi) && p <= length(pi) && ~isempty(pi{p})
934 mu_ir = sn.rates(ist, r);
935
936 if ~isempty(isGeomProc) && p <= numel(isGeomProc) && isGeomProc(p)
937 % Infinite geometric marginal pi_n = (1-rho) rho^n:
938 % E[N] = rho/(1-rho), P(N>0) = rho.
939 rho = rhoProc(p);
940 QN(ist, r) = rho / (1 - rho);
941 UN(ist, r) = rho;
942 if ~isnan(mu_ir) && mu_ir > 0
943 TN(ist, r) = mu_ir * rho;
944 end
945 else
946 Np = length(pi{p});
947
948 % Queue length: E[N] = sum_{n=0}^{Np-1} n * pi(n+1)
949 QN(ist, r) = (0:(Np-1)) * pi{p}(:);
950
951 % Utilization: P(N > 0) = 1 - pi(0) = 1 - pi{p}(1)
952 UN(ist, r) = 1 - pi{p}(1);
953
954 % Throughput: compute from utilization and service rate
955 if ~isnan(mu_ir) && mu_ir > 0
956 TN(ist, r) = mu_ir * UN(ist, r);
957 end
958 end
959 end
960 end
961end
962
963% Handle self-looping classes: they always stay at their reference station
964% and share the server with other classes under PS scheduling.
965% Only override if the method did not already compute SLC metrics (QN == 0).
966if isfield(sn, 'isslc') && any(sn.isslc)
967 for r = 1:K
968 if sn.isslc(r)
969 refst = sn.refstat(r);
970 if refst > 0 && refst <= M && QN(refst, r) == 0
971 % Self-looping class: all jobs stay at reference station
972 QN(refst, r) = sn.njobs(r);
973
974 % Service rate for this class
975 mu_ir = sn.rates(refst, r);
976 if ~isnan(mu_ir) && mu_ir > 0
977 nservers = sn.nservers(refst);
978 if isinf(nservers)
979 % Delay (infinite server): no capacity constraint,
980 % each job gets dedicated service
981 UN(refst, r) = QN(refst, r);
982 TN(refst, r) = mu_ir * QN(refst, r);
983 else
984 % Queue (finite server): capacity constraint applies
985 % Get utilization from other classes at this station
986 other_util = 0;
987 for s = 1:K
988 if s ~= r && ~sn.isslc(s)
989 other_util = other_util + UN(refst, s);
990 end
991 end
992
993 % Remaining capacity is shared with SLC
994 remaining_capacity = max(0, 1 - other_util);
995
996 % SLC utilization: min(demand, remaining capacity)
997 slc_demand = QN(refst, r) / mu_ir;
998 UN(refst, r) = min(slc_demand, remaining_capacity);
999 TN(refst, r) = mu_ir * UN(refst, r);
1000 end
1001 end
1002 end
1003 end
1004 end
1005end
1006
1007% Response times from Little's law: R = Q / T
1008for ist = 1:M
1009 for r = 1:K
1010 if TN(ist, r) > 0
1011 RN(ist, r) = QN(ist, r) / TN(ist, r);
1012 else
1013 RN(ist, r) = 0;
1014 end
1015 end
1016end
1017
1018% System metrics
1019CN = zeros(1, K);
1020XN = zeros(1, K);
1021
1022% For open classes: system throughput = arrival rate, system response time = sum of response times
1023for r = 1:K
1024 if sn.njobs(r) >= Inf % Open class
1025 % System throughput equals arrival rate (from source)
1026 for ist = 1:M
1027 nodeIdx = sn.stationToNode(ist);
1028 if sn.nodetype(nodeIdx) == NodeType.Source
1029 XN(r) = sn.rates(ist, r);
1030 break;
1031 end
1032 end
1033 % System response time = sum over all stations
1034 CN(r) = sum(RN(:, r));
1035 else
1036 % Closed class: use reference station
1037 refst = sn.refstat(r);
1038 if refst > 0 && refst <= M
1039 XN(r) = TN(refst, r);
1040 if XN(r) > 0
1041 CN(r) = sn.njobs(r) / XN(r);
1042 end
1043 end
1044 end
1045end
1046
1047end
1048
1049function pi = birth_death_solve(Q)
1050% BIRTH_DEATH_SOLVE Solve equilibrium of a birth-death (tridiagonal) CTMC
1051%
1052% For a birth-death chain with birth rate lambda_n = Q(n, n+1) and
1053% death rate mu_n = Q(n, n-1), the equilibrium is computed using the
1054% recursion pi(n) = pi(n-1) * lambda(n-1) / mu(n).
1055%
1056% This is numerically stable and avoids the ill-conditioned linear system
1057% that plagues null-space methods for large state spaces.
1058
1059n = size(Q, 1);
1060if n <= 1
1061 pi = 1;
1062 return;
1063end
1064
1065pi = zeros(1, n);
1066pi(1) = 1.0;
1067
1068for i = 2:n
1069 birth_rate = Q(i-1, i);
1070 death_rate = Q(i, i-1);
1071 if death_rate > 0
1072 pi(i) = pi(i-1) * birth_rate / death_rate;
1073 else
1074 pi(i) = 0;
1075 end
1076end
1077
1078total = sum(pi);
1079if total > 0
1080 pi = pi / total;
1081else
1082 pi = ones(1, n) / n;
1083end
1084
1085end
1086
1087function result = is_tridiagonal(Q)
1088% IS_TRIDIAGONAL Check if a matrix is tridiagonal
1089n = size(Q, 1);
1090result = true;
1091for i = 1:n
1092 for j = 1:n
1093 if abs(i - j) > 1 && abs(Q(i, j)) > 1e-14
1094 result = false;
1095 return;
1096 end
1097 end
1098end
1099end
Definition Station.m:265