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% 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
180 if L{k}(n, m) > 0 && (m < n - 1 || m > n + 1)
181 notBirthDeath(k) =
true;
187% Deterministic initial guess (reproducibility); see _kb/06-solver-catalog.md
for rationale
190% Compute initial equilibrium
191[pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
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);
199 iter = iter + 1; % legacy while-loop exited with the counter past the cap
202 function [xnew, xref] = inap_sweep(picur, ~)
205 % Update each action rate
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
216 LAMBDA_sum = LAMBDA_sum + Aa{a}(i,j) * pi{k}(i);
224 % inap: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j)
225 % x(a) = mean(LAMBDA) for non-zero entries
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);
234 if ~isempty(LAMBDA_vec)
235 x(a) = mean(LAMBDA_vec);
240 % Recompute equilibrium with new x
241 [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
245 function e = pi_blocknorm(xn, xr)
247 for kk = 1:numProcesses
248 e = max(e, norm(xn{kk} - xr{kk}, 1));
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
257Q = cell(1, numProcesses);
258pi = cell(1, numProcesses);
260for k = 1:numProcesses
261 % Start with local/hidden rates
262 Qk = L{k} - diag(L{k} * ones(N(k), 1));
264 % Add contributions from each action
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));
270 % Process k is active for action c: add Aa{c}
271 Qk = Qk + Aa{c} - diag(Aa{c} * ones(N(k), 1));
275 % Convert to valid infinitesimal generator
276 Q{k} = ctmc_makeinfgen(Qk);
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});
283 pi{k} = ctmc_solve(Q{k});
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).
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.
303% Reference: A. Marin, S. Rota Bulo, S. Balsamo, "A Numerical Algorithm for
304% the Decomposition of Cooperating Structured Markov Processes", MASCOTS 2012.
309numProcesses = max(AP(:));
311% Extract rate matrices
320L = cell(1, numProcesses);
321for k = 1:numProcesses
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);
333 aRowSum{a} = sum(Aa{a}, 2);
336% Deterministic initial guess (see inap): reproducible across back-ends.
339[pi, Q, rhoProc, isGeomProc] = ...
340 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
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);
348 iter = iter + 1; % legacy
while-loop exited with the counter past the cap
352 function [xnew, xref] = inapinf_sweep(picur, ~)
355 % Reversed-rate update, Eq. (4): x_l = pi^(alpha_l) T^(l) e.
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);
365 x(a) = v
' * aRowSum{a};
369 [pi, Q, rhoProc, isGeomProc] = ...
370 compute_equilibrium_qbd(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N, isOpenProc);
374 function e = pi_blocknorm_trunc(xn, xr)
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));
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.
390 resVec = x(a) * v - v * Aa{a};
391 rcatRes = max(rcatRes, norm(resVec, 2));
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);
406for k = 1:numProcesses
409 % Assemble strictly off-diagonal rate matrix
for component k
410 Off = L{k} - diag(diag(L{k}));
413 Off = Off + x(c) * Pb{c};
418 Off = Off - diag(diag(Off));
420 Qk = Off - diag(sum(Off, 2));
421 Q{k} = ctmc_makeinfgen(Qk);
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)
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.
436 interDown = sum(row(2:s0-2));
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
442 isGeomProc(k) = true;
443 pi{k} = (1 - rho) * rho .^ (0:Nk-1);
450 if is_tridiagonal(Q{k})
451 pi{k} = birth_death_solve(Q{k});
453 pi{k} = ctmc_solve(Q{k});
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).
478disc = c1^2 - 4 * b * f;
484r1 = (-c1 - sq) / (2 * b);
485r2 = (-c1 + sq) / (2 * b);
486cands = sort([r1, r2]);
495function [R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates)
496% BUILD_RCAT Convert LINE network structure to RCAT format
504rt = sn.rt; % (M*K) x (M*K) routing table
506% Identify station types
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;
518 % Queue, Delay, or other service stations
519 queueStations(end+1) = ist;
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
527processMap = zeros(M, K);
528for ist = queueStations
530 % Skip Signal classes - they don
't have their own queue state
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;
541numProcesses = processIdx;
551% G-network signals modify positive-customer processes, not their own;
552% see _kb/06-solver-catalog.md for rationale
554% Determine number of states for each process
555N = zeros(1, numProcesses);
556for p = 1:numProcesses
557 [ist, r] = find(processMap == p);
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
563 N(p) = maxStates; % Truncate at maxStates
568% Count actions: each routing transition (i,r) -> (j,s) where P > 0
570actionMap = struct('from_station
', {}, 'from_class
', {}, ...
571 'to_station
', {}, 'to_class
', {}, 'prob
', {}, ...
572 'isNegative
', {}, 'isCatastrophe
', {}, 'removalDistribution
', {});
574for ist = queueStations
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
580 isNegativeClass = false;
581 isCatastropheClass = false;
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;
591 % Get removal distribution for this class
592 if isfield(sn, 'signalremdist
') && ~isempty(sn.signalremdist) && r <= length(sn.signalremdist)
593 removalDist = sn.signalremdist{r};
597 for jst = queueStations
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;
620numActions = actionIdx;
623R = cell(numActions + 1, max(numProcesses, 2));
625 AP = zeros(numActions, 2);
627 AP = zeros(0, 2); % Empty matrix when no actions
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)
';
634 sinkNodes = []; % Ensure empty row vector, not column
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);
644% Build active and passive matrices for each action
648 % Active process (departure)
649 ist = am.from_station;
651 p_active = processMap(ist, r);
655 mu_ir = sn.rates(ist, r);
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;
663 % Boundary self-loop physical only for closed class (open-truncation bias);
664 % see _kb/06-solver-catalog.md for rationale
666 Aa(N(p_active), N(p_active)) = mu_ir * prob;
670 % Passive process (arrival or signal effect)
673 p_passive = processMap(jst, s);
674 AP(a, 2) = p_passive;
676 Pb = zeros(N(p_passive));
678 % NEGATIVE: Job removal at destination (G-network negative customer)
680 % CATASTROPHE: All jobs are removed - all states transition to 1 (empty)
681 for n = 1:N(p_passive)
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)
690 % Empty queue: no effect
693 % For each possible resulting state m (from 1 to 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)
698 % Remove exactly k jobs: P(removal = k)
699 prob = dist.evalPMF(k);
701 Pb(n, m) = Pb(n, m) + prob;
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)
708 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
710 probAtLeastN = 1 - cdfNMinus1;
712 Pb(n, 1) = Pb(n, 1) + probAtLeastN;
719 % DEFAULT: Remove exactly 1 job (original behavior)
720 % Empty queue: no effect (state 1 stays at state 1)
722 % Non-empty queues: decrement (n -> n-1)
723 for n = 2:(N(p_passive) - 1)
726 % Boundary at max capacity: decrement
728 Pb(N(p_passive), N(p_passive) - 1) = 1;
732 % POSITIVE: Normal job arrival at destination
733 for n = 1:(N(p_passive) - 1)
736 % Boundary: at max capacity
737 Pb(N(p_passive), N(p_passive)) = 1;
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
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
757for isrc = sourceStations
759 % Check if source class s_src is a signal
760 isSignal = sn.issignal(s_src);
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).
768 prob_src = prob_src + rt((isrc-1)*K + s_src, (ist-1)*K + s_dst);
771 % For regular classes: direct routing to (ist, r)
772 prob_src = rt((isrc-1)*K + s_src, (ist-1)*K + r);
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;
784 lambda_ir_catastrophe = lambda_ir_catastrophe + srcRate * prob_src;
786 % Check
if it has a removal distribution
788 if isfield(sn,
'signalremdist') && ~isempty(sn.signalremdist) && s_src <= length(sn.signalremdist)
789 removalDist = sn.signalremdist{s_src};
791 if ~isempty(removalDist)
792 batchArrivals{end+1} = {srcRate * prob_src, removalDist};
794 lambda_ir_neg_single = lambda_ir_neg_single + srcRate * prob_src;
798 lambda_ir_pos = lambda_ir_pos + srcRate * prob_src;
804% Positive arrival transitions: n -> n+1
807 L(n, n+1) = lambda_ir_pos;
811% Catastrophe arrival transitions: n -> 1 (
for all n > 1)
812if lambda_ir_catastrophe > 0
814 L(n, 1) = L(n, 1) + lambda_ir_catastrophe;
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};
824 k = n - m; % Number of jobs to remove
826 % Remove exactly k jobs:
P(removal = k)
827 prob = dist.evalPMF(k);
829 % Remove all jobs (m = 1):
P(removal >= n-1)
832 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
834 prob = 1 - cdfNMinus1;
837 L(n, m) = L(n, m) + rate * prob;
843% Single removal negative arrival transitions: n -> n-1 (only
if queue non-empty)
844if lambda_ir_neg_single > 0
846 L(n, n-1) = L(n, n-1) + lambda_ir_neg_single;
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);
858 if isfield(sn,
'rtnodes') && ~isempty(sn.rtnodes)
859 nNodes = length(sn.nodetype);
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);
872 % Self-routing (stays at same station, same class)
873 prob_self = rt((ist-1)*K + r, (ist-1)*K + r);
875 % Combined local departure rate
876 local_departure_rate = mu_ir * prob_sink;
878 % Departure transitions: n -> n-1 (add to existing negative arrival effects)
880 L(n, n-1) = L(n, n-1) + local_departure_rate;
883 % Self-service transitions: n -> n (diagonal, for phase transitions)
885 L(n, n) = mu_ir * prob_self;
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
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.
898if nargin < 8, rhoProc = []; end
899if nargin < 9, isGeomProc = []; end
909% Compute metrics for each (station, class) pair
912 p = processMap(ist, r);
913 if p > 0 && ~isempty(pi) && p <= length(pi) && ~isempty(pi{p})
914 mu_ir = sn.rates(ist, r);
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.
920 QN(ist, r) = rho / (1 - rho);
922 if ~isnan(mu_ir) && mu_ir > 0
923 TN(ist, r) = mu_ir * rho;
928 % Queue length: E[N] =
sum_{n=0}^{Np-1} n * pi(n+1)
929 QN(ist, r) = (0:(Np-1)) * pi{p}(:);
931 % Utilization:
P(N > 0) = 1 - pi(0) = 1 - pi{p}(1)
932 UN(ist, r) = 1 - pi{p}(1);
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);
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)
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);
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);
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);
964 % Queue (finite server): capacity constraint applies
965 % Get utilization from other classes at this station
968 if s ~= r && ~sn.isslc(s)
969 other_util = other_util + UN(refst, s);
973 % Remaining capacity
is shared with SLC
974 remaining_capacity = max(0, 1 - other_util);
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);
987% Response times from Little's law: R = Q / T
991 RN(ist, r) = QN(ist, r) / TN(ist, r);
1002% For open classes: system throughput = arrival rate, system response time = sum of response times
1004 if sn.njobs(r) >= Inf % Open class
1005 % System throughput equals arrival rate (from source)
1007 nodeIdx = sn.stationToNode(ist);
1008 if sn.nodetype(nodeIdx) == NodeType.Source
1009 XN(r) = sn.rates(ist, r);
1013 % System response time = sum over all stations
1014 CN(r) = sum(RN(:, r));
1016 % Closed class: use reference station
1017 refst = sn.refstat(r);
1018 if refst > 0 && refst <= M
1019 XN(r) = TN(refst, r);
1021 CN(r) = sn.njobs(r) / XN(r);
1029function pi = birth_death_solve(Q)
1030% BIRTH_DEATH_SOLVE Solve equilibrium of a birth-death (tridiagonal) CTMC
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).
1036% This
is numerically stable and avoids the ill-conditioned linear system
1037% that plagues null-space methods for large state spaces.
1049 birth_rate = Q(i-1, i);
1050 death_rate = Q(i, i-1);
1052 pi(i) = pi(i-1) * birth_rate / death_rate;
1062 pi = ones(1, n) / n;
1067function result = is_tridiagonal(Q)
1068% IS_TRIDIAGONAL Check if a matrix
is tridiagonal
1073 if abs(i - j) > 1 && abs(Q(i, j)) > 1e-14