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