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% 'exact' - Not available (autocat moved to line-legacy.git)
13%
14% Copyright (c) 2012-2025, Imperial College London
15% All rights reserved.
16
17M = sn.nstations;
18K = sn.nclasses;
19
20% Set default max states for truncation
21if isfield(options, 'config') && isfield(options.config, 'maxStates')
22 maxStates = options.config.maxStates;
23else
24 maxStates = 100;
25end
26
27% Set default tolerances
28if isfield(options, 'iter_tol') && ~isempty(options.iter_tol)
29 tol = options.iter_tol;
30else
31 tol = 1e-6;
32end
33
34if isfield(options, 'iter_max') && ~isempty(options.iter_max)
35 maxiter = options.iter_max;
36else
37 maxiter = 1000;
38end
39
40% Build RCAT model from network structure
41[R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates);
42
43% Check if we have a valid model
44numProcesses = max(processMap(:));
45numActions = size(AP, 1);
46
47% Return early only if no processes found
48if numProcesses == 0
49 line_warning(mfilename, 'Network could not be mapped to RCAT format (no processes found).\n');
50 QN = zeros(M, K);
51 UN = zeros(M, K);
52 RN = zeros(M, K);
53 TN = zeros(M, K);
54 CN = zeros(1, K);
55 XN = zeros(1, K);
56 iter = 0;
57 return;
58end
59
60% If no actions but we have processes, solve using local rates only
61% This handles single-queue G-networks (Source -> Queue -> Sink)
62if numActions == 0
63 % No inter-station actions: solve equilibrium using only L matrices
64 x = [];
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});
74 end
75 iter = 0;
76 [QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
77 return;
78end
79
80% Choose solver method
81method = options.method;
82if strcmp(method, 'default')
83 method = 'inap';
84end
85
86switch method
87 case 'inap'
88 % Fast iterative heuristic
89 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inap');
90
91 case 'inapplus'
92 % Improved INAP with weighted rates (no normalization)
93 [x, pi, Q, iter] = inap(R, AP, tol, maxiter, 'inapplus');
94
95 case 'exact'
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');
99
100 otherwise
101 line_error(mfilename, 'Unknown method: %s\n', method);
102end
103
104% Convert RCAT solution to LINE metrics
105[QN, UN, RN, TN, CN, XN] = rcat_metrics(sn, x, pi, Q, processMap, actionMap, N);
106
107end
108
109%% Local Functions
110
111function [x, pi, Q, iter] = inap(R, AP, tol, maxiter, method)
112% INAP Iterative Numerical Approximation Procedure for RCAT
113%
114% Methods:
115% 'inap': x(a) = mean(Aa(i,j) * pi(i) / pi(j))
116% 'inapplus': x(a) = sum(Aa(i,j) * pi(i))
117
118if nargin < 5 || isempty(method)
119 method = 'inap';
120end
121
122% Parse R and AP
123A = size(AP, 1);
124ACT = AP(:, 1);
125PSV = AP(:, 2);
126numProcesses = max(AP(:));
127
128% Extract rate matrices
129Aa = cell(1, A);
130Pb = cell(1, A);
131for a = 1:A
132 Aa{a} = R{a, 1};
133 Pb{a} = R{a, 2};
134end
135
136% Extract local rates
137L = cell(1, numProcesses);
138for k = 1:numProcesses
139 L{k} = R{A+1, k};
140end
141
142% Get state space sizes
143N = zeros(1, numProcesses);
144for k = 1:numProcesses
145 N(k) = size(L{k}, 1);
146end
147
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.
151x = (1:A)' / (A + 1);
152
153% Compute initial equilibrium
154[pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
155
156iter = 1;
157while iter <= maxiter
158 % Save old iteration result
159 piprev = pi;
160
161 % Update each action rate
162 for a = 1:A
163 k = ACT(a);
164
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
168 LAMBDA_sum = 0;
169 for i = 1:N(k)
170 for j = 1:N(k)
171 if Aa{a}(i,j) > 0
172 LAMBDA_sum = LAMBDA_sum + Aa{a}(i,j) * pi{k}(i);
173 end
174 end
175 end
176 if LAMBDA_sum > 0
177 x(a) = LAMBDA_sum;
178 end
179 else
180 % inap: LAMBDA(i,j) = Aa{a}(i,j) * pi{k}(i) / pi{k}(j)
181 % x(a) = mean(LAMBDA) for non-zero entries
182 LAMBDA_vec = [];
183 for i = 1:N(k)
184 for j = 1:N(k)
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);
187 end
188 end
189 end
190 if ~isempty(LAMBDA_vec)
191 x(a) = mean(LAMBDA_vec);
192 end
193 end
194 end
195
196 % Recompute equilibrium with new x
197 [pi, Q] = compute_equilibrium(x, Aa, Pb, L, ACT, PSV, numProcesses, A, N);
198
199 % Convergence check
200 maxErr = 0;
201 for k = 1:numProcesses
202 maxErr = max([maxErr, norm(pi{k} - piprev{k}, 1)]);
203 end
204
205 if maxErr < tol
206 return
207 end
208
209 iter = iter + 1;
210end
211
212end
213
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
216
217Q = cell(1, numProcesses);
218pi = cell(1, numProcesses);
219
220for k = 1:numProcesses
221 % Start with local/hidden rates
222 Qk = L{k} - diag(L{k} * ones(N(k), 1));
223
224 % Add contributions from each action
225 for c = 1:A
226 if PSV(c) == k
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));
229 elseif ACT(c) == k
230 % Process k is active for action c: add Aa{c}
231 Qk = Qk + Aa{c} - diag(Aa{c} * ones(N(k), 1));
232 end
233 end
234
235 % Convert to valid infinitesimal generator
236 Q{k} = ctmc_makeinfgen(Qk);
237
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});
243 else
244 pi{k} = ctmc_solve(Q{k});
245 end
246end
247
248end
249
250function [R, AP, processMap, actionMap, N] = build_rcat(sn, maxStates)
251% BUILD_RCAT Convert LINE network structure to RCAT format
252
253if nargin < 2
254 maxStates = 100;
255end
256
257M = sn.nstations;
258K = sn.nclasses;
259rt = sn.rt; % (M*K) x (M*K) routing table
260
261% Identify station types
262sourceStations = [];
263sinkStations = [];
264queueStations = [];
265
266for ist = 1:M
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;
272 else
273 % Queue, Delay, or other service stations
274 queueStations(end+1) = ist;
275 end
276end
277
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
281processIdx = 0;
282processMap = zeros(M, K);
283for ist = queueStations
284 for r = 1:K
285 % Skip Signal classes - they don't have their own queue state
286 if sn.issignal(r)
287 continue;
288 end
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;
293 end
294 end
295end
296numProcesses = processIdx;
297
298if numProcesses == 0
299 R = {};
300 AP = [];
301 actionMap = [];
302 N = [];
303 return;
304end
305
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)
310
311% Determine number of states for each process
312N = zeros(1, numProcesses);
313for p = 1:numProcesses
314 [ist, r] = find(processMap == p);
315 if ~isempty(ist)
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
319 else % Open class
320 N(p) = maxStates; % Truncate at maxStates
321 end
322 end
323end
324
325% Count actions: each routing transition (i,r) -> (j,s) where P > 0
326actionIdx = 0;
327actionMap = struct('from_station', {}, 'from_class', {}, ...
328 'to_station', {}, 'to_class', {}, 'prob', {}, ...
329 'isNegative', {}, 'isCatastrophe', {}, 'removalDistribution', {});
330
331for ist = queueStations
332 for r = 1:K
333 if processMap(ist, r) > 0
334 % Check if class r is a negative signal class
335 isNegativeClass = false;
336 isCatastropheClass = false;
337 removalDist = [];
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;
343 end
344 % Get removal distribution for this class
345 if isfield(sn, 'signalRemovalDist') && ~isempty(sn.signalRemovalDist) && r <= length(sn.signalRemovalDist)
346 removalDist = sn.signalRemovalDist{r};
347 end
348 end
349
350 for jst = queueStations
351 for s = 1:K
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;
366 end
367 end
368 end
369 end
370 end
371 end
372end
373numActions = actionIdx;
374
375% Initialize R and AP
376R = cell(numActions + 1, max(numProcesses, 2));
377if numActions > 0
378 AP = zeros(numActions, 2);
379else
380 AP = zeros(0, 2); % Empty matrix when no actions
381end
382
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)';
386if isempty(sinkNodes)
387 sinkNodes = []; % Ensure empty row vector, not column
388end
389
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);
395end
396
397% Build active and passive matrices for each action
398for a = 1:numActions
399 am = actionMap(a);
400
401 % Active process (departure)
402 ist = am.from_station;
403 r = am.from_class;
404 p_active = processMap(ist, r);
405 AP(a, 1) = p_active;
406
407 % Get service rate
408 mu_ir = sn.rates(ist, r);
409 prob = am.prob;
410
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;
415 end
416 % Boundary: at max capacity
417 Aa(N(p_active), N(p_active)) = mu_ir * prob;
418 R{a, 1} = Aa;
419
420 % Passive process (arrival or signal effect)
421 jst = am.to_station;
422 s = am.to_class;
423 p_passive = processMap(jst, s);
424 AP(a, 2) = p_passive;
425
426 Pb = zeros(N(p_passive));
427 if am.isNegative
428 % NEGATIVE: Job removal at destination (G-network negative customer)
429 if am.isCatastrophe
430 % CATASTROPHE: All jobs are removed - all states transition to 1 (empty)
431 for n = 1:N(p_passive)
432 Pb(n, 1) = 1;
433 end
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)
439 if n == 1
440 % Empty queue: no effect
441 Pb(1, 1) = 1;
442 else
443 % For each possible resulting state m (from 1 to n)
444 for m = 1: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)
447 if m > 1
448 % Remove exactly k jobs: P(removal = k)
449 prob = dist.evalPMF(k);
450 if prob > 0
451 Pb(n, m) = Pb(n, m) + prob;
452 end
453 else
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)
456 cdfNMinus1 = 0;
457 for j = 0:(n-2)
458 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
459 end
460 probAtLeastN = 1 - cdfNMinus1;
461 if probAtLeastN > 0
462 Pb(n, 1) = Pb(n, 1) + probAtLeastN;
463 end
464 end
465 end
466 end
467 end
468 else
469 % DEFAULT: Remove exactly 1 job (original behavior)
470 % Empty queue: no effect (state 1 stays at state 1)
471 Pb(1, 1) = 1;
472 % Non-empty queues: decrement (n -> n-1)
473 for n = 2:(N(p_passive) - 1)
474 Pb(n, n-1) = 1;
475 end
476 % Boundary at max capacity: decrement
477 if N(p_passive) > 1
478 Pb(N(p_passive), N(p_passive) - 1) = 1;
479 end
480 end
481 else
482 % POSITIVE: Normal job arrival at destination
483 for n = 1:(N(p_passive) - 1)
484 Pb(n, n+1) = 1;
485 end
486 % Boundary: at max capacity
487 Pb(N(p_passive), N(p_passive)) = 1;
488 end
489 R{a, 2} = Pb;
490end
491
492end
493
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
497
498L = zeros(Np);
499
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
505batchArrivals = {};
506
507for isrc = sourceStations
508 for s_src = 1:K
509 % Check if source class s_src is a signal
510 isSignal = sn.issignal(s_src);
511
512 if isSignal
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).
516 prob_src = 0;
517 for s_dst = 1:K
518 prob_src = prob_src + rt((isrc-1)*K + s_src, (ist-1)*K + s_dst);
519 end
520 else
521 % For regular classes: direct routing to (ist, r)
522 prob_src = rt((isrc-1)*K + s_src, (ist-1)*K + r);
523 end
524
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;
533 if isCat
534 lambda_ir_catastrophe = lambda_ir_catastrophe + srcRate * prob_src;
535 else
536 % Check if it has a removal distribution
537 removalDist = [];
538 if isfield(sn, 'signalRemovalDist') && ~isempty(sn.signalRemovalDist) && s_src <= length(sn.signalRemovalDist)
539 removalDist = sn.signalRemovalDist{s_src};
540 end
541 if ~isempty(removalDist)
542 batchArrivals{end+1} = {srcRate * prob_src, removalDist};
543 else
544 lambda_ir_neg_single = lambda_ir_neg_single + srcRate * prob_src;
545 end
546 end
547 else
548 lambda_ir_pos = lambda_ir_pos + srcRate * prob_src;
549 end
550 end
551 end
552end
553
554% Positive arrival transitions: n -> n+1
555if lambda_ir_pos > 0
556 for n = 1:(Np-1)
557 L(n, n+1) = lambda_ir_pos;
558 end
559end
560
561% Catastrophe arrival transitions: n -> 1 (for all n > 1)
562if lambda_ir_catastrophe > 0
563 for n = 2:Np
564 L(n, 1) = L(n, 1) + lambda_ir_catastrophe;
565 end
566end
567
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};
572 for n = 2:Np
573 for m = 1:n
574 k = n - m; % Number of jobs to remove
575 if m > 1
576 % Remove exactly k jobs: P(removal = k)
577 prob = dist.evalPMF(k);
578 else
579 % Remove all jobs (m = 1): P(removal >= n-1)
580 cdfNMinus1 = 0;
581 for j = 0:(n-2)
582 cdfNMinus1 = cdfNMinus1 + dist.evalPMF(j);
583 end
584 prob = 1 - cdfNMinus1;
585 end
586 if prob > 0
587 L(n, m) = L(n, m) + rate * prob;
588 end
589 end
590 end
591end
592
593% Single removal negative arrival transitions: n -> n-1 (only if queue non-empty)
594if lambda_ir_neg_single > 0
595 for n = 2:Np
596 L(n, n-1) = L(n, n-1) + lambda_ir_neg_single;
597 end
598end
599
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);
606
607 prob_sink = 0;
608 if isfield(sn, 'rtnodes') && ~isempty(sn.rtnodes)
609 nNodes = length(sn.nodetype);
610 for jsnk = sinkNodes
611 for s = 1:K
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);
617 end
618 end
619 end
620 end
621
622 % Self-routing (stays at same station, same class)
623 prob_self = rt((ist-1)*K + r, (ist-1)*K + r);
624
625 % Combined local departure rate
626 local_departure_rate = mu_ir * prob_sink;
627
628 % Departure transitions: n -> n-1 (add to existing negative arrival effects)
629 for n = 2:Np
630 L(n, n-1) = L(n, n-1) + local_departure_rate;
631 end
632
633 % Self-service transitions: n -> n (diagonal, for phase transitions)
634 for n = 2:Np
635 L(n, n) = mu_ir * prob_self;
636 end
637end
638
639end
640
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
643
644M = sn.nstations;
645K = sn.nclasses;
646
647QN = zeros(M, K);
648UN = zeros(M, K);
649RN = zeros(M, K);
650TN = zeros(M, K);
651
652% Compute metrics for each (station, class) pair
653for ist = 1:M
654 for r = 1:K
655 p = processMap(ist, r);
656 if p > 0 && ~isempty(pi) && p <= length(pi) && ~isempty(pi{p})
657 Np = length(pi{p});
658
659 % Queue length: E[N] = sum_{n=0}^{Np-1} n * pi(n+1)
660 QN(ist, r) = (0:(Np-1)) * pi{p}(:);
661
662 % Utilization: P(N > 0) = 1 - pi(0) = 1 - pi{p}(1)
663 UN(ist, r) = 1 - pi{p}(1);
664
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);
669 end
670 end
671 end
672end
673
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)
678 for r = 1:K
679 if sn.isslc(r)
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);
684
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);
689 if isinf(nservers)
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);
694 else
695 % Queue (finite server): capacity constraint applies
696 % Get utilization from other classes at this station
697 other_util = 0;
698 for s = 1:K
699 if s ~= r && ~sn.isslc(s)
700 other_util = other_util + UN(refst, s);
701 end
702 end
703
704 % Remaining capacity is shared with SLC
705 remaining_capacity = max(0, 1 - other_util);
706
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);
711 end
712 end
713 end
714 end
715 end
716end
717
718% Response times from Little's law: R = Q / T
719for ist = 1:M
720 for r = 1:K
721 if TN(ist, r) > 0
722 RN(ist, r) = QN(ist, r) / TN(ist, r);
723 else
724 RN(ist, r) = 0;
725 end
726 end
727end
728
729% System metrics
730CN = zeros(1, K);
731XN = zeros(1, K);
732
733% For open classes: system throughput = arrival rate, system response time = sum of response times
734for r = 1:K
735 if sn.njobs(r) >= Inf % Open class
736 % System throughput equals arrival rate (from source)
737 for ist = 1:M
738 nodeIdx = sn.stationToNode(ist);
739 if sn.nodetype(nodeIdx) == NodeType.Source
740 XN(r) = sn.rates(ist, r);
741 break;
742 end
743 end
744 % System response time = sum over all stations
745 CN(r) = sum(RN(:, r));
746 else
747 % Closed class: use reference station
748 refst = sn.refstat(r);
749 if refst > 0 && refst <= M
750 XN(r) = TN(refst, r);
751 if XN(r) > 0
752 CN(r) = sn.njobs(r) / XN(r);
753 end
754 end
755 end
756end
757
758end
759
760function pi = birth_death_solve(Q)
761% BIRTH_DEATH_SOLVE Solve equilibrium of a birth-death (tridiagonal) CTMC
762%
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).
766%
767% This is numerically stable and avoids the ill-conditioned linear system
768% that plagues null-space methods for large state spaces.
769
770n = size(Q, 1);
771if n <= 1
772 pi = 1;
773 return;
774end
775
776pi = zeros(1, n);
777pi(1) = 1.0;
778
779for i = 2:n
780 birth_rate = Q(i-1, i);
781 death_rate = Q(i, i-1);
782 if death_rate > 0
783 pi(i) = pi(i-1) * birth_rate / death_rate;
784 else
785 pi(i) = 0;
786 end
787end
788
789total = sum(pi);
790if total > 0
791 pi = pi / total;
792else
793 pi = ones(1, n) / n;
794end
795
796end
797
798function result = is_tridiagonal(Q)
799% IS_TRIDIAGONAL Check if a matrix is tridiagonal
800n = size(Q, 1);
801result = true;
802for i = 1:n
803 for j = 1:n
804 if abs(i - j) > 1 && abs(Q(i, j)) > 1e-14
805 result = false;
806 return;
807 end
808 end
809end
810end