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%
'exact' - Not available (autocat moved to line-legacy.git)
14% Copyright (c) 2012-2025, Imperial College London
20% Set
default max states
for truncation
21if isfield(options,
'config') && isfield(options.config,
'maxStates')
22 maxStates = options.config.maxStates;
27% Set default tolerances
28if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
29 tol = options.iter_tol;
34if isfield(options, 'iter_max') && ~isempty(options.iter_max)
35 maxiter = options.iter_max;
40% Build RCAT model from network structure
41[R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates);
43% Check if we have a valid model
44numProcesses = max(processMap(:));
45numActions = size(AP, 1);
47% Return early only if no processes found
49 line_warning(mfilename, 'Network could not be mapped to RCAT format (no processes found).\n');
60% If no actions but we have processes, solve using local rates only
61% This handles single-queue G-networks (Source -> Queue -> Sink)
63 % No inter-station actions: solve equilibrium using only L matrices
65 pi = cell(1, numProcesses);
66 Q = cell(1, numProcesses);
67 for p = 1:numProcesses
68 L = R{1, p}; % Local rate matrix
is in R{numActions+1, p} = R{1, p} when numActions=0
69 % Convert to valid generator matrix
70 Qp = L - diag(L * ones(size(L, 1), 1));
71 Q{p} = ctmc_makeinfgen(Qp);
72 % Solve
for equilibrium
73 pi{p} = ctmc_solve(Q{p});
76 [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
81method = options.method;
82if strcmp(method,
'default')
88 % Fast iterative heuristic
89 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
92 % Improved INAP with weighted rates (no normalization)
93 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inapplus');
96 % Optimization-based solver using autocat (not available in this version)
97 line_warning(mfilename, '''exact'' method not available. Falling back to inap.\n');
98 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
101 line_error(mfilename, 'Unknown method: %s\n', method);
104% Convert RCAT solution to LINE metrics
105[QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
111function [x, pi, Q, iter] = inap(R, AP, tol, maxiter, method)
112% INAP Iterative Numerical Approximation Procedure for RCAT
115% 'inap': x(a) = mean(Aa(i,j) * pi(i) / pi(j))
116% 'inapplus': x(a) = sum(Aa(i,j) * pi(i))
118if nargin < 5 || isempty(method)
126numProcesses = max(AP(:));
128% Extract rate matrices
137L = cell(1, numProcesses);
138for k = 1:numProcesses
142% Get state space sizes
143N = zeros(1, numProcesses);
144for k = 1:numProcesses
145 N(k) = size(L{k}, 1);
148% Deterministic initial guess
for the fixed-point iteration. A random start
149% made results non-reproducible run-to-run and divergent across back-ends;
150% the iteration converges to the same fixed point regardless.
153% Compute initial equilibrium
154[pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
158 % Save old iteration result
161 % Update each action rate
165 if strcmp(method, 'inapplus
')
166 % inapplus: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i)
167 % x(a) = sum(LAMBDA) for non-zero entries
172 LAMBDA_sum = LAMBDA_sum + Aa{a}(i,j) * pi{k}(i);
180 % inap: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j)
181 % x(a) = mean(LAMBDA) for non-zero entries
185 if Aa{a}(i,j) > 0 && pi{k}(j) > 0
186 LAMBDA_vec(end+1) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j);
190 if ~isempty(LAMBDA_vec)
191 x(a) = mean(LAMBDA_vec);
196 % Recompute equilibrium with new x
197 [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
201 for k = 1:numProcesses
202 maxErr = max([maxErr, norm(pi{k} - piprev{k}, 1)]);
214function [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N)
215% Compute equilibrium distribution for each process given action rates x
217Q = cell(1, numProcesses);
218pi = cell(1, numProcesses);
220for k = 1:numProcesses
221 % Start with local/hidden rates
222 Qk = L{k} - diag(L{k} * ones(N(k), 1));
224 % Add contributions from each action
227 % Process k is passive for action c: add x(c) * Pb{c}
228 Qk = Qk + x(c) * Pb{c} - diag(Pb{c} * ones(N(k), 1));
230 % Process k is active for action c: add Aa{c}
231 Qk = Qk + Aa{c} - diag(Aa{c} * ones(N(k), 1));
235 % Convert to valid infinitesimal generator
236 Q{k} = ctmc_makeinfgen(Qk);
238 % Solve for equilibrium distribution using birth-death recursion for
239 % tridiagonal generators (numerically stable for large state spaces),
240 % falling back to ctmc_solve otherwise
241 if is_tridiagonal(Q{k})
242 pi{k} = birth_death_solve(Q{k});
244 pi{k} = ctmc_solve(Q{k});
250function [R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates)
251% BUILD_RCAT Convert LINE network structure to RCAT format
259rt = sn.rt; % (M*K) x (M*K) routing table
261% Identify station types
267 nodeIdx = sn.stationToNode(ist);
268 if sn.nodetype(nodeIdx) == NodeType.Source
269 sourceStations(end+1) = ist;
270 elseif sn.nodetype(nodeIdx) == NodeType.Sink
271 sinkStations(end+1) = ist;
273 % Queue, Delay, or other service stations
274 queueStations(end+1) = ist;
278% Create process mapping: each (station, class) pair at queue stations
279% Note: Signal classes (negative customers) don't create separate processes
280% as they only modify the state of positive customer processes
282processMap = zeros(M, K);
283for ist = queueStations
285 % Skip Signal
classes - they don
't have their own queue state
289 % Check if this station serves this class
290 if ~isnan(sn.rates(ist, r)) && sn.rates(ist, r) > 0
291 processIdx = processIdx + 1;
292 processMap(ist, r) = processIdx;
296numProcesses = processIdx;
306% Check for signal classes - G-networks with signals
307% G-network signals (negative customers, catastrophes, batch removal) are handled by:
308% 1. Adding signal arrival effects to the L matrix for positive customer processes
309% 2. Creating actions for signal routing between stations (job removal at destination)
311% Determine number of states for each process
312N = zeros(1, numProcesses);
313for p = 1:numProcesses
314 [ist, r] = find(processMap == p);
316 ist = ist(1); r = r(1);
317 if sn.njobs(r) < Inf % Closed class
318 N(p) = sn.njobs(r) + 1; % States 0, 1, ..., njobs
320 N(p) = maxStates; % Truncate at maxStates
325% Count actions: each routing transition (i,r) -> (j,s) where P > 0
327actionMap = struct('from_station
', {}, 'from_class
', {}, ...
328 'to_station
', {}, 'to_class
', {}, 'prob
', {}, ...
329 'isNegative
', {}, 'isCatastrophe
', {}, 'removalDistribution
', {});
331for ist = queueStations
333 if processMap(ist, r) > 0
334 % Check if class r is a negative signal class
335 isNegativeClass = false;
336 isCatastropheClass = false;
338 if sn.issignal(r) && ~isnan(sn.signaltype{r}) && sn.signaltype{r} == SignalType.NEGATIVE
339 isNegativeClass = true;
340 % Check if class r is a catastrophe signal
341 if isfield(sn, 'isCatastrophe
') && ~isempty(sn.isCatastrophe) && sn.isCatastrophe(r) > 0
342 isCatastropheClass = true;
344 % Get removal distribution for this class
345 if isfield(sn, 'signalRemovalDist
') && ~isempty(sn.signalRemovalDist) && r <= length(sn.signalRemovalDist)
346 removalDist = sn.signalRemovalDist{r};
350 for jst = queueStations
352 if processMap(jst, s) > 0
353 % Get routing probability
354 prob_ij_rs = rt((ist-1)*K + r, (jst-1)*K + s);
355 if prob_ij_rs > 0 && (ist ~= jst || r ~= s)
356 % This is an action (departure from i,r triggers arrival at j,s)
357 actionIdx = actionIdx + 1;
358 actionMap(actionIdx).from_station = ist;
359 actionMap(actionIdx).from_class = r;
360 actionMap(actionIdx).to_station = jst;
361 actionMap(actionIdx).to_class = s;
362 actionMap(actionIdx).prob = prob_ij_rs;
363 actionMap(actionIdx).isNegative = isNegativeClass;
364 actionMap(actionIdx).isCatastrophe = isCatastropheClass;
365 actionMap(actionIdx).removalDistribution = removalDist;
373numActions = actionIdx;
376R = cell(numActions + 1, max(numProcesses, 2));
378 AP = zeros(numActions, 2);
380 AP = zeros(0, 2); % Empty matrix when no actions
383% Identify sink nodes (nodetype = -1 = NodeType.Sink)
384% Use row vector to ensure for-loop doesn't execute when empty
385sinkNodes = find(sn.nodetype == NodeType.Sink)
';
387 sinkNodes = []; % Ensure empty row vector, not column
390% Build local/hidden rate matrices L for each process (R{end,k})
391for p = 1:numProcesses
392 [ist, r] = find(processMap == p);
393 ist = ist(1); r = r(1);
394 R{numActions + 1, p} = build_local_rates(sn, ist, r, N(p), rt, sourceStations, sinkNodes, K);
397% Build active and passive matrices for each action
401 % Active process (departure)
402 ist = am.from_station;
404 p_active = processMap(ist, r);
408 mu_ir = sn.rates(ist, r);
411 % Active matrix: transition n -> n-1 with rate mu*prob (service completion)
412 Aa = zeros(N(p_active));
413 for n = 2:N(p_active)
414 Aa(n, n-1) = mu_ir * prob;
416 % Boundary: at max capacity
417 Aa(N(p_active), N(p_active)) = mu_ir * prob;
420 % Passive process (arrival or signal effect)
423 p_passive = processMap(jst, s);
424 AP(a, 2) = p_passive;
426 Pb = zeros(N(p_passive));
428 % NEGATIVE: Job removal at destination (G-network negative customer)
430 % CATASTROPHE: All jobs are removed - all states transition to 1 (empty)
431 for n = 1:N(p_passive)
434 elseif ~isempty(am.removalDistribution)
435 % BATCH REMOVAL: Remove a random number of jobs based on distribution
436 % P[n, m] = probability of transition from n to m jobs
437 dist = am.removalDistribution;
438 for n = 1:N(p_passive)
440 % Empty queue: no effect
443 % For each possible resulting state m (from 1 to n)
445 k = n - m; % Number of jobs to remove to go from n to m
446 % Probability of removing exactly k jobs when queue has n-1 jobs (0-indexed)
448 % Remove exactly k jobs: P(removal = k)
449 prob = dist.evalPMF(k);
451 Pb(n, m) = Pb(n, m) + prob;
454 % Remove all jobs (m = 1, i.e., state 0): P(removal >= n-1)
455 % = 1 - CDF(n-2) = 1 - sum_{j=0}^{n-2} P(removal = j)
458 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
460 probAtLeastN = 1 - cdfNMinus1;
462 Pb(n, 1) = Pb(n, 1) + probAtLeastN;
469 % DEFAULT: Remove exactly 1 job (original behavior)
470 % Empty queue: no effect (state 1 stays at state 1)
472 % Non-empty queues: decrement (n -> n-1)
473 for n = 2:(N(p_passive) - 1)
476 % Boundary at max capacity: decrement
478 Pb(N(p_passive), N(p_passive) - 1) = 1;
482 % POSITIVE: Normal job arrival at destination
483 for n = 1:(N(p_passive) - 1)
486 % Boundary: at max capacity
487 Pb(N(p_passive), N(p_passive)) = 1;
494function L = build_local_rates(sn, ist, r, Np, rt, sourceStations, sinkNodes, K)
495% Build local/hidden transition matrix for process at station ist, class r
496% Note: sinkNodes contains node indices (not station indices) for Sink nodes
500% External arrivals from source - separate positive, negative (single), batch, and catastrophe
501lambda_ir_pos = 0; % Positive arrivals
502lambda_ir_neg_single = 0; % Negative arrivals with single removal (default)
503lambda_ir_catastrophe = 0; % Catastrophe arrivals (remove all)
504% Batch removal arrivals: cell array of {rate, distribution} pairs
507for isrc = sourceStations
509 % Check if source class s_src is a signal
510 isSignal = sn.issignal(s_src);
513 % For signals: they route to themselves (Signal -> Signal), but their effect
514 % is on positive customers at the destination station. We check if the signal
515 % routes to ANY class at this station (not just class r).
518 prob_src = prob_src + rt((isrc-1)*K + s_src, (ist-1)*K + s_dst);
521 % For regular classes: direct routing to (ist, r)
522 prob_src = rt((isrc-1)*K + s_src, (ist-1)*K + r);
525 if prob_src > 0 && ~isnan(sn.rates(isrc, s_src))
526 srcRate = sn.rates(isrc, s_src);
527 % Check if source class s_src is a negative or catastrophe signal
528 if isSignal && ~isnan(sn.signaltype{s_src}) && ...
529 (sn.signaltype{s_src} == SignalType.NEGATIVE || sn.signaltype{s_src} == SignalType.CATASTROPHE)
530 % Check if it's a catastrophe (either via isCatastrophe flag or signaltype)
531 isCat = (isfield(sn,
'isCatastrophe') && ~isempty(sn.isCatastrophe) && sn.isCatastrophe(s_src)) || ...
532 sn.signaltype{s_src} == SignalType.CATASTROPHE;
534 lambda_ir_catastrophe = lambda_ir_catastrophe + srcRate * prob_src;
536 % Check
if it has a removal distribution
538 if isfield(sn,
'signalRemovalDist') && ~isempty(sn.signalRemovalDist) && s_src <= length(sn.signalRemovalDist)
539 removalDist = sn.signalRemovalDist{s_src};
541 if ~isempty(removalDist)
542 batchArrivals{end+1} = {srcRate * prob_src, removalDist};
544 lambda_ir_neg_single = lambda_ir_neg_single + srcRate * prob_src;
548 lambda_ir_pos = lambda_ir_pos + srcRate * prob_src;
554% Positive arrival transitions: n -> n+1
557 L(n, n+1) = lambda_ir_pos;
561% Catastrophe arrival transitions: n -> 1 (
for all n > 1)
562if lambda_ir_catastrophe > 0
564 L(n, 1) = L(n, 1) + lambda_ir_catastrophe;
568% Batch removal arrival transitions: n -> m at rate λ *
P(remove n-m)
for m < n
569for b = 1:length(batchArrivals)
570 rate = batchArrivals{b}{1};
571 dist = batchArrivals{b}{2};
574 k = n - m; % Number of jobs to remove
576 % Remove exactly k jobs:
P(removal = k)
577 prob = dist.evalPMF(k);
579 % Remove all jobs (m = 1):
P(removal >= n-1)
582 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
584 prob = 1 - cdfNMinus1;
587 L(n, m) = L(n, m) + rate * prob;
593% Single removal negative arrival transitions: n -> n-1 (only
if queue non-empty)
594if lambda_ir_neg_single > 0
596 L(n, n-1) = L(n, n-1) + lambda_ir_neg_single;
600% Service rate at
this station
601mu_ir = sn.rates(ist, r);
602if ~isnan(mu_ir) && mu_ir > 0
603 % Departures to sink (use rtnodes with node indices)
604 % Get the node index
for this station
605 nodeIdx = sn.stationToNode(ist);
608 if isfield(sn,
'rtnodes') && ~isempty(sn.rtnodes)
609 nNodes = length(sn.nodetype);
612 % rtnodes indices: (nodeIdx-1)*K + classIdx
613 fromIdx = (nodeIdx - 1) * K + r;
614 toIdx = (jsnk - 1) * K + s;
615 if fromIdx <= size(sn.rtnodes, 1) && toIdx <= size(sn.rtnodes, 2)
616 prob_sink = prob_sink + sn.rtnodes(fromIdx, toIdx);
622 % Self-routing (stays at same station, same class)
623 prob_self = rt((ist-1)*K + r, (ist-1)*K + r);
625 % Combined local departure rate
626 local_departure_rate = mu_ir * prob_sink;
628 % Departure transitions: n -> n-1 (add to existing negative arrival effects)
630 L(n, n-1) = L(n, n-1) + local_departure_rate;
633 % Self-service transitions: n -> n (diagonal, for phase transitions)
635 L(n, n) = mu_ir * prob_self;
641function [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N)
642% RCAT_METRICS Convert RCAT solution to LINE performance metrics
652% Compute metrics for each (station, class) pair
655 p = processMap(ist, r);
656 if p > 0 && ~isempty(pi) && p <= length(pi) && ~isempty(pi{p})
659 % Queue length: E[N] = sum_{n=0}^{Np-1} n * pi(n+1)
660 QN(ist, r) = (0:(Np-1)) * pi{p}(:);
662 % Utilization:
P(N > 0) = 1 - pi(0) = 1 - pi{p}(1)
663 UN(ist, r) = 1 - pi{p}(1);
665 % Throughput: compute from utilization and service rate
666 mu_ir = sn.rates(ist, r);
667 if ~isnan(mu_ir) && mu_ir > 0
668 TN(ist, r) = mu_ir * UN(ist, r);
674% Handle self-looping
classes: they always stay at their reference station
675% and share the server with other
classes under PS scheduling.
676% Only
override if the method did not already compute SLC metrics (QN == 0).
677if isfield(sn,
'isslc') && any(sn.isslc)
680 refst = sn.refstat(r);
681 if refst > 0 && refst <= M && QN(refst, r) == 0
682 % Self-looping class: all jobs stay at reference station
683 QN(refst, r) = sn.njobs(r);
685 % Service rate for this class
686 mu_ir = sn.rates(refst, r);
687 if ~isnan(mu_ir) && mu_ir > 0
688 nservers = sn.nservers(refst);
690 % Delay (infinite server): no capacity constraint,
691 % each job gets dedicated service
692 UN(refst, r) = QN(refst, r);
693 TN(refst, r) = mu_ir * QN(refst, r);
695 % Queue (finite server): capacity constraint applies
696 % Get utilization from other
classes at this station
699 if s ~= r && ~sn.isslc(s)
700 other_util = other_util + UN(refst, s);
704 % Remaining capacity
is shared with SLC
705 remaining_capacity = max(0, 1 - other_util);
707 % SLC utilization: min(demand, remaining capacity)
708 slc_demand = QN(refst, r) / mu_ir;
709 UN(refst, r) = min(slc_demand, remaining_capacity);
710 TN(refst, r) = mu_ir * UN(refst, r);
718% Response times from Little's law: R = Q / T
722 RN(ist, r) = QN(ist, r) / TN(ist, r);
733% For open
classes: system throughput = arrival rate, system response time = sum of response times
735 if sn.njobs(r) >= Inf % Open class
736 % System throughput equals arrival rate (from source)
738 nodeIdx = sn.stationToNode(ist);
739 if sn.nodetype(nodeIdx) == NodeType.Source
740 XN(r) = sn.rates(ist, r);
744 % System response time = sum over all stations
745 CN(r) = sum(RN(:, r));
747 % Closed class: use reference station
748 refst = sn.refstat(r);
749 if refst > 0 && refst <= M
750 XN(r) = TN(refst, r);
752 CN(r) = sn.njobs(r) / XN(r);
760function pi = birth_death_solve(Q)
761% BIRTH_DEATH_SOLVE Solve equilibrium of a birth-death (tridiagonal) CTMC
763% For a birth-death chain with birth rate lambda_n = Q(n, n+1) and
764% death rate mu_n = Q(n, n-1), the equilibrium
is computed using the
765% recursion pi(n) = pi(n-1) * lambda(n-1) / mu(n).
767% This
is numerically stable and avoids the ill-conditioned linear system
768% that plagues null-space methods for large state spaces.
780 birth_rate = Q(i-1, i);
781 death_rate = Q(i, i-1);
783 pi(i) = pi(i-1) * birth_rate / death_rate;
798function result = is_tridiagonal(Q)
799% IS_TRIDIAGONAL Check if a matrix
is tridiagonal
804 if abs(i - j) > 1 && abs(Q(i, j)) > 1e-14