1function [fesModel, fesStation, deaggInfo] = aggregateFES(model, stationSubset, options)
2% AGGREGATEFES Replace a station subset with a Flow-Equivalent Server (FES)
4% [fesModel, fesStation, deaggInfo] = AGGREGATEFES(model, stationSubset)
5% [fesModel, fesStation, deaggInfo] = AGGREGATEFES(model, stationSubset, options)
7% This function replaces a subset of stations in a closed product-form
8% queueing network with a single Flow-Equivalent Server (FES). The FES has
9% Limited Joint Dependence (LJD) service rates where
the rate
for class-c
10% in state (n1,...,nK) equals
the throughput of
class-c in an isolated
11% subnetwork consisting only of
the subset stations.
14% model - Closed product-form Network model
15% stationSubset - Cell array of
Station objects to aggregate
16% options - Optional
struct with fields:
17% .solver - Solver for throughput computation ('mva' default)
18% .cutoffs - Per-class population cutoffs (default: njobs per class)
19% .verbose - Enable verbose output (default: false)
22% fesModel - New Network with FES replacing
the subset
23% fesStation - Reference to
the FES Queue station
24% deaggInfo - Struct containing:
25% .originalModel - Original model reference
26% .stationSubset - Original subset stations
27% .subsetIndices - Original station indices
28% .throughputTable - Computed throughputs for all states
29% .cutoffs - Per-class cutoffs used
30% .stochCompSubset - Stochastic complement for subset
31% .stochCompComplement - Stochastic complement for complement
32% .isolatedModel - Isolated subnetwork model
35% model = Network('Example');
36% % ... create stations and classes ...
37% [fesModel, fesStation, info] = ModelAdapter.aggregateFES(model, {queue2, queue3});
38% solver = SolverMVA(fesModel);
39% avgTable = solver.getAvgTable();
41% Copyright (c) 2012-2026, Imperial College London
44%% Input validation and defaults
48if ~isfield(options, 'solver')
49 options.solver = 'mva';
51if ~isfield(options, 'cutoffs')
54if ~isfield(options, 'verbose')
55 options.verbose = false;
58%% Get network structure
59sn = model.getStruct();
63% Get station indices for subset
64modelNodes = model.getNodes();
65origStationIdxs = model.getStationIndexes();
66modelStations = modelNodes(origStationIdxs);
67subsetIndices = zeros(1, length(stationSubset));
68for i = 1:length(stationSubset)
70 if stationSubset{i} == modelStations{j}
77% Validate inputs using sn struct and indices
78[isValid, errorMsg] = fes_validate(sn, subsetIndices);
80 line_error(mfilename, errorMsg);
83% Get complement indices (stations not in subset)
85complementIndices = setdiff(allIndices, subsetIndices);
87% Set cutoffs if not provided
88if isempty(options.cutoffs)
89 % Default: use total jobs per class
90 options.cutoffs = sn.njobs';
92cutoffs = options.cutoffs;
95 fprintf('FES Aggregation: %d subset stations, %d complement stations, %d classes\n', ...
96 length(subsetIndices), length(complementIndices), K);
99%% Compute stochastic complement routing matrices
100% The routing matrix sn.rt is indexed by (stateful_node-1)*K + class
101% We need to partition it by stations
103% Build index sets for rt matrix
106 isf = sn.stationToStateful(i);
107 subsetRtIndices = [subsetRtIndices, ((isf-1)*K + 1):(isf*K)];
110complementRtIndices = [];
111for i = complementIndices
112 isf = sn.stationToStateful(i);
113 complementRtIndices = [complementRtIndices, ((isf-1)*K + 1):(isf*K)];
116% Compute stochastic complement for subset (routing within subset only)
117% S = P11 + P12 * inv(I - P22) * P21
119stochCompSubset = dtmc_stochcomp(rt, subsetRtIndices);
121% Compute stochastic complement for complement
122stochCompComplement = dtmc_stochcomp(rt, complementRtIndices);
125 fprintf('Stochastic complements computed: subset (%dx%d), complement (%dx%d)\n', ...
126 size(stochCompSubset, 1), size(stochCompSubset, 2), ...
127 size(stochCompComplement, 1), size(stochCompComplement, 2));
130%% Build isolated subnetwork data and compute throughputs
131[L_iso, mi_iso, visits_iso, isDelay_iso] = fes_build_isolated(sn, subsetIndices, stochCompSubset);
134 fprintf('Isolated subnetwork data extracted for %d stations.\n', length(subsetIndices));
137% Compute throughputs for all population states
138scalingTable = fes_compute_throughputs(L_iso, mi_iso, isDelay_iso, cutoffs, options);
140% Norton composite rate: the FES service rate must be the subnetwork DEPARTURE
141% rate, not the isolated-subnet throughput XN returned above. By Kritzinger et
142% al. (1982), Eq. 4.2, tau_r(n) = XN_r(n) * sum_{j in subset} xi_jr * P_r(j->comp),
143% where xi_jr are the visit ratios embedded in the isolated demands
144% (xi_jr = L_iso(j,r)*rate(j,r)) and P_r(j->comp) is the routing probability from
145% subset station j to the complement. The escape factor is population-independent,
146% so it rescales each per-class table by a scalar. Omitting it is exact only when
147% the subset has a single exit station (escape=1); it is required for subsets with
148% multiple exit points (e.g. multi-entry subsets whose stations all exit).
151 for a = 1:length(subsetIndices)
152 j = subsetIndices(a);
153 Vjr = L_iso(a, r) * sn.rates(j, r); % visit ratio used in the demand
154 isf_j = sn.stationToStateful(j);
156 for i = complementIndices
157 isf_i = sn.stationToStateful(i);
158 pexit = pexit + rt((isf_j-1)*K + r, (isf_i-1)*K + r);
161 escape(r) = escape(r) + Vjr * pexit;
164 if escape(r) > GlobalConstants.FineTol
165 scalingTable{r} = scalingTable{r} * escape(r);
170 fprintf('Throughput table computed for %d states.\n', prod(cutoffs + 1));
171 fprintf('Norton escape factors per class: %s\n', mat2str(escape, 4));
174%% Create the FES model
175fesModel = Network(sprintf('%s_FES', model.getName()));
177% Copy complement stations to new model
178nodeMap = cell(sn.nnodes, 1);
179stationMap = cell(M, 1);
181for i = complementIndices
182 origStation = modelStations{i};
183 nodeIdx = sn.stationToNode(i);
185 switch class(origStation)
187 newStation = Queue(fesModel, origStation.name, origStation.schedStrategy);
188 if ~isinf(origStation.numberOfServers)
189 newStation.setNumberOfServers(origStation.numberOfServers);
191 if ~isempty(origStation.cap) && isfinite(origStation.cap)
192 newStation.setCapacity(origStation.cap);
195 newStation = Delay(fesModel, origStation.name);
197 line_error(mfilename, sprintf('Unsupported station type %s.', class(origStation)));
200 nodeMap{nodeIdx} = newStation;
201 stationMap{i} = newStation;
204% Create the FES station (single Queue with PS scheduling)
205fesStation = Queue(fesModel, 'FES', SchedStrategy.PS);
206fesStation.setNumberOfServers(1);
209newClasses = cell(1, K);
210modelClasses = model.classes;
212% Choose reference station (FES or first complement station)
213if ~isempty(complementIndices)
214 refStation = stationMap{complementIndices(1)};
216 refStation = fesStation;
220 origClass = modelClasses{k};
221 population = sn.njobs(k);
222 newClass = ClosedClass(fesModel, origClass.name, population, refStation);
223 newClasses{k} = newClass;
226% Set service distributions for complement stations
227for i = complementIndices
228 newStation = stationMap{i};
231 origPH = sn.proc{i}{k};
233 if isempty(origPH) || (iscell(origPH) && isempty(origPH{1})) || ...
234 (iscell(origPH) && all(isnan(origPH{1}(:))))
235 newStation.setService(newClasses{k}, Disabled.getInstance());
238 T_matrix = origPH{1}; % Sub-generator (diagonal is -rate)
239 t0_vector = origPH{2}; % Exit rate vector
240 nPhases = size(T_matrix, 1);
243 rate = -T_matrix(1,1);
244 newStation.setService(newClasses{k}, Exp(rate));
246 alpha = ones(1, nPhases) / nPhases;
247 dist = APH(alpha, T_matrix);
248 newStation.setService(newClasses{k}, dist);
251 newStation.setService(newClasses{k}, Exp(sn.rates(i, k)));
257% Set the class dependence on the FES from per-class throughput scaling tables
258% The scalingTable{k} contains throughputs for class k at each population state
260% For multi-class FES, we use a class-dependence handle (cdscaling) which allows
261% each class to have its own state-dependent scaling factor.
262% The FES service rate for class c in state (n1,...,nK) equals throughput_c(n).
264% Set base service rate (will be scaled by the class dependence)
265% Use Exp(1.0) as base; the class-dependent scaling provides the actual throughput rate
267 fesStation.setService(newClasses{k}, Exp(1.0));
270% Handle zeros in scaling tables (replace with small positive value)
272 scalingTable{k}(scalingTable{k} < GlobalConstants.FineTol) = GlobalConstants.FineTol;
276 fprintf('Per-class scaling tables:\n');
278 fprintf(' Class %d: Max = %.6f, Min = %.6f\n', k, ...
279 max(scalingTable{k}), min(scalingTable{k}(scalingTable{k} > GlobalConstants.FineTol)));
283% Install the FES rates as a per-class class-dependence function
284% beta_{i,r}(n) = X_r(n), i.e. Sauer's chain-dependent service rate mu_{r,i}(n)
285% (Sauer 1983, eq. (40)). scalingTable is a cell {1 x K} of linearized
286% throughput vectors; wrap it as a single handle of the per-class population
287% vector returning the length-K vector of per-class rates, with the population
288% clamped to the cutoffs the table was built on.
289fesBetaHandle = fes_beta_handle(scalingTable, cutoffs);
290% Peak per-class FES rate = normalizer for Util = T*S/peak (the FES rates are
291% Sauer's chain-dependent service rates; their lattice peak plays the role of
292% the effective server count).
293fesPeak = cd_peak_scaling(fesBetaHandle, cutoffs, numel(cutoffs));
294fesStation.setLimitedClassDependence(fesBetaHandle, fesPeak);
296%% Build routing matrix for FES model
297I_fes = length(fesModel.nodes);
298P = fesModel.initRoutingMatrix();
303 if fesModel.nodes{n} == fesStation
309% Build node index mapping for complement stations
310complementNodeMap = containers.Map('KeyType', 'double', 'ValueType', 'double');
311for i = complementIndices
312 nodeIdx = sn.stationToNode(i);
313 if ~isempty(nodeMap{nodeIdx})
315 if fesModel.nodes{n} == nodeMap{nodeIdx}
316 complementNodeMap(i) = n;
323% Set routing probabilities
324% Routes within complement use stochastic complement
325% Routes to/from subset go through FES
327 fprintf('Setting up routing for FES model:\n');
328 fprintf(' FES node index: %d\n', fesNodeIdx);
329 fprintf(' Complement station indices: %s\n', mat2str(complementIndices));
330 fprintf(' Subset station indices: %s\n', mat2str(subsetIndices));
334 P_k = zeros(I_fes, I_fes);
336 % Exit-distribution weights = per-class visit ratios of the subset stations,
337 % computed by fes_build_isolated from the stochastic complement (stationary
338 % vector pi*S = pi of the class-k routing among subset stations). Weighting
339 % the FES -> complement split by these ratios (Kritzinger Eq. 4.6) makes the
340 % aggregation exact for closed product-form networks with an arbitrary number
341 % of subset exit points, not just single-exit/tandem subsets.
342 nSub = length(subsetIndices);
343 visitRatios = visits_iso(:, k)'; % 1 x nSub, class-k visit ratios
345 % Routes within complement (DIRECT paths only, not through subset)
346 for i = complementIndices
347 if ~isKey(complementNodeMap, i)
350 iNode = complementNodeMap(i);
351 isf_i = sn.stationToStateful(i);
353 for j = complementIndices
354 if ~isKey(complementNodeMap, j)
357 jNode = complementNodeMap(j);
358 isf_j = sn.stationToStateful(j);
360 % Use ORIGINAL routing for direct complement-to-complement paths
361 rtIdx_i = (isf_i - 1) * K + k;
362 rtIdx_j = (isf_j - 1) * K + k;
364 if rtIdx_i <= size(rt, 1) && rtIdx_j <= size(rt, 2)
365 prob = rt(rtIdx_i, rtIdx_j);
366 if prob > GlobalConstants.FineTol
367 P_k(iNode, jNode) = prob;
372 % Routes from complement to subset -> FES
373 for j = subsetIndices
374 isf_j = sn.stationToStateful(j);
375 rtIdx_i = (isf_i - 1) * K + k;
376 rtIdx_j = (isf_j - 1) * K + k;
378 if rtIdx_i <= size(rt, 1) && rtIdx_j <= size(rt, 2)
379 prob = rt(rtIdx_i, rtIdx_j);
380 if prob > GlobalConstants.FineTol
381 P_k(iNode, fesNodeIdx) = P_k(iNode, fesNodeIdx) + prob;
387 % Routes from FES to complement
388 % Weight by visit ratios within subset
389 for j = complementIndices
390 if ~isKey(complementNodeMap, j)
393 jNode = complementNodeMap(j);
394 isf_j = sn.stationToStateful(j);
398 i = subsetIndices(idx);
399 isf_i = sn.stationToStateful(i);
400 rtIdx_i = (isf_i - 1) * K + k;
401 rtIdx_j = (isf_j - 1) * K + k;
403 if rtIdx_i <= size(rt, 1) && rtIdx_j <= size(rt, 2)
404 prob = rt(rtIdx_i, rtIdx_j);
405 % Weight by visit ratio
406 probSum = probSum + visitRatios(idx) * prob;
410 if probSum > GlobalConstants.FineTol
411 P_k(fesNodeIdx, jNode) = probSum;
415 % No explicit self-loop on FES - internal routing is captured by LJD rates
416 % (the state-dependent throughput already accounts for internal circulation)
420 rowSum = sum(P_k(n, :));
421 if rowSum > GlobalConstants.FineTol
422 P_k(n, :) = P_k(n, :) / rowSum;
429 fprintf('Routing matrix for class %d:\n', k);
430 nodeNames = cell(1, I_fes);
432 nodeNames{n} = fesModel.nodes{n}.name;
434 fprintf(' Nodes: %s\n', strjoin(nodeNames, ', '));
436 fprintf(' %s: %s\n', nodeNames{i}, mat2str(P_k(i,:), 4));
444%% Build deaggregation info
446deaggInfo.originalModel = model;
447deaggInfo.stationSubset = stationSubset;
448deaggInfo.subsetIndices = subsetIndices;
449deaggInfo.complementIndices = complementIndices;
450deaggInfo.throughputTable = scalingTable;
451deaggInfo.cutoffs = cutoffs;
452deaggInfo.stochCompSubset = stochCompSubset;
453deaggInfo.stochCompComplement = stochCompComplement;
454deaggInfo.isolatedDemands = L_iso;
455deaggInfo.isolatedServers = mi_iso;
456deaggInfo.isolatedVisits = visits_iso;
457deaggInfo.isolatedIsDelay = isDelay_iso;
458deaggInfo.fesNodeIdx = fesNodeIdx;
461 fprintf('FES model created with %d stations (1 FES + %d complement).\n', ...
462 I_fes, length(complementIndices));