1classdef UQ < EnsembleSolver
2 % UQ Solver wrapper
for models with Prior distributions
4 % UQ 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 = UQ(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 % Cap on
the tensor-product design size. A design point
is one full
41 % solver run, so this bounds
the cost of a quadrature design over
42 % several Priors; beyond it
the Monte Carlo design
is the right tool.
43 MaxDesignPoints = 4096;
47 solverFactory; % Function handle to create
solvers: @(model) SolverXXX(model)
48 priorInfo; % Struct array with Prior detection info, one entry per Prior
49 design; % Struct array of design points: .weight and .dists (one per Prior)
50 aggregatedResult; % Prior-weighted aggregate metrics
51 originalModel; % Reference to original model with Prior
55 function self = UQ(model, solverFactory, varargin)
56 % UQ Create a UQ solver wrapper
58 % @brief Creates a UQ wrapper
for uncertainty analysis
59 % @param model Network model (may contain Prior distributions)
60 % @param solverFactory Function handle: @(m) SolverXXX(m) or solver
class name
61 % @param varargin Optional solver options
62 % @
return self UQ instance
64 self@EnsembleSolver(model, mfilename);
66 % Handle solver factory - accept class name or function handle
67 if isa(solverFactory, 'function_handle')
68 self.solverFactory = solverFactory;
69 elseif ischar(solverFactory) || isstring(solverFactory)
70 % Convert solver
class name to factory
71 className = char(solverFactory);
72 self.solverFactory = str2func([
'@(m) ', className,
'(m)']);
74 line_error(mfilename,
'solverFactory must be a function handle or solver class name');
78 self.setOptions(Solver.parseOptions(varargin, UQ.defaultOptions));
80 self.setOptions(UQ.defaultOptions);
88 self.aggregatedResult = [];
89 self.originalModel = model;
91 % Detect and validate Prior usage
95 function detectPriors(self)
96 % DETECTPRIORS Find Prior distributions in
the model
98 % Scans all
nodes for Prior distributions and stores location info.
99 % Currently supports only a single Prior in
the model.
103 nodes = model.getNodes();
104 classes = model.getClasses();
106 for i = 1:length(
nodes)
109 % Check service distributions (Queue, Delay)
110 if isa(node,
'Queue') || isa(node,
'Delay')
111 for c = 1:length(classes)
113 dist = node.getService(classes{c});
114 if ~isempty(dist) && isa(dist,
'Prior')
115 priors{end+1} = struct(...
116 'type', 'service', ...
118 'nodeName', node.name, ...
120 'class', classes{c}, ...
121 'className', classes{c}.name, ...
126 % Service not set
for this class
131 % Check arrival distributions (Source)
132 if isa(node,
'Source')
133 for c = 1:length(classes)
134 if isa(classes{c},
'OpenClass')
136 if ~isempty(node.arrivalProcess) && ...
137 length(node.arrivalProcess) >= 1 && ...
138 size(node.arrivalProcess, 2) >= c && ...
139 ~isempty(node.arrivalProcess{1, c})
140 dist = node.arrivalProcess{1, c};
141 if isa(dist,
'Prior')
142 priors{end+1} = struct(...
143 'type', 'arrival', ...
145 'nodeName', node.name, ...
147 'class', classes{c}, ...
148 'className', classes{c}.name, ...
164 self.priorInfo = [priors{:}];
168 function buildDesign(self)
169 % BUILDDESIGN Reduce
the detected Priors to weighted design points
171 % Each design point assigns one concrete Distribution to every
172 % Prior in
the model and carries
the weight of that joint
173 % assignment. Discrete and quadrature designs take
the tensor
174 % product of
the per-Prior alternatives, so their weights are
the
175 % products of
the marginal weights: this is the product-density
176 % case of the joint f(theta_1,...,theta_l) in Trivedi and Bobbio
177 % (2017), Eq. (3.67), and assumes the Priors are independent.
178 % Monte Carlo instead draws all Priors jointly, so its cost is
179 % independent of the number of Priors.
181 if isempty(self.priorInfo)
182 self.design = struct('weight', 1, 'dists', {{}});
186 L = length(self.priorInfo);
187 method = self.getUQMethod();
188 n = self.getUQNodes();
190 if strcmp(method,
'montecarlo')
191 self.design = repmat(struct('weight', 1/n, 'dists', {{}}), 1, n);
195 [d, ~] = self.priorInfo(l).prior.discretize(1,
'montecarlo');
198 self.design(i).weight = 1/n;
199 self.design(i).dists = dists;
204 % Tensor product of
the per-Prior alternatives
205 margDists = cell(1, L);
206 margWeights = cell(1, L);
208 [margDists{l}, margWeights{l}] = self.priorInfo(l).prior.discretize(n,
'quadrature');
211 counts = cellfun(@length, margDists);
212 total = prod(counts);
213 if total > UQ.MaxDesignPoints
214 line_error(mfilename, sprintf([
'Tensor-product design has %d points, above the limit of %d. ', ...
215 'Use options.method = ''montecarlo'' or reduce options.samples.'], ...
216 total, UQ.MaxDesignPoints));
219 self.design = repmat(
struct(
'weight', 1,
'dists', {{}}), 1, total);
221 idx = UQ.unrankIndex(i, counts);
225 dists{l} = margDists{l}{idx(l)};
226 w = w * margWeights{l}(idx(l));
228 self.design(i).weight = w;
229 self.design(i).dists = dists;
233 function method = getUQMethod(self)
234 % METHOD = GETUQMETHOD()
235 % Resolve
the discretization method from
the solver options.
237 %
'default' keeps
the historical behaviour: discrete Priors are
238 % expanded as given, continuous Priors are discretized by
240 method =
'quadrature';
241 if isfield(self.options,
'method') && ~isempty(self.options.method)
242 m =
char(self.options.method);
244 case {
'default',
'discrete',
'quadrature'}
245 method =
'quadrature';
247 method =
'montecarlo';
249 line_error(mfilename, sprintf(
'Unknown UQ method: %s', m));
254 function n = getUQNodes(self)
256 % Number of
nodes per continuous Prior, from options.samples.
258 if isfield(self.options,
'samples') && ~isempty(self.options.samples)
259 n = round(self.options.samples);
262 line_error(mfilename, 'options.samples must be at least 1');
266 function hasPrior = hasPriorDistribution(self)
267 % HASPRIOR = HASPRIORDISTRIBUTION()
268 % Return true if model contains a Prior distribution
269 hasPrior = ~isempty(self.priorInfo);
272 function n = getNumAlternatives(self)
273 % N = GETNUMALTERNATIVES()
274 % Return number of design points (1 if no Prior)
275 if isempty(self.design)
278 n = length(self.design);
281 function probs = getProbabilities(self)
282 % PROBS = GETPROBABILITIES()
283 % Return vector of design-point weights
284 if isempty(self.design)
287 probs = [self.design.weight];
290 function E = getNumberOfModels(self)
291 % E = GETNUMBEROFMODELS()
292 % Return number of ensemble models (design points)
294 % Overrides EnsembleSolver to return
count based on
the design,
295 % since ensemble
is not populated until init().
296 if ~isempty(self.ensemble)
297 E = length(self.ensemble);
299 E = self.getNumAlternatives();
303 %% EnsembleSolver abstract method implementations
306 % INIT Initialize
the UQ solver
308 % Expands
the model into a family of concrete models,
309 % one for each Prior alternative.
311 if isempty(self.design)
314 line_debug('UQ init: expanding model into %d alternatives', length(self.design));
315 if isempty(self.priorInfo)
316 % No Prior - just use
the original model
317 self.ensemble = {self.model};
318 self.solvers{1} = self.solverFactory(self.model);
322 n = length(self.design);
323 L = length(self.priorInfo);
324 self.ensemble = cell(1, n);
325 self.solvers = cell(1, n);
328 % Deep copy
the model
329 modelCopy = self.originalModel.copy();
331 nodes = modelCopy.getNodes();
332 classes = modelCopy.getClasses();
334 % Replace every Prior with its concrete alternative at
this
337 node =
nodes{self.priorInfo(l).nodeIdx};
338 class = classes{self.priorInfo(l).classIdx};
339 concreteDist = self.design(i).dists{l};
341 if strcmp(self.priorInfo(l).type,
'service')
342 node.setService(class, concreteDist);
343 elseif strcmp(self.priorInfo(l).type, 'arrival')
344 node.setArrival(class, concreteDist);
348 % Rename model to indicate alternative
349 modelCopy.name = sprintf('%s_alt%d', self.originalModel.name, i);
351 self.ensemble{i} = modelCopy;
352 self.solvers{i} = self.solverFactory(modelCopy);
356 function pre(self, it)
357 % PRE Pre-iteration operations (no-op for UQ)
358 % UQ only needs a single iteration
361 function [result, runtime] = analyze(self, it, e)
362 % ANALYZE Run solver for ensemble model e
364 % @param it Iteration number
365 % @param e Ensemble model index
366 % @return result Solver result structure
367 % @return runtime Solver execution time
370 solver = self.solvers{e};
371 solver.runAnalyzer();
372 result = solver.result;
376 function post(self, it)
377 % POST Post-iteration operations
379 % Aggregates results from all ensemble models using prior weights.
381 self.aggregateResults();
384 function finish(self)
385 % FINISH Finalization (no-op
for UQ)
388 function
bool = converged(self, it)
389 % CONVERGED Check convergence
391 % UQ converges after
the first iteration (it >= 1).
395 function [QN, UN, RN, TN, AN, WN] = getEnsembleAvg(self)
396 % GETENSEMBLEAVG Get per-model average metrics
398 % Returns cell arrays with metrics from each ensemble model.
400 n = self.getNumberOfModels();
409 if ~isempty(self.results) && size(self.results, 2) >= e && ~isempty(self.results{1, e})
410 res = self.results{1, e};
411 if isfield(res,
'Avg')
412 if isfield(res.Avg, 'Q'), QN{e} = res.Avg.Q; end
413 if isfield(res.Avg,
'U'), UN{e} = res.Avg.U; end
414 if isfield(res.Avg,
'R'), RN{e} = res.Avg.R; end
415 if isfield(res.Avg,
'T'), TN{e} = res.Avg.T; end
416 if isfield(res.Avg,
'A'), AN{e} = res.Avg.A; end
417 if isfield(res.Avg,
'W'), WN{e} = res.Avg.W; end
423 %% Result aggregation methods
425 function aggregateResults(self)
426 % AGGREGATERESULTS Compute prior-weighted aggregate metrics
428 if isempty(self.results)
432 n = self.getNumberOfModels();
433 probs = self.getProbabilities();
435 % Initialize aggregated result
436 self.aggregatedResult =
struct();
437 self.aggregatedResult.solver =
'UQ';
438 self.aggregatedResult.Avg =
struct();
440 % Get dimensions from first result
443 if ~isempty(self.results{1, e})
444 firstResult = self.results{1, e};
449 if isempty(firstResult) || ~isfield(firstResult,
'Avg')
453 % Aggregate each metric field
454 fields = {
'Q',
'U',
'R',
'T',
'A',
'W',
'C',
'X'};
455 for f = 1:length(fields)
457 if isfield(firstResult.Avg, fname) && ~isempty(firstResult.Avg.(fname))
458 self.aggregatedResult.Avg.(fname) = zeros(size(firstResult.Avg.(fname)));
460 if ~isempty(self.results{1, e}) && ...
461 isfield(self.results{1, e},
'Avg') && ...
462 isfield(self.results{1, e}.Avg, fname) && ...
463 ~isempty(self.results{1, e}.Avg.(fname))
464 self.aggregatedResult.Avg.(fname) = self.aggregatedResult.Avg.(fname) + ...
465 probs(e) * self.results{1, e}.Avg.(fname);
472 %% Override NetworkSolver methods
for aggregated results
474 function [QN, UN, RN, TN, AN, WN] = getAvg(self, varargin)
475 % GETAVG Return prior-weighted average metrics
477 % Returns aggregated metrics weighted by prior probabilities.
479 % Run solver if not done
480 if isempty(self.results)
484 QN = []; UN = []; RN = []; TN = []; AN = []; WN = [];
485 if ~isempty(self.aggregatedResult) && isfield(self.aggregatedResult,
'Avg')
486 if isfield(self.aggregatedResult.Avg, 'Q'), QN = self.aggregatedResult.Avg.Q; end
487 if isfield(self.aggregatedResult.Avg, 'U'), UN = self.aggregatedResult.Avg.U; end
488 if isfield(self.aggregatedResult.Avg, 'R'), RN = self.aggregatedResult.Avg.R; end
489 if isfield(self.aggregatedResult.Avg, 'T'), TN = self.aggregatedResult.Avg.T; end
490 if isfield(self.aggregatedResult.Avg, 'A'), AN = self.aggregatedResult.Avg.A; end
491 if isfield(self.aggregatedResult.Avg, 'W'), WN = self.aggregatedResult.Avg.W; end
495 function AvgTable = getAvgTable(self, varargin)
496 % GETAVGTABLE Return prior-weighted average table
498 % Returns a table of aggregated metrics weighted by prior probabilities.
500 % Run solver if not done
501 if isempty(self.results)
505 [QN, UN, RN, TN, AN, WN] = self.getAvg();
507 % Get model structure
508 sn = self.originalModel.getStruct(false);
524 Q_val = 0; U_val = 0; R_val = 0; T_val = 0; A_val = 0; W_val = 0;
526 if ~isempty(QN), Q_val = QN(ist, k); end
527 if ~isempty(UN), U_val = UN(ist, k); end
528 if ~isempty(RN), R_val = RN(ist, k); end
529 if ~isempty(TN), T_val = TN(ist, k); end
530 if ~isempty(AN), A_val = AN(ist, k); end
531 if ~isempty(WN), W_val = WN(ist, k); end
533 % Only include rows with non-zero values
534 if Q_val > 0 || U_val > 0 || T_val > 0
535 Station{end+1, 1} = sn.nodenames{sn.stationToNode(ist)};
536 JobClass{end+1, 1} = sn.classnames{k};
537 QLen(end+1, 1) = Q_val;
538 Util(end+1, 1) = U_val;
539 RespT(end+1, 1) = R_val;
540 ResidT(end+1, 1) = W_val;
541 ArvR(end+1, 1) = A_val;
542 Tput(end+1, 1) = T_val;
553 JobClass = categorical(JobClass);
555 AvgTable = table(
Station, JobClass, QLen, Util, RespT, ResidT, ArvR, Tput);
558 %% UQ-specific result methods
560 function ptable = getPosteriorTable(self)
561 % GETPOSTERIORTABLE Return table with per-alternative results
563 % Returns a table showing metrics for each Prior alternative
564 % along with its probability.
566 % Run solver if not done
567 if isempty(self.results)
571 probs = self.getProbabilities();
572 n = self.getNumberOfModels();
573 sn = self.originalModel.getStruct(
false);
588 if isempty(self.results{1, alt})
591 res = self.results{1, alt};
592 if ~isfield(res,
'Avg')
598 Q_val = 0; U_val = 0; R_val = 0; T_val = 0;
600 if isfield(res.Avg, 'Q') && ~isempty(res.Avg.Q)
601 Q_val = res.Avg.Q(ist, k);
603 if isfield(res.Avg, 'U') && ~isempty(res.Avg.U)
604 U_val = res.Avg.U(ist, k);
606 if isfield(res.Avg, 'R') && ~isempty(res.Avg.R)
607 R_val = res.Avg.R(ist, k);
609 if isfield(res.Avg, 'T') && ~isempty(res.Avg.T)
610 T_val = res.Avg.T(ist, k);
613 % Only include rows with non-zero values
614 if Q_val > 0 || U_val > 0 || T_val > 0
615 Alternative(end+1, 1) = alt;
616 Probability(end+1, 1) = probs(alt);
617 Station{end+1, 1} = sn.nodenames{sn.stationToNode(ist)};
618 JobClass{end+1, 1} = sn.classnames{k};
619 QLen(end+1, 1) = Q_val;
620 Util(end+1, 1) = U_val;
621 RespT(end+1, 1) = R_val;
622 Tput(end+1, 1) = T_val;
628 if isempty(Alternative)
634 JobClass = categorical(JobClass);
636 ptable = table(Alternative, Probability,
Station, JobClass, QLen, Util, RespT, Tput);
639 function empDist = getPosteriorDist(self, metric, station,
class)
640 % GETPOSTERIORDIST Return empirical distribution of a metric
642 % Returns an EmpiricalCDF
object representing
the posterior
643 % distribution of
the specified metric across Prior alternatives.
645 % @param metric Metric name:
'Q',
'U',
'R',
'T',
'A',
'W'
646 % @param station
Station node or station index
647 % @param
class JobClass object or class index
648 % @
return empDist EmpiricalCDF
object
650 % Run solver
if not done
651 if isempty(self.results)
655 probs = self.getProbabilities();
656 n = self.getNumberOfModels();
659 if isnumeric(station)
662 ist = station.stationIndex;
664 line_error(mfilename, 'station must be a
Station object or numeric index');
670 elseif isa(class, 'JobClass')
673 line_error(mfilename, 'class must be a JobClass
object or numeric index');
676 % Extract metric values for each alternative
677 values = zeros(n, 1);
679 if ~isempty(self.results{1, e}) && isfield(self.results{1, e},
'Avg')
680 res = self.results{1, e}.Avg;
681 if isfield(res, metric) && ~isempty(res.(metric))
682 values(e) = res.(metric)(ist, k);
691 % Build empirical CDF
692 % Sort values and corresponding probabilities
693 [sortedVals, sortIdx] = sort(values);
694 sortedProbs = probs(sortIdx);
695 cdfVals = cumsum(sortedProbs);
697 % Create data matrix
for EmpiricalCDF: [CDF_value, X_value]
698 cdfData = [cdfVals(:), sortedVals(:)];
700 % Create EmpiricalCDF
object
701 empDist = EmpiricalCDF(cdfData);
704 %% Required Solver abstract methods
706 function runtime = runAnalyzer(self, options)
707 % RUNANALYZER Run
the UQ analysis
709 % @param options Solver options (optional)
710 % @
return runtime Total runtime in seconds
713 if nargin >= 2 && ~isempty(options)
714 self.setOptions(options);
716 line_debug(
'UQ solver starting: nalternatives=%d', self.getNumAlternatives());
717 line_debug(
'Default method: using UQ ensemble analysis\n');
722 function sn = getStruct(self)
723 % GETSTRUCT Return model structure
725 % Returns
the structure of
the original model.
726 sn = self.originalModel.getStruct(
false);
731 function [allMethods] = listValidMethods(self)
732 % LISTVALIDMETHODS Return valid methods
733 allMethods = {
'default',
'discrete',
'quadrature',
'montecarlo'};
736 %% Uncertainty summaries
738 function [m, v] = getMoments(self, metric, station,
class)
739 % [M, V] = GETMOMENTS(METRIC, STATION, CLASS)
740 % Weighted mean and variance of a metric over
the design.
742 % The mean
is the unconditional expectation of Trivedi and Bobbio
743 % (2017), Eq. (3.68);
the variance
is the second moment of
the
744 % same weighting, as derived
for the cold-standby case in their
745 % Sec. 8.5.1. Both are exact for a discrete Prior and quadrature-
746 % or sample-approximate for a continuous one.
748 % @param metric Metric name ('Q','U','R','T','A','W')
749 % @param station
Station object or index
750 % @param class Class
object or index
751 % @return m Weighted mean
752 % @return v Weighted variance
754 [vals, w] = self.getSamples(metric, station, class);
756 v = sum(w .* (vals - m).^2);
759 function ci = getCredibleInterval(self, metric, station,
class, level)
760 % CI = GETCREDIBLEINTERVAL(METRIC, STATION, CLASS, LEVEL)
761 % Equal-tailed credible interval from
the weighted empirical CDF.
763 % @param metric Metric name ('Q','U','R','T','A','W')
764 % @param station
Station object or index
765 % @param class Class
object or index
766 % @param level Coverage level in (0,1), default 0.95
767 % @return ci Two-element vector [lower, upper]
769 if nargin < 5 || isempty(level)
772 if level <= 0 || level >= 1
773 line_error(mfilename,
'level must lie strictly between 0 and 1');
776 [vals, w] = self.getSamples(metric, station,
class);
777 [vals, ord] = sort(vals);
779 cw = cumsum(w) / sum(w);
781 alpha = (1 - level) / 2;
782 lo = vals(find(cw >= alpha, 1,
'first'));
783 hi = vals(find(cw >= 1 - alpha, 1,
'first'));
784 if isempty(lo), lo = vals(1); end
785 if isempty(hi), hi = vals(end); end
789 function [vals, w] = getSamples(self, metric, station,
class)
790 % [VALS, W] = GETSAMPLES(METRIC, STATION, CLASS)
791 % Per-design-point metric values and their weights.
793 if isempty(self.results)
797 ist = self.resolveStationIndex(station);
798 k = self.resolveClassIndex(
class);
800 n = self.getNumberOfModels();
801 w = self.getProbabilities();
804 res = self.results{1, e};
805 if isempty(res) || ~isfield(res,
'Avg') || ~isfield(res.Avg, metric) || isempty(res.Avg.(metric))
806 line_error(mfilename, sprintf(
'Metric %s unavailable for design point %d', metric, e));
808 vals(e) = res.Avg.(metric)(ist, k);
812 function ist = resolveStationIndex(self, station)
813 % IST = RESOLVESTATIONINDEX(STATION)
814 % Accept a
Station object or a numeric index.
815 if isnumeric(station)
817 elseif isa(station,
'Station')
818 ist = station.stationIndex;
820 line_error(mfilename, 'station must be a
Station object or numeric index');
824 function k = resolveClassIndex(self, class)
825 % K = RESOLVECLASSINDEX(CLASS)
826 % Accept a JobClass
object or a numeric index.
829 elseif isa(class, 'JobClass')
832 line_error(mfilename, 'class must be a JobClass
object or numeric index');
838 function idx = unrankIndex(i, counts)
839 % IDX = UNRANKINDEX(I, COUNTS)
840 % Map
the linear index I in 1..prod(COUNTS) to a subscript vector
841 % over a mixed-radix grid, first coordinate varying fastest.
846 idx(l) = mod(rem, counts(l)) + 1;
847 rem = floor(rem / counts(l));
851 function featSupported = getFeatureSet()
852 % GETFEATURESET Return supported features
853 featSupported = SolverFeatureSet;
854 featSupported.setTrue('Prior');
857 function [
bool, featSupported] = supports(model)
858 % SUPPORTS Check if model
is supported
860 featSupported = UQ.getFeatureSet();
863 function options = defaultOptions()
864 % DEFAULTOPTIONS Return default options
865 options = Solver.defaultOptions();
866 options.iter_max = 1; % Single iteration for UQ
867 % Nodes per continuous Prior. Each node
is a full solver run, so
868 %
the simulation-oriented default of Solver.defaultOptions
is far
870 options.samples = 11;