1classdef Posterior < EnsembleSolver
2 % Posterior Solver wrapper
for models with Prior distributions
4 % Posterior detects Prior distributions in a model, expands the model
5 % into a family of concrete networks (one per Prior alternative), solves
6 % each
using the specified solver, and aggregates results
using prior
7 % probabilities as weights.
9 % @brief Solver wrapper
for Bayesian-style uncertainty analysis
11 % Key characteristics:
12 % - Detects and expands Prior distributions in models
13 % - Orchestrates multiple solver runs
14 % - Aggregates results with prior-weighted expectations
15 % - Provides posterior distribution access
19 % model = Network(
'UncertainService');
20 % source = Source(model,
'Source');
21 % queue = Queue(model,
'Queue', SchedStrategy.FCFS);
22 % sink = Sink(model,
'Sink');
24 %
class = OpenClass(model, 'Jobs');
25 % source.setArrival(
class, Exp(1.0));
26 % queue.setService(
class, Prior({Exp(1), Exp(2)}, [0.5, 0.5]));
28 % model.link(model.serialRouting(source, queue, sink));
30 % post = Posterior(model, @SolverMVA);
31 % avgTable = post.getAvgTable(); % Prior-weighted expectations
32 % postTable = post.getPosteriorTable(); % Per-alternative results
33 % postDist = post.getPosteriorDist(
'R', queue,
class); % Response time distribution
36 % Copyright (c) 2012-2026, Imperial College London
37 % All rights reserved.
40 solverFactory; % Function handle to create solvers: @(model) SolverXXX(model)
41 priorInfo; % Structure with Prior detection info
42 aggregatedResult; % Prior-weighted aggregate metrics
43 originalModel; % Reference to original model with Prior
47 function self = Posterior(model, solverFactory, varargin)
48 % POSTERIOR Create a Posterior solver wrapper
50 % @brief Creates a Posterior wrapper
for uncertainty analysis
51 % @param model Network model (may contain Prior distributions)
52 % @param solverFactory Function handle: @(m) SolverXXX(m) or solver
class name
53 % @param varargin Optional solver options
54 % @
return self Posterior instance
56 self@EnsembleSolver(model, mfilename);
58 % Handle solver factory - accept class name or function handle
59 if isa(solverFactory, 'function_handle')
60 self.solverFactory = solverFactory;
61 elseif ischar(solverFactory) || isstring(solverFactory)
62 % Convert solver
class name to factory
63 className = char(solverFactory);
64 self.solverFactory = str2func([
'@(m) ', className,
'(m)']);
66 line_error(mfilename,
'solverFactory must be a function handle or solver class name');
70 self.setOptions(Solver.parseOptions(varargin, Posterior.defaultOptions));
72 self.setOptions(Posterior.defaultOptions);
79 self.aggregatedResult = [];
80 self.originalModel = model;
82 % Detect and validate Prior usage
86 function detectPriors(self)
87 % DETECTPRIORS Find Prior distributions in the model
89 % Scans all
nodes for Prior distributions and stores location info.
90 % Currently supports only a single Prior in the model.
94 nodes = model.getNodes();
97 for i = 1:length(
nodes)
100 % Check service distributions (Queue, Delay)
101 if isa(node,
'Queue') || isa(node,
'Delay')
104 dist = node.getService(
classes{c});
105 if ~isempty(dist) && isa(dist,
'Prior')
106 priors{end+1} = struct(...
107 'type', 'service', ...
109 'nodeName', node.name, ...
112 'className',
classes{c}.name, ...
117 % Service not set
for this class
122 % Check arrival distributions (Source)
123 if isa(node,
'Source')
125 if isa(
classes{c},
'OpenClass')
127 if ~isempty(node.arrivalProcess) && ...
128 length(node.arrivalProcess) >= 1 && ...
129 size(node.arrivalProcess, 2) >= c && ...
130 ~isempty(node.arrivalProcess{1, c})
131 dist = node.arrivalProcess{1, c};
132 if isa(dist,
'Prior')
133 priors{end+1} = struct(...
134 'type', 'arrival', ...
136 'nodeName', node.name, ...
139 'className',
classes{c}.name, ...
152 % Validate: currently only single Prior supported
153 if length(priors) > 1
154 line_error(mfilename,
'Currently only a single Prior distribution per model is supported');
160 self.priorInfo = priors{1};
164 function hasPrior = hasPriorDistribution(self)
165 % HASPRIOR = HASPRIORDISTRIBUTION()
166 % Return
true if model contains a Prior distribution
167 hasPrior = ~isempty(self.priorInfo);
170 function n = getNumAlternatives(self)
171 % N = GETNUMALTERNATIVES()
172 % Return number of Prior alternatives (1
if no Prior)
173 if isempty(self.priorInfo)
176 n = self.priorInfo.prior.getNumAlternatives();
180 function probs = getProbabilities(self)
181 % PROBS = GETPROBABILITIES()
182 % Return vector of prior probabilities
183 if isempty(self.priorInfo)
186 probs = self.priorInfo.prior.probabilities;
190 function E = getNumberOfModels(self)
191 % E = GETNUMBEROFMODELS()
192 % Return number of ensemble models (alternatives)
194 % Overrides EnsembleSolver to return count based on Prior,
195 % since ensemble
is not populated until init().
196 if ~isempty(self.ensemble)
197 E = length(self.ensemble);
199 E = self.getNumAlternatives();
203 %% EnsembleSolver abstract method implementations
206 % INIT Initialize the Posterior solver
208 % Expands the model into a family of concrete models,
209 % one for each Prior alternative.
211 line_debug('Posterior init: expanding model into %d alternatives', self.getNumAlternatives());
212 if isempty(self.priorInfo)
213 % No Prior - just use the original model
214 self.ensemble = {self.model};
215 self.solvers{1} = self.solverFactory(self.model);
219 prior = self.priorInfo.prior;
220 n = prior.getNumAlternatives();
221 self.ensemble = cell(1, n);
222 self.solvers = cell(1, n);
225 % Deep copy the model
226 modelCopy = self.originalModel.copy();
228 % Get the corresponding node and
class in the copy
229 nodes = modelCopy.getNodes();
230 classes = modelCopy.getClasses();
231 node =
nodes{self.priorInfo.nodeIdx};
234 % Replace Prior with concrete alternative
235 concreteDist = prior.getAlternative(i);
237 if strcmp(self.priorInfo.type,
'service')
238 node.setService(class, concreteDist);
239 elseif strcmp(self.priorInfo.type, 'arrival')
240 node.setArrival(class, concreteDist);
243 % Rename model to indicate alternative
244 modelCopy.name = sprintf('%s_alt%d', self.originalModel.name, i);
246 self.ensemble{i} = modelCopy;
247 self.solvers{i} = self.solverFactory(modelCopy);
251 function pre(self, it)
252 % PRE Pre-iteration operations (no-op for Posterior)
253 % Posterior only needs a single iteration
256 function [result, runtime] = analyze(self, it, e)
257 % ANALYZE Run solver for ensemble model e
259 % @param it Iteration number
260 % @param e Ensemble model index
261 % @return result Solver result structure
262 % @return runtime Solver execution time
265 solver = self.solvers{e};
266 solver.runAnalyzer();
267 result = solver.result;
271 function post(self, it)
272 % POST Post-iteration operations
274 % Aggregates results from all ensemble models using prior weights.
276 self.aggregateResults();
279 function finish(self)
280 % FINISH Finalization (no-op
for Posterior)
283 function
bool = converged(self, it)
284 % CONVERGED Check convergence
286 % Posterior converges after the first iteration (it >= 1).
290 function [QN, UN, RN, TN, AN, WN] = getEnsembleAvg(self)
291 % GETENSEMBLEAVG Get per-model average metrics
293 % Returns cell arrays with metrics from each ensemble model.
295 n = self.getNumberOfModels();
304 if ~isempty(self.results) && size(self.results, 2) >= e && ~isempty(self.results{1, e})
305 res = self.results{1, e};
306 if isfield(res,
'Avg')
307 if isfield(res.Avg, 'Q'), QN{e} = res.Avg.Q; end
308 if isfield(res.Avg,
'U'), UN{e} = res.Avg.U; end
309 if isfield(res.Avg,
'R'), RN{e} = res.Avg.R; end
310 if isfield(res.Avg,
'T'), TN{e} = res.Avg.T; end
311 if isfield(res.Avg,
'A'), AN{e} = res.Avg.A; end
312 if isfield(res.Avg,
'W'), WN{e} = res.Avg.W; end
318 %% Result aggregation methods
320 function aggregateResults(self)
321 % AGGREGATERESULTS Compute prior-weighted aggregate metrics
323 if isempty(self.results)
327 n = self.getNumberOfModels();
328 probs = self.getProbabilities();
330 % Initialize aggregated result
331 self.aggregatedResult =
struct();
332 self.aggregatedResult.solver =
'Posterior';
333 self.aggregatedResult.Avg =
struct();
335 % Get dimensions from first result
338 if ~isempty(self.results{1, e})
339 firstResult = self.results{1, e};
344 if isempty(firstResult) || ~isfield(firstResult,
'Avg')
348 % Aggregate each metric field
349 fields = {
'Q',
'U',
'R',
'T',
'A',
'W',
'C',
'X'};
350 for f = 1:length(fields)
352 if isfield(firstResult.Avg, fname) && ~isempty(firstResult.Avg.(fname))
353 self.aggregatedResult.Avg.(fname) = zeros(size(firstResult.Avg.(fname)));
355 if ~isempty(self.results{1, e}) && ...
356 isfield(self.results{1, e},
'Avg') && ...
357 isfield(self.results{1, e}.Avg, fname) && ...
358 ~isempty(self.results{1, e}.Avg.(fname))
359 self.aggregatedResult.Avg.(fname) = self.aggregatedResult.Avg.(fname) + ...
360 probs(e) * self.results{1, e}.Avg.(fname);
367 %% Override NetworkSolver methods
for aggregated results
369 function [QN, UN, RN, TN, AN, WN] = getAvg(self, varargin)
370 % GETAVG Return prior-weighted average metrics
372 % Returns aggregated metrics weighted by prior probabilities.
374 % Run solver if not done
375 if isempty(self.results)
379 QN = []; UN = []; RN = []; TN = []; AN = []; WN = [];
380 if ~isempty(self.aggregatedResult) && isfield(self.aggregatedResult,
'Avg')
381 if isfield(self.aggregatedResult.Avg, 'Q'), QN = self.aggregatedResult.Avg.Q; end
382 if isfield(self.aggregatedResult.Avg, 'U'), UN = self.aggregatedResult.Avg.U; end
383 if isfield(self.aggregatedResult.Avg, 'R'), RN = self.aggregatedResult.Avg.R; end
384 if isfield(self.aggregatedResult.Avg, 'T'), TN = self.aggregatedResult.Avg.T; end
385 if isfield(self.aggregatedResult.Avg, 'A'), AN = self.aggregatedResult.Avg.A; end
386 if isfield(self.aggregatedResult.Avg, 'W'), WN = self.aggregatedResult.Avg.W; end
390 function AvgTable = getAvgTable(self, varargin)
391 % GETAVGTABLE Return prior-weighted average table
393 % Returns a table of aggregated metrics weighted by prior probabilities.
395 % Run solver if not done
396 if isempty(self.results)
400 [QN, UN, RN, TN, AN, WN] = self.getAvg();
402 % Get model structure
403 sn = self.originalModel.getStruct(false);
419 Q_val = 0; U_val = 0; R_val = 0; T_val = 0; A_val = 0; W_val = 0;
421 if ~isempty(QN), Q_val = QN(ist, k); end
422 if ~isempty(UN), U_val = UN(ist, k); end
423 if ~isempty(RN), R_val = RN(ist, k); end
424 if ~isempty(TN), T_val = TN(ist, k); end
425 if ~isempty(AN), A_val = AN(ist, k); end
426 if ~isempty(WN), W_val = WN(ist, k); end
428 % Only include rows with non-zero values
429 if Q_val > 0 || U_val > 0 || T_val > 0
430 Station{end+1, 1} = sn.nodenames{sn.stationToNode(ist)};
431 JobClass{end+1, 1} = sn.classnames{k};
432 QLen(end+1, 1) = Q_val;
433 Util(end+1, 1) = U_val;
434 RespT(end+1, 1) = R_val;
435 ResidT(end+1, 1) = W_val;
436 ArvR(end+1, 1) = A_val;
437 Tput(end+1, 1) = T_val;
447 Station = categorical(Station);
448 JobClass = categorical(JobClass);
450 AvgTable = table(Station, JobClass, QLen, Util, RespT, ResidT, ArvR, Tput);
453 %% Posterior-specific result methods
455 function ptable = getPosteriorTable(self)
456 % GETPOSTERIORTABLE Return table with per-alternative results
458 % Returns a table showing metrics for each Prior alternative
459 % along with its probability.
461 % Run solver if not done
462 if isempty(self.results)
466 probs = self.getProbabilities();
467 n = self.getNumberOfModels();
468 sn = self.originalModel.getStruct(
false);
483 if isempty(self.results{1, alt})
486 res = self.results{1, alt};
487 if ~isfield(res,
'Avg')
493 Q_val = 0; U_val = 0; R_val = 0; T_val = 0;
495 if isfield(res.Avg, 'Q') && ~isempty(res.Avg.Q)
496 Q_val = res.Avg.Q(ist, k);
498 if isfield(res.Avg, 'U') && ~isempty(res.Avg.U)
499 U_val = res.Avg.U(ist, k);
501 if isfield(res.Avg, 'R') && ~isempty(res.Avg.R)
502 R_val = res.Avg.R(ist, k);
504 if isfield(res.Avg, 'T') && ~isempty(res.Avg.T)
505 T_val = res.Avg.T(ist, k);
508 % Only include rows with non-zero values
509 if Q_val > 0 || U_val > 0 || T_val > 0
510 Alternative(end+1, 1) = alt;
511 Probability(end+1, 1) = probs(alt);
512 Station{end+1, 1} = sn.nodenames{sn.stationToNode(ist)};
513 JobClass{end+1, 1} = sn.classnames{k};
514 QLen(end+1, 1) = Q_val;
515 Util(end+1, 1) = U_val;
516 RespT(end+1, 1) = R_val;
517 Tput(end+1, 1) = T_val;
523 if isempty(Alternative)
528 Station = categorical(Station);
529 JobClass = categorical(JobClass);
531 ptable = table(Alternative, Probability, Station, JobClass, QLen, Util, RespT, Tput);
534 function empDist = getPosteriorDist(self, metric, station,
class)
535 % GETPOSTERIORDIST Return empirical distribution of a metric
537 % Returns an EmpiricalCDF
object representing the posterior
538 % distribution of the specified metric across Prior alternatives.
540 % @param metric Metric name:
'Q',
'U',
'R',
'T',
'A',
'W'
541 % @param station Station node or station index
542 % @param
class JobClass object or class index
543 % @
return empDist EmpiricalCDF
object
545 % Run solver
if not done
546 if isempty(self.results)
550 probs = self.getProbabilities();
551 n = self.getNumberOfModels();
554 if isnumeric(station)
556 elseif isa(station, 'Station')
557 ist = station.stationIndex;
559 line_error(mfilename, 'station must be a Station
object or numeric index');
565 elseif isa(class, 'JobClass')
568 line_error(mfilename, 'class must be a JobClass
object or numeric index');
571 % Extract metric values for each alternative
572 values = zeros(n, 1);
574 if ~isempty(self.results{1, e}) && isfield(self.results{1, e},
'Avg')
575 res = self.results{1, e}.Avg;
576 if isfield(res, metric) && ~isempty(res.(metric))
577 values(e) = res.(metric)(ist, k);
586 % Build empirical CDF
587 % Sort values and corresponding probabilities
588 [sortedVals, sortIdx] = sort(values);
589 sortedProbs = probs(sortIdx);
590 cdfVals = cumsum(sortedProbs);
592 % Create data matrix
for EmpiricalCDF: [CDF_value, X_value]
593 cdfData = [cdfVals(:), sortedVals(:)];
595 % Create EmpiricalCDF
object
596 empDist = EmpiricalCDF(cdfData);
599 %% Required Solver abstract methods
601 function runtime = runAnalyzer(self, options)
602 % RUNANALYZER Run the Posterior analysis
604 % @param options Solver options (optional)
605 % @
return runtime Total runtime in seconds
608 if nargin >= 2 && ~isempty(options)
609 self.setOptions(options);
611 line_debug(
'Posterior solver starting: nalternatives=%d', self.getNumAlternatives());
612 line_debug(
'Default method: using Posterior ensemble analysis\n');
617 function sn = getStruct(self)
618 % GETSTRUCT Return model structure
620 % Returns the structure of the original model.
621 sn = self.originalModel.getStruct(
false);
626 function [allMethods] = listValidMethods(self)
627 % LISTVALIDMETHODS Return valid methods
628 allMethods = {
'default'};
633 function featSupported = getFeatureSet()
634 % GETFEATURESET Return supported features
635 featSupported = SolverFeatureSet;
636 featSupported.setTrue(
'Prior');
639 function [bool, featSupported] = supports(model)
640 % SUPPORTS Check
if model
is supported
642 featSupported = Posterior.getFeatureSet();
645 function options = defaultOptions()
646 % DEFAULTOPTIONS Return
default options
647 options = Solver.defaultOptions();
648 options.iter_max = 1; % Single iteration
for Posterior