1function [QN, UN, RN, TN, CN, XN, iter] = solver_mam_ag(sn, options)
2% SOLVER_MAM_AG AG methods
for SolverMAM
4% [QN, UN, RN, TN, CN, XN, ITER] = SOLVER_MAM_AG(SN, OPTIONS)
6% Uses RCAT (Reversed Compound Agent Theorem) to find product-
form
7% solutions
for queueing networks.
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)
18% Copyright (c) 2012-2025, Imperial College London
24% Set
default max states
for truncation
25if isfield(options,
'config') && isfield(options.config,
'maxStates')
26 maxStates = options.config.maxStates;
31% Set default tolerances
32if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
33 tol = options.iter_tol;
38if isfield(options, 'iter_max') && ~isempty(options.iter_max)
39 maxiter = options.iter_max;
44% Build RCAT model from network structure
45[R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates);
47% Check if we have a valid model
48numProcesses = max(processMap(:));
49numActions = size(AP, 1);
51% Return early only if no processes found
53 line_warning(mfilename, 'Network could not be mapped to RCAT format (no processes found).\n');
64% If no actions but we have processes, solve using local rates only
65% This handles single-queue G-networks (Source -> Queue -> Sink)
67 % No inter-station actions: solve equilibrium using only L matrices
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});
80 [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
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);
88 [ipst, ipr] = find(processMap == p);
90 isOpenProc(p) = isinf(sn.njobs(ipr(1)));
95method = options.method;
96if strcmp(method,
'default')
100% Per-process geometric-tail decay (set only by 'inapinf'); empty => metrics
101% are computed from
the explicit stationary vectors pi.
107 % Fast iterative heuristic
108 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
111 % Improved INAP with weighted rates (no normalization)
112 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inapplus');
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);
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');
127 line_error(mfilename, 'Unknown method: %s\n', method);
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);
137function [x, pi, Q, iter] = inap(R, AP, tol, maxiter, method)
138% INAP Iterative Numerical Approximation Procedure for RCAT
141% 'inap': x(a) = mean(Aa(i,j) * pi(i) / pi(j))
142% 'inapplus': x(a) = sum(Aa(i,j) * pi(i))
144if nargin < 5 || isempty(method)
152numProcesses = max(AP(:));
154% Extract rate matrices
163L = cell(1, numProcesses);
164for k = 1:numProcesses
168% Get state space sizes
169N = zeros(1, numProcesses);
170for k = 1:numProcesses
171 N(k) = size(L{k}, 1);
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
188 if L{k}(n, m) > 0 && (m < n - 1 || m > n + 1)
189 notBirthDeath(k) =
true;
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.
200% Compute initial equilibrium
201[pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
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);
209 iter = iter + 1; % legacy while-loop exited with the counter past the cap
212 function [xnew, xref] = inap_sweep(picur, ~)
215 % Update each action rate
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
226 LAMBDA_sum = LAMBDA_sum + Aa{a}(i,j) * pi{k}(i);
234 % inap: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j)
235 % x(a) = mean(LAMBDA) for non-zero entries
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);
244 if ~isempty(LAMBDA_vec)
245 x(a) = mean(LAMBDA_vec);
250 % Recompute equilibrium with new x
251 [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
255 function e = pi_blocknorm(xn, xr)
257 for kk = 1:numProcesses
258 e = max(e, norm(xn{kk} - xr{kk}, 1));
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
267Q = cell(1, numProcesses);
268pi = cell(1, numProcesses);
270for k = 1:numProcesses
271 % Start with local/hidden rates
272 Qk = L{k} - diag(L{k} * ones(N(k), 1));
274 % Add contributions from each action
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));
280 % Process k is active for action c: add Aa{c}
281 Qk = Qk + Aa{c} - diag(Aa{c} * ones(N(k), 1));
285 % Convert to valid infinitesimal generator
286 Q{k} = ctmc_makeinfgen(Qk);
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});
294 pi{k} = ctmc_solve(Q{k});
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).
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.
314% Reference: A. Marin, S. Rota Bulo, S. Balsamo, "A Numerical Algorithm for
315% the Decomposition of Cooperating Structured Markov Processes", MASCOTS 2012.
320numProcesses = max(AP(:));
322% Extract rate matrices
331L = cell(1, numProcesses);
332for k = 1:numProcesses
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);
344 aRowSum{a} = sum(Aa{a}, 2);
347% Deterministic initial guess (see inap): reproducible across back-ends.
350[pi, Q, rhoProc, isGeomProc] = ...
351 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
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);
359 iter = iter + 1; % legacy
while-loop exited with
the counter past
the cap
363 function [xnew, xref] = inapinf_sweep(picur, ~)
366 % Reversed-rate update, Eq. (4): x_l = pi^(alpha_l) T^(l) e.
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);
376 x(a) = v
' * aRowSum{a};
380 [pi, Q, rhoProc, isGeomProc] = ...
381 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
385 function e = pi_blocknorm_trunc(xn, xr)
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));
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.
401 resVec = x(a) * v - v * Aa{a};
402 rcatRes = max(rcatRes, norm(resVec, 2));
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.
415Q = cell(1, numProcesses);
416pi = cell(1, numProcesses);
417rhoProc = zeros(1, numProcesses);
418isGeomProc =
false(1, numProcesses);
420for k = 1:numProcesses
423 % Assemble strictly off-diagonal rate matrix
for component k
424 Off = L{k} - diag(diag(L{k}));
427 Off = Off + x(c) * Pb{c};
432 Off = Off - diag(diag(Off));
434 Qk = Off - diag(sum(Off, 2));
435 Q{k} = ctmc_makeinfgen(Qk);
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)
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.
450 interDown = sum(row(2:s0-2));
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
456 isGeomProc(k) = true;
457 pi{k} = (1 - rho) * rho .^ (0:Nk-1);
464 if is_tridiagonal(Q{k})
465 pi{k} = birth_death_solve(Q{k});
467 pi{k} = ctmc_solve(Q{k});
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).
492disc = c1^2 - 4 * b * f;
498r1 = (-c1 - sq) / (2 * b);
499r2 = (-c1 + sq) / (2 * b);
500cands = sort([r1, r2]);
509function [R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates)
510% BUILD_RCAT Convert LINE network structure to RCAT format
518rt = sn.rt; % (M*K) x (M*K) routing table
520% Identify station types
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;
532 % Queue, Delay, or other service stations
533 queueStations(end+1) = ist;
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
541processMap = zeros(M, K);
542for ist = queueStations
544 % Skip Signal classes - they don
't have their own queue state
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;
555numProcesses = processIdx;
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)
570% Determine number of states for each process
571N = zeros(1, numProcesses);
572for p = 1:numProcesses
573 [ist, r] = find(processMap == p);
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
579 N(p) = maxStates; % Truncate at maxStates
584% Count actions: each routing transition (i,r) -> (j,s) where P > 0
586actionMap = struct('from_station
', {}, 'from_class
', {}, ...
587 'to_station
', {}, 'to_class
', {}, 'prob
', {}, ...
588 'isNegative
', {}, 'isCatastrophe
', {}, 'removalDistribution
', {});
590for ist = queueStations
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
596 isNegativeClass = false;
597 isCatastropheClass = false;
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;
607 % Get removal distribution for this class
608 if isfield(sn, 'signalremdist
') && ~isempty(sn.signalremdist) && r <= length(sn.signalremdist)
609 removalDist = sn.signalremdist{r};
613 for jst = queueStations
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;
636numActions = actionIdx;
639R = cell(numActions + 1, max(numProcesses, 2));
641 AP = zeros(numActions, 2);
643 AP = zeros(0, 2); % Empty matrix when no actions
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)
';
650 sinkNodes = []; % Ensure empty row vector, not column
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);
660% Build active and passive matrices for each action
664 % Active process (departure)
665 ist = am.from_station;
667 p_active = processMap(ist, r);
671 mu_ir = sn.rates(ist, r);
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;
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).
686 Aa(N(p_active), N(p_active)) = mu_ir * prob;
690 % Passive process (arrival or signal effect)
693 p_passive = processMap(jst, s);
694 AP(a, 2) = p_passive;
696 Pb = zeros(N(p_passive));
698 % NEGATIVE: Job removal at destination (G-network negative customer)
700 % CATASTROPHE: All jobs are removed - all states transition to 1 (empty)
701 for n = 1:N(p_passive)
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)
710 % Empty queue: no effect
713 % For each possible resulting state m (from 1 to 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)
718 % Remove exactly k jobs: P(removal = k)
719 prob = dist.evalPMF(k);
721 Pb(n, m) = Pb(n, m) + prob;
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)
728 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
730 probAtLeastN = 1 - cdfNMinus1;
732 Pb(n, 1) = Pb(n, 1) + probAtLeastN;
739 % DEFAULT: Remove exactly 1 job (original behavior)
740 % Empty queue: no effect (state 1 stays at state 1)
742 % Non-empty queues: decrement (n -> n-1)
743 for n = 2:(N(p_passive) - 1)
746 % Boundary at max capacity: decrement
748 Pb(N(p_passive), N(p_passive) - 1) = 1;
752 % POSITIVE: Normal job arrival at destination
753 for n = 1:(N(p_passive) - 1)
756 % Boundary: at max capacity
757 Pb(N(p_passive), N(p_passive)) = 1;
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
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
777for isrc = sourceStations
779 % Check if source class s_src is a signal
780 isSignal = sn.issignal(s_src);
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).
788 prob_src = prob_src + rt((isrc-1)*K + s_src, (ist-1)*K + s_dst);
791 % For regular classes: direct routing to (ist, r)
792 prob_src = rt((isrc-1)*K + s_src, (ist-1)*K + r);
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;
804 lambda_ir_catastrophe = lambda_ir_catastrophe + srcRate * prob_src;
806 % Check
if it has a removal distribution
808 if isfield(sn,
'signalremdist') && ~isempty(sn.signalremdist) && s_src <= length(sn.signalremdist)
809 removalDist = sn.signalremdist{s_src};
811 if ~isempty(removalDist)
812 batchArrivals{end+1} = {srcRate * prob_src, removalDist};
814 lambda_ir_neg_single = lambda_ir_neg_single + srcRate * prob_src;
818 lambda_ir_pos = lambda_ir_pos + srcRate * prob_src;
824% Positive arrival transitions: n -> n+1
827 L(n, n+1) = lambda_ir_pos;
831% Catastrophe arrival transitions: n -> 1 (
for all n > 1)
832if lambda_ir_catastrophe > 0
834 L(n, 1) = L(n, 1) + lambda_ir_catastrophe;
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};
844 k = n - m; % Number of jobs to remove
846 % Remove exactly k jobs:
P(removal = k)
847 prob = dist.evalPMF(k);
849 % Remove all jobs (m = 1):
P(removal >= n-1)
852 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
854 prob = 1 - cdfNMinus1;
857 L(n, m) = L(n, m) + rate * prob;
863% Single removal negative arrival transitions: n -> n-1 (only
if queue non-empty)
864if lambda_ir_neg_single > 0
866 L(n, n-1) = L(n, n-1) + lambda_ir_neg_single;
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);
878 if isfield(sn,
'rtnodes') && ~isempty(sn.rtnodes)
879 nNodes = length(sn.nodetype);
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);
892 % Self-routing (stays at same station, same class)
893 prob_self = rt((ist-1)*K + r, (ist-1)*K + r);
895 % Combined local departure rate
896 local_departure_rate = mu_ir * prob_sink;
898 % Departure transitions: n -> n-1 (add to existing negative arrival effects)
900 L(n, n-1) = L(n, n-1) + local_departure_rate;
903 % Self-service transitions: n -> n (diagonal, for phase transitions)
905 L(n, n) = mu_ir * prob_self;
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
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.
918if nargin < 8, rhoProc = []; end
919if nargin < 9, isGeomProc = []; end
929% Compute metrics for each (station, class) pair
932 p = processMap(ist, r);
933 if p > 0 && ~isempty(pi) && p <= length(pi) && ~isempty(pi{p})
934 mu_ir = sn.rates(ist, r);
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.
940 QN(ist, r) = rho / (1 - rho);
942 if ~isnan(mu_ir) && mu_ir > 0
943 TN(ist, r) = mu_ir * rho;
948 % Queue length: E[N] = sum_{n=0}^{Np-1} n * pi(n+1)
949 QN(ist, r) = (0:(Np-1)) * pi{p}(:);
951 % Utilization:
P(N > 0) = 1 - pi(0) = 1 - pi{p}(1)
952 UN(ist, r) = 1 - pi{p}(1);
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);
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)
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);
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);
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);
984 % Queue (finite server): capacity constraint applies
985 % Get utilization from other classes at this station
988 if s ~= r && ~sn.isslc(s)
989 other_util = other_util + UN(refst, s);
993 % Remaining capacity
is shared with SLC
994 remaining_capacity = max(0, 1 - other_util);
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);
1007% Response times from Little's law: R = Q / T
1011 RN(ist, r) = QN(ist, r) / TN(ist, r);
1022% For open classes: system throughput = arrival rate, system response time = sum of response times
1024 if sn.njobs(r) >= Inf % Open class
1025 % System throughput equals arrival rate (from source)
1027 nodeIdx = sn.stationToNode(ist);
1028 if sn.nodetype(nodeIdx) == NodeType.Source
1029 XN(r) = sn.rates(ist, r);
1033 % System response time = sum over all stations
1034 CN(r) = sum(RN(:, r));
1036 % Closed class: use reference station
1037 refst = sn.refstat(r);
1038 if refst > 0 && refst <= M
1039 XN(r) = TN(refst, r);
1041 CN(r) = sn.njobs(r) / XN(r);
1049function pi = birth_death_solve(Q)
1050% BIRTH_DEATH_SOLVE Solve equilibrium of a birth-death (tridiagonal) CTMC
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).
1056% This
is numerically stable and avoids
the ill-conditioned linear system
1057% that plagues null-space methods for large state spaces.
1069 birth_rate = Q(i-1, i);
1070 death_rate = Q(i, i-1);
1072 pi(i) = pi(i-1) * birth_rate / death_rate;
1082 pi = ones(1, n) / n;
1087function result = is_tridiagonal(Q)
1088% IS_TRIDIAGONAL Check if a matrix
is tridiagonal
1093 if abs(i - j) > 1 && abs(Q(i, j)) > 1e-14