1classdef SolverENV < EnsembleSolver
2 % ENV - Ensemble environment solver
for models with random environment changes
4 % SolverENV analyzes queueing networks operating in random environments where
5 % system parameters (arrival rates, service rates, routing) change according
6 % to an underlying environmental process. It solves ensemble models by analyzing
7 % each environmental stage and computing environment-averaged performance metrics.
9 % @brief Environment solver
for networks in random changing environments
11 % Key characteristics:
12 % - Random environment with multiple operational stages
13 % - Environmental process governing parameter changes
14 % - Ensemble model analysis across environment stages
15 % - Environment-averaged performance computation
16 % - Stage-dependent system behavior modeling
18 % Environment solver features:
19 % - Multi-stage environmental modeling
20 % - Stage transition matrix analysis
21 % - Weighted performance metric computation
22 % - Environmental ensemble solution
23 % - Adaptive parameter modeling
25 % SolverENV
is ideal
for:
26 % - Systems with time-varying parameters
27 % - Networks subject to environmental fluctuations
28 % - Multi-mode operational system analysis
29 % - Performance under uncertainty modeling
30 % - Adaptive system behavior analysis
34 % env_model = Environment(stages, transitions); % Define environment
35 % solver = SolverENV(env_model, @SolverMVA, options);
36 % metrics = solver.getEnsembleAvg(); % Environment-averaged metrics
39 % Copyright (c) 2012-2026, Imperial College London
40 % All rights reserved.
44 env; % user-supplied representation of each stage transition
48 resetEnvRates; % function implementing the reset policy
for environment rates
49 stateDepMethod =
''; % state-dependent method configuration
50 % Enhanced init properties (aligned with JAR)
51 ServerNum; % Cell array of server counts per
class
52 SRates; % Cell array of service rates per
class
54 Eutil; % Infinitesimal generator
55 transitionCdfs; % Transition CDF functions
56 sojournCdfs; % Sojourn time CDF functions
57 dtmcP; % DTMC transition matrix
58 holdTimeMatrix; % Hold time matrix
59 SMPMethod =
false; % Use DTMC-based computation for Semi-Markov Processes
60 compression = false; % Use Courtois decomposition
61 compressionResult; % Results from Courtois decomposition
62 Ecompress; % Number of macro-states after compression
63 MS; % Macro-state partition
67 function self = SolverENV(renv, solverFactory, options)
68 % SELF = SOLVERENV(ENV,SOLVERFACTORY,OPTIONS)
69 self@EnsembleSolver(renv, mfilename);
70 if nargin>=3 %exist(
'options',
'var')
71 self.setOptions(options);
73 self.setOptions(SolverENV.defaultOptions);
76 % Enable SMP method if specified in options
77 if isfield(self.options, 'method') && strcmpi(self.options.method, 'smp')
78 self.SMPMethod = true;
79 line_debug('ENV solver: SMP method enabled via options.method=''smp''');
83 self.ensemble = renv.getEnsemble;
86 for e=1:length(self.env)
87 self.sn{e} = self.ensemble{e}.getStruct;
88 self.setSolver(solverFactory(self.ensemble{e}),e);
91 for e=1:length(self.env)
92 for h=1:length(self.env)
93 self.resetFromMarginal{e,h} = renv.resetFun{e,h};
97 for e=1:length(self.env)
98 for h=1:length(self.env)
99 self.resetEnvRates{e,h} = renv.resetEnvRatesFun{e,h};
103 % Auto-detect state-dependent environment
104 hasStateDependentRates = false;
105 for e=1:length(self.env)
106 for h=1:length(self.env)
107 if ~isempty(self.resetEnvRates{e,h})
108 % Check
if it
's not the default identity function
109 % Default is: @(originalDist, QExit, UExit, TExit) originalDist
110 func_str = func2str(self.resetEnvRates{e,h});
111 if ~contains(func_str, 'originalDist
') || length(func_str) > 50
112 hasStateDependentRates = true;
117 if hasStateDependentRates
122 if hasStateDependentRates
123 self.stateDepMethod = 'statedep
';
124 line_debug('ENV solver: Auto-detected state-dependent environment rates
');
127 % Validate incompatible method combinations
128 if self.SMPMethod && strcmp(self.stateDepMethod, 'statedep
')
129 line_error(mfilename, ['SMP method (method=
''smp
'')
is incompatible with state-dependent environments.\n
' ...
130 'SMP method computes environment probabilities once at initialization,\n
' ...
131 'but state-dependent environments modify transition rates during iterations.\n
' ...
132 'Please use either method=
''smp
'' OR state-dependent rates, but not both.
']);
135 for e=1:length(self.ensemble)
136 for h=1:length(self.env)
137 if isa(self.env{e,h},'Disabled
')
138 self.env{e,h} = Exp(0);
139 elseif ~isa(self.env{e,h},'Markovian
') && ~self.SMPMethod
140 line_error(mfilename,sprintf('The distribution of the environment transition from stage %d to %d
is not supported by the %s solver. Use method=
''smp
'' for non-Markovian distributions.
',e,h,self.getName));
145 for e=1:length(self.ensemble)
146 if ~self.solvers{e}.supports(self.ensemble{e})
147 line_error(mfilename,sprintf('Model in the environment stage %d
is not supported by the %s solver.
',e,self.getName));
152 function setStateDepMethod(self, method)
153 % SETSTATEDEPMETHOD(METHOD) Sets the state-dependent method
155 line_error(mfilename, 'State-dependent method cannot be null or empty.
');
157 self.stateDepMethod = method;
160 function setSMPMethod(self, flag)
161 % SETSMPMETHOD(FLAG) Enable/disable DTMC-based computation for Semi-Markov Processes
162 self.SMPMethod = flag;
165 function setCompression(self, flag)
166 % SETCOMPRESSION(FLAG) Enable/disable Courtois decomposition
167 self.compression = flag;
170 function [p, eps, epsMax, q] = ctmc_decompose(self, Q, MS)
171 % CTMC_DECOMPOSE Perform CTMC decomposition using configured method
172 % [p, eps, epsMax, q] = CTMC_DECOMPOSE(Q, MS)
174 % Uses options.config.decomp to select the decomposition algorithm:
175 % 'courtois
' - Courtois decomposition (default)
176 % 'kms
' - Koury-McAllister-Stewart method
177 % 'takahashi
' - Takahashi's method
178 %
'multi' - Multigrid method (requires MSS)
181 % p - steady-state probability vector
183 % epsMax - max acceptable eps value
184 % q - randomization coefficient
186 % Get decomposition/aggregation method from options
187 if isfield(self.options,
'config') && isfield(self.options.config,
'da')
188 method = self.options.config.da;
193 % Get numsteps
for iterative methods
194 if isfield(self.options,
'config') && isfield(self.options.config,
'da_iter')
195 numsteps = self.options.config.da_iter;
202 [p, ~, ~, eps, epsMax, ~, ~, ~, q] = ctmc_courtois(Q, MS);
204 [p, ~, ~, eps, epsMax] = ctmc_kms(Q, MS, numsteps);
205 q = 1.05 * max(max(abs(Q)));
207 [p, ~, ~, ~, eps, epsMax] = ctmc_takahashi(Q, MS, numsteps);
208 q = 1.05 * max(max(abs(Q)));
210 % Multi requires MSS (macro-macro-states), default to singletons
211 nMacro = size(MS, 1);
212 MSS = cell(nMacro, 1);
216 [p, ~, ~, ~, eps, epsMax] = ctmc_multi(Q, MS, MSS);
217 q = 1.05 * max(max(abs(Q)));
219 line_error(mfilename, sprintf(
'Unknown decomposition method: %s', method));
223 function
bool = converged(self, it) % convergence test at iteration it
224 % BOOL = CONVERGED(IT) % CONVERGENCE TEST AT ITERATION IT
225 % Computes max relative absolute difference of queue lengths between iterations
226 % Aligned with JAR SolverEnv.converged() implementation
233 E = self.getNumberOfModels;
234 M = self.ensemble{1}.getNumberOfStations;
235 K = self.ensemble{1}.getNumberOfClasses;
237 % Check convergence per
class (aligned with JAR structure)
239 % Build QEntry and QExit matrices (M x E) for this class
240 QEntry = zeros(M, E);
243 % Skip stages where analysis failed
244 res_curr = self.results{it,e};
245 res_prev = self.results{it-1,e};
247 if ~isempty(res_curr) && isfield(res_curr,
'Tran') ...
248 && isstruct(res_curr.Tran.Avg) && isfield(res_curr.Tran.Avg,
'Q')
249 Qik_curr = res_curr.Tran.Avg.Q{i,k};
250 if isstruct(Qik_curr) && isfield(Qik_curr,
'metric') && ~isempty(Qik_curr.metric)
251 QExit(i,e) = Qik_curr.metric(1);
254 if ~isempty(res_prev) && isfield(res_prev, 'Tran') ...
255 && isstruct(res_prev.Tran.Avg) && isfield(res_prev.Tran.Avg, 'Q')
256 Qik_prev = res_prev.Tran.Avg.Q{i,k};
257 if isstruct(Qik_prev) && isfield(Qik_prev,
'metric') && ~isempty(Qik_prev.metric)
258 QEntry(i,e) = Qik_prev.metric(1);
264 % Compute max relative absolute difference using maxpe
265 % maxpe computes max(abs(1 - approx./exact)) = max(abs((approx-exact)./exact))
266 % This matches JAR's Matrix.maxAbsDiff() implementation
267 maxDiff = maxpe(QExit(:), QEntry(:));
269 maxDiff = 0; % Handle case where all QEntry values are zero
271 if isnan(maxDiff) || isinf(maxDiff)
272 return % Non-convergence on invalid values
274 if maxDiff >= self.options.iter_tol
275 return % Not converged
282 function runAnalyzer(self)
284 % Run the ensemble solver iteration
285 line_debug('ENV solver starting: nstages=%d, method=%s', self.getNumberOfModels, self.options.method);
287 % Show library attribution if verbose and not yet shown
288 if self.options.verbose ~= VerboseLevel.SILENT && ~GlobalConstants.isLibraryAttributionShown()
289 libs = SolverENV.getLibrariesUsed([], self.options);
291 line_printf('The solver will leverage %s.\n', strjoin(libs, ', '));
292 GlobalConstants.setLibraryAttributionShown(true);
301 % Initialize the environment solver with enhanced data structures
302 % aligned with JAR SolverEnv implementation
303 line_debug('ENV solver init: initializing environment data structures');
304 options = self.options;
305 if isfield(options,'seed')
306 Solver.resetRandomGeneratorSeed(options.seed);
310 % Initialize ServerNum and SRates matrices (aligned with JAR)
311 E = self.getNumberOfModels;
312 M = self.sn{1}.nstations;
313 K = self.sn{1}.nclasses;
315 self.ServerNum = cell(K, 1);
316 self.SRates = cell(K, 1);
318 self.ServerNum{k} = zeros(M, E);
319 self.SRates{k} = zeros(M, E);
322 self.ServerNum{k}(m, e) = self.sn{e}.nservers(m);
323 self.SRates{k}(m, e) = self.sn{e}.rates(m, k);
328 % Build rate matrix E0
329 self.E0 = zeros(E, E);
332 if ~isa(self.envObj.env{e,h},
'Disabled')
333 self.E0(e, h) = self.envObj.env{e,h}.getRate();
337 self.Eutil = ctmc_makeinfgen(self.E0);
339 % Initialize transition CDFs
340 self.transitionCdfs = cell(E, E);
343 if ~isa(self.envObj.env{e,h},
'Disabled')
344 envDist = self.envObj.env{e,h};
345 self.transitionCdfs{e,h} = @(t) envDist.evalCDF(t);
347 self.transitionCdfs{e,h} = @(t) 0;
352 % Initialize sojourn CDFs
353 self.sojournCdfs = cell(E, 1);
355 self.sojournCdfs{e} = @(t) self.computeSojournCdf(e, t);
358 % SMPMethod: Use DTMC-based computation instead of CTMC for Semi-Markov Processes
359 % Verified numerical integration for Semi-Markov Process DTMC transition probabilities
361 line_debug('ENV using DTMC-based computation for Semi-Markov Processes (SMPMethod=true)');
362 self.dtmcP = zeros(E, E);
365 if k == e || isa(self.envObj.env{k,e},
'Disabled')
366 self.dtmcP(k, e) = 0.0;
368 % Compute the upper limit of the sojourn time
371 while self.transitionCdfs{k,e}(T) < 1.0 - epsilon
373 if T > 1e6 % safety limit
377 % Adaptive number of integration intervals based on T
378 N = max(1000, round(T * 100));
384 deltaF = self.transitionCdfs{k,e}(t1) - self.transitionCdfs{k,e}(t0);
387 if h ~= k && h ~= e && ~isa(self.envObj.env{k,h},
'Disabled')
388 % Use midpoint
for better accuracy
389 tmid = (t0 + t1) / 2.0;
390 survival = survival * (1.0 - self.envObj.env{k,h}.evalCDF(tmid));
393 sumVal = sumVal + deltaF * survival;
395 self.dtmcP(k, e) = sumVal;
400 % Solve DTMC
for stationary distribution
401 dtmcPie = dtmc_solve(self.dtmcP);
403 % Calculate hold times
using numerical integration
404 self.holdTimeMatrix = self.computeHoldTime(E);
406 % Compute steady-state probabilities
410 denomSum = denomSum + dtmcPie(e) * self.holdTimeMatrix(e);
413 pi(k) = dtmcPie(k) * self.holdTimeMatrix(k) / denomSum;
415 self.envObj.probEnv = pi;
417 % Update embedding weights
418 newEmbweight = zeros(E, E);
423 sumVal = sumVal + pi(h) * self.E0(h, e);
428 newEmbweight(k, e) = 0;
431 newEmbweight(k, e) = pi(k) * self.E0(k, e) / sumVal;
436 self.envObj.probOrig = newEmbweight;
439 % Compression: Use Courtois decomposition
for large environments
441 line_debug(
'ENV using compression (Courtois decomposition)');
442 self.applyCompression(E, M, K);
446 function applyCompression(self, E, M, K)
447 % APPLYCOMPRESSION Apply Courtois decomposition to reduce environment size
448 % This method finds a good partition of the environment states and
449 % creates compressed macro-state networks.
451 % Find best partition
453 self.MS = self.findBestPartition(E);
455 % Beam search
for large environments
456 self.MS = self.beamSearchPartition(E);
460 % No compression possible, use singletons
461 self.MS = cell(E, 1);
469 self.Ecompress = length(self.MS);
471 % Apply decomposition/aggregation
472 [p, eps, epsMax, q] = self.ctmc_decompose(self.Eutil, self.MS);
475 line_warning(mfilename,
'Environment cannot be effectively compressed (eps > epsMax).');
478 % Store compression results
479 self.compressionResult.p = p;
480 self.compressionResult.eps = eps;
481 self.compressionResult.epsMax = epsMax;
482 self.compressionResult.q = q;
484 % Update probEnv with macro-state probabilities
485 pMacro = zeros(1, self.Ecompress);
486 for i = 1:self.Ecompress
487 pMacro(i) = sum(p(self.MS{i}));
489 self.envObj.probEnv = pMacro;
491 % Compute micro-state probabilities within each macro-state
492 pmicro = zeros(E, 1);
493 for i = 1:self.Ecompress
494 blockProb = p(self.MS{i});
495 if sum(blockProb) > 0
496 pmicro(self.MS{i}) = blockProb / sum(blockProb);
499 self.compressionResult.pmicro = pmicro;
500 self.compressionResult.pMacro = pMacro;
502 % Update embedding weights
for macro-states
503 Ecomp = self.Ecompress;
504 newEmbweight = zeros(Ecomp, Ecomp);
509 sumVal = sumVal + pMacro(h) * self.computeMacroRate(h, e);
514 newEmbweight(k, e) = 0;
516 newEmbweight(k, e) = pMacro(k) * self.computeMacroRate(k, e) / sumVal;
520 self.envObj.probOrig = newEmbweight;
522 % Build macro-state networks with weighted-average rates
523 macroEnsemble = cell(self.Ecompress, 1);
524 macroSolvers = cell(self.Ecompress, 1);
525 macroSn = cell(self.Ecompress, 1);
527 for i = 1:self.Ecompress
528 % Copy the first micro-state network
529 firstMicro = self.MS{i}(1);
530 macroEnsemble{i} = self.ensemble{firstMicro}.copy();
532 % Compute weighted-average rates
536 for r = 1:length(self.MS{i})
537 microIdx = self.MS{i}(r);
538 w = pmicro(microIdx);
539 rateSum = rateSum + w * self.sn{microIdx}.rates(m, k);
542 % Update service rate
543 jobclass = macroEnsemble{i}.classes{k};
544 station = macroEnsemble{i}.stations{m};
545 if isa(station,
'Queue') || isa(station,
'Delay')
547 station.setService(
jobclass, Exp(rateSum));
553 macroEnsemble{i}.refreshStruct(
true);
554 macroSn{i} = macroEnsemble{i}.getStruct(
true);
556 % Create solver
for macro-state
557 % Use the same solver factory pattern as original
558 macroSolvers{i} = SolverFluid(macroEnsemble{i}, self.solvers{firstMicro}.options);
561 % Replace ensemble and solvers with compressed versions
562 self.ensemble = macroEnsemble;
563 self.solvers = macroSolvers;
567 function rate = computeMacroRate(self, fromMacro, toMacro)
568 % COMPUTEMACRORATE Compute transition rate between macro-states
570 for i = 1:length(self.MS{fromMacro})
571 mi = self.MS{fromMacro}(i);
572 for j = 1:length(self.MS{toMacro})
573 mj = self.MS{toMacro}(j);
574 rate = rate + self.compressionResult.pmicro(mi) * self.E0(mi, mj);
579 function MS = findBestPartition(self, E)
580 % FINDBESTPARTITION Find the best partition
for small environments (E <= 10)
581 % Uses exhaustive search over all possible partitions
583 % Start with singletons
589 [~, bestEps, bestEpsMax] = self.ctmc_decompose(self.Eutil, bestMS);
590 if isempty(bestEps) || isnan(bestEps)
598 testMS = cell(E-1, 1);
602 testMS{idx} = [i, j];
610 [~, testEps, testEpsMax] = self.ctmc_decompose(self.Eutil, testMS);
611 if ~isempty(testEps) && ~isnan(testEps) && testEps < bestEps
613 bestEpsMax = testEpsMax;
620 self.Ecompress = length(MS);
623 function MS = beamSearchPartition(self, E)
624 % BEAMSEARCHPARTITION Beam search
for large environments (E > 10)
627 alpha = 0.01; % Coupling threshold
629 if isfield(self.options, 'config') && isfield(self.options.config, 'env_alpha')
630 alpha = self.options.config.env_alpha;
633 % Initialize with singletons
634 singletons = cell(E, 1);
640 bestSeen = singletons;
641 [~, bestEps] = self.ctmc_decompose(self.Eutil, bestSeen);
643 % Iteratively merge blocks
647 for b = 1:length(beam)
649 nBlocks = length(ms);
651 % Try all pairwise merges
653 for j = (i+1):nBlocks
654 % Create merged partition
655 trial = cell(nBlocks - 1, 1);
659 trial{idx} = [ms{i}(:); ms{j}(:)];
667 [~, childEps, childEpsMax] = self.ctmc_decompose(self.Eutil, trial);
669 if ~isempty(childEps) && ~isnan(childEps) && childEps > 0
670 cost = childEps - childEpsMax + alpha * depth;
671 candidates{end+1} = {trial, cost};
682 if isempty(candidates)
686 % Sort by cost and keep top B
687 costs = cellfun(@(x) x{2}, candidates);
688 [~, sortIdx] = sort(costs);
690 for i = 1:min(B, length(sortIdx))
691 beam{end+1} = candidates{sortIdx(i)}{1};
696 self.Ecompress = length(MS);
699 function holdTime = computeHoldTime(self, E)
700 % COMPUTEHOLDTIME Compute expected holding times
using numerical integration
701 holdTime = zeros(1, E);
703 % Survival function: 1 - sojournCDF
704 surv = @(t) 1 - self.sojournCdfs{k}(t);
706 % Compute upper limit
708 while surv(upperLimit) > 1e-8
709 upperLimit = upperLimit * 2;
710 if upperLimit > 1e6 % safety limit
715 % Simpson
's rule integration
722 tmid = (t0 + t1) / 2;
723 % Simpson's rule: (f(a) + 4*f(mid) + f(b)) * h/6
724 integral = integral + (surv(t0) + 4*surv(tmid) + surv(t1)) * dt / 6;
726 holdTime(k) = integral;
730 function cdf = computeSojournCdf(self, e, t)
731 % COMPUTESOJOURNCDF Compute sojourn time CDF
for environment stage e
732 E = self.getNumberOfModels;
736 surv = surv * (1 - self.transitionCdfs{e,h}(t));
742 function pre(self, it)
748 if isinf(self.getSolver(e).options.timespan(2))
749 [QN,~,~,~] = self.getSolver(e).getAvg();
751 [QNt,~,~] = self.getSolver(e).getTranAvg();
752 % Handle
case where getTranAvg returns NaN
for disabled/empty results
753 QN = zeros(size(QNt));
754 for i = 1:size(QNt,1)
755 for k = 1:size(QNt,2)
756 if isstruct(QNt{i,k}) && isfield(QNt{i,k},
'metric')
757 QN(i,k) = QNt{i,k}.metric(end);
759 QN(i,k) = 0; % Default to 0
for NaN/invalid entries
764 if ~isa(self.solvers{e},
'SolverFluid')
765 QN = self.roundMarginalForDiscreteSolver(QN, self.sn{e});
767 self.ensemble{e}.initFromMarginal(QN);
769 % Skip pre-initialization for stages where getAvg fails
775 % solves model in stage e
776 function [results_e, runtime] = analyze(self, it, e)
777 % [RESULTS_E, RUNTIME] = ANALYZE(IT, E)
778 results_e = struct();
779 results_e.(
'Tran') =
struct();
780 results_e.Tran.(
'Avg') = [];
785 [Qt,Ut,Tt] = self.ensemble{e}.getTranHandles;
786 self.solvers{e}.reset();
787 [QNt,UNt,TNt] = self.solvers{e}.getTranAvg(Qt,Ut,Tt);
788 results_e.Tran.Avg.Q = QNt;
789 results_e.Tran.Avg.U = UNt;
790 results_e.Tran.Avg.T = TNt;
792 % Skip analysis
for stages where transient analysis fails
797 function post(self, it)
800 E = self.getNumberOfModels;
801 M = self.ensemble{1}.getNumberOfStations;
802 K = self.ensemble{1}.getNumberOfClasses;
804 if isempty(self.results{it,e}) || ~isfield(self.results{it,e},
'Tran') ...
805 || ~isstruct(self.results{it,e}.Tran.Avg) || ~isfield(self.results{it,e}.Tran.Avg,
'Q')
807 Qexit{e,h} = zeros(M, K);
808 Uexit{e,h} = zeros(M, K);
809 Texit{e,h} = zeros(M, K);
814 Qexit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.Q));
815 Uexit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.U));
816 Texit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.T));
817 for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
818 for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
819 Qir = self.results{it,e}.Tran.Avg.Q{i,r};
820 % Check
if result
is a valid
struct with required fields
821 if isstruct(Qir) && isfield(Qir,
't') && isfield(Qir,
'metric') && ~isempty(Qir.t)
822 w{e,h} = [0, map_cdf(self.envObj.proc{e}{h}, Qir.t(2:end)) - map_cdf(self.envObj.proc{e}{h}, Qir.t(1:end-1))]
';
824 Qexit{e,h}(i,r) = Qir.metric'*w{e,h}/sum(w{e,h});
825 Uir = self.results{it,e}.Tran.Avg.U{i,r};
826 if isstruct(Uir) && isfield(Uir,
'metric') && ~isempty(Uir.metric)
827 Uexit{e,h}(i,r) = Uir.metric
'*w{e,h}/sum(w{e,h});
829 Tir = self.results{it,e}.Tran.Avg.T{i,r};
830 if isstruct(Tir) && isfield(Tir, 'metric
') && ~isempty(Tir.metric)
831 Texit{e,h}(i,r) = Tir.metric'*w{e,h}/sum(w{e,h});
846 Qentry = cell(1,E); % average entry queue-length
848 % Skip stages where analysis failed (no valid Tran results)
849 if isempty(self.results{it,e}) || ~isfield(self.results{it,e}, 'Tran') ...
850 || ~isstruct(self.results{it,e}.Tran.Avg) || ~isfield(self.results{it,e}.Tran.Avg, 'Q')
853 Qentry{e} = zeros(size(Qexit{e}));
855 % probability of coming from h to e \times resetFun(Qexit from h to e
856 if self.envObj.probOrig(h,e) > 0
857 Qentry{e} = Qentry{e} + self.envObj.probOrig(h,e) * self.resetFromMarginal{h,e}(Qexit{h,e});
860 if ~isa(self.solvers{e},
'SolverFluid')
861 Qentry{e} = self.roundMarginalForDiscreteSolver(Qentry{e}, self.sn{e});
863 self.solvers{e}.reset();
864 self.ensemble{e}.initFromMarginal(Qentry{e});
867 % Update transition rates between stages
if state-dependent method
868 % Auto-detected or manually specified via options.method='statedep'
869 if strcmp(self.stateDepMethod, 'statedep') || ...
870 (isfield(self.options, 'method') && strcmp(self.options.method, 'statedep'))
873 if ~isa(self.envObj.env{e,h}, 'Disabled') && ~isempty(self.resetEnvRates{e,h})
874 self.envObj.env{e,h} = self.resetEnvRates{e,h}(...
875 self.envObj.env{e,h}, Qexit{e,h}, Uexit{e,h}, Texit{e,h});
879 % Reinitialize environment after rate updates
884 function finish(self)
887 it = size(self.results,1); % use last iteration
888 E = self.getNumberOfModels;
889 M = self.ensemble{1}.getNumberOfStations;
890 K = self.ensemble{1}.getNumberOfClasses;
892 QExit{e}=zeros(M, K);
893 UExit{e}=zeros(M, K);
894 TExit{e}=zeros(M, K);
895 if it>0 && ~isempty(self.results{it,e}) && isfield(self.results{it,e},
'Tran') ...
896 && isstruct(self.results{it,e}.Tran.Avg) && isfield(self.results{it,e}.Tran.Avg,
'Q')
897 for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
898 for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
899 Qir = self.results{it,e}.Tran.Avg.Q{i,r};
900 % Check
if result
is a valid
struct with required fields
901 if isstruct(Qir) && isfield(Qir,
't') && isfield(Qir,
'metric') && ~isempty(Qir.t)
902 w{e} = [0, map_cdf(self.envObj.holdTime{e}, Qir.t(2:end)) - map_cdf(self.envObj.holdTime{e}, Qir.t(1:end-1))]
';
903 QExit{e}(i,r) = Qir.metric'*w{e}/sum(w{e});
904 Uir = self.results{it,e}.Tran.Avg.U{i,r};
905 if isstruct(Uir) && isfield(Uir,
'metric') && ~isempty(Uir.metric)
906 UExit{e}(i,r) = Uir.metric
'*w{e}/sum(w{e});
910 Tir = self.results{it,e}.Tran.Avg.T{i,r};
911 if isstruct(Tir) && isfield(Tir, 'metric
') && ~isempty(Tir.metric)
912 TExit{e}(i,r) = Tir.metric'*w{e}/sum(w{e});
925 % QE{e,h} = zeros(size(self.results{it,e}.Tran.Avg.Q));
926 %
for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
927 %
for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
928 % w{e,h} = [0, map_cdf(self.envObj.proc{e}{h}, self.results{it,e}.Tran.Avg.Q{i,r}(2:end,2)) - map_cdf(self.envObj.proc{e}{h}, self.results{it,e}.Tran.Avg.Q{i,r}(1:end-1,2))]
';
930 % QE{e,h}(i,r) = self.results{it,e}.Tran.Avg.Q{i,r}(:,1)'*w{e,h}/sum(w{e,h});
943 Qval = Qval + self.envObj.probEnv(e) * QExit{e}; % to check
944 Uval = Uval + self.envObj.probEnv(e) * UExit{e}; % to check
945 Tval = Tval + self.envObj.probEnv(e) * TExit{e}; % to check
947 self.result.Avg.Q = Qval;
948 % self.result.Avg.R = R;
949 % self.result.Avg.X = X;
950 self.result.Avg.U = Uval;
951 self.result.Avg.T = Tval;
952 % self.result.Avg.C = C;
953 %self.result.runtime = runtime;
954 %
if self.options.verbose
959 function name = getName(self)
965 function [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
966 % [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
968 % Returns the infinitesimal generator matrices for the random environment model.
971 % renvInfGen - Combined infinitesimal generator for the random environment (flattened)
972 % stageInfGen - Cell array of infinitesimal generators for each stage
973 % renvEventFilt - Cell array (E x E) of event filtration matrices for environment transitions
974 % stageEventFilt - Cell array of event filtration matrices for each stage
975 % renvEvents - Cell array of Event objects for environment transitions
976 % stageEvents - Cell array of synchronization maps for each stage
978 E = self.getNumberOfModels;
979 stageInfGen = cell(1,E);
980 stageEventFilt = cell(1,E);
981 stageEvents = cell(1,E);
983 if isa(self.solvers{e},
'SolverCTMC')
984 [stageInfGen{e}, stageEventFilt{e}, stageEvents{e}] = self.solvers{e}.getGenerator();
986 line_error(mfilename,
'This method requires SolverENV to be instantiated with the CTMC solver.');
990 % Get number of states
for each stage
991 nstates = cellfun(@(g) size(g, 1), stageInfGen);
993 % Get number of phases
for each transition distribution
994 nphases = zeros(E, E);
997 if ~isempty(self.env{i,j}) && ~isa(self.env{i,j},
'Disabled')
998 nphases(i,j) = self.env{i,j}.getNumberOfPhases();
1004 % Adjust diagonal (self-transitions have one less phase in the Kronecker expansion)
1005 nphases = nphases - eye(E);
1007 % Initialize block cell structure
for the random environment generator
1008 renvInfGen = cell(E,E);
1011 % Diagonal block: stage infinitesimal generator
1012 renvInfGen{e,e} = stageInfGen{e};
1015 % Off-diagonal blocks: reset matrices (identity with appropriate dimensions)
1016 minStates = min(nstates(e), nstates(h));
1017 resetMatrix_eh = sparse(nstates(e), nstates(h));
1019 resetMatrix_eh(i,i) = 1;
1021 renvInfGen{e,h} = resetMatrix_eh;
1026 % Build environment transition events and expand generator with phase structure
1027 renvEvents = cell(1,0);
1031 % Get D0 (phase generator)
for transition from e to h
1032 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
1035 proc = self.env{e,h}.getProcess();
1038 % Kronecker sum with diagonal block
1039 renvInfGen{e,e} = krons(renvInfGen{e,e}, D0);
1041 % Get D1 (completion rate matrix) and initial probability vector pie
1042 if isempty(self.env{h,e}) || isa(self.env{h,e},
'Disabled') || any(isnan(map_pie(self.env{h,e}.getProcess())))
1043 pie = ones(1, nphases(h,e));
1045 pie = map_pie(self.env{h,e}.getProcess());
1048 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
1051 proc = self.env{e,h}.getProcess();
1055 % Kronecker product
for off-diagonal block
1056 onePhase = ones(nphases(e,h), 1);
1057 kronArg = D1 * onePhase * pie;
1058 renvInfGen{e,h} = kron(renvInfGen{e,h}, sparse(kronArg));
1060 % Create environment transition events
1061 for i=1:self.ensemble{e}.getNumberOfNodes
1062 renvEvents{1,end+1} = Event(EventType.STAGE, i, NaN, NaN, [e,h]); %#ok<AGROW>
1065 % Handle other stages (f != e, f != h)
1068 if isempty(self.env{f,h}) || isa(self.env{f,h},
'Disabled') || any(isnan(map_pie(self.env{f,h}.getProcess())))
1069 pie_fh = ones(1, nphases(f,h));
1071 pie_fh = map_pie(self.env{f,h}.getProcess());
1073 oneVec = ones(nphases(e,h), 1);
1074 renvInfGen{e,f} = kron(renvInfGen{e,f}, oneVec * pie_fh);
1081 % Build
event filtration matrices
for environment transitions
1082 % Each renvEventFilt{e,h} isolates transitions from stage e to stage h
1083 renvEventFilt = cell(E,E);
1086 tmpCell = cell(E,E);
1089 tmpCell{e1,h1} = renvInfGen{e1,h1};
1092 % Zero out diagonal blocks (internal stage transitions)
1094 tmpCell{e1,e1} = tmpCell{e1,e1} * 0;
1096 % Zero out off-diagonal blocks that don't match (e,h) transition
1100 if e1~=h1 % Only zero out off-diagonal entries
1101 tmpCell{e1,h1} = tmpCell{e1,h1} * 0;
1106 renvEventFilt{e,h} = cell2mat(tmpCell);
1110 % Flatten block structure into single matrix and normalize
1111 renvInfGen = cell2mat(renvInfGen);
1112 renvInfGen = ctmc_makeinfgen(renvInfGen);
1115 function varargout = getAvg(varargin)
1116 % [QNCLASS, UNCLASS, TNCLASS] = GETAVG()
1117 [varargout{1:nargout}] = getEnsembleAvg( varargin{:} );
1120 function [QNclass, UNclass, RNclass, TNclass, ANclass, WNclass] = getEnsembleAvg(self)
1121 % [QNCLASS, UNCLASS, TNCLASS] = GETENSEMBLEAVG()
1126 if isempty(self.result) || (isfield(self.options,'force') && self.options.force)
1128 if isempty(self.result)
1135 QNclass = self.result.Avg.Q;
1136 UNclass = self.result.Avg.U;
1137 TNclass = self.result.Avg.T;
1138 WNclass = QNclass ./ TNclass;
1139 RNclass = NaN*WNclass;
1140 ANclass = NaN*TNclass;
1143 function [AvgTable,QT,UT,TT] = getAvgTable(self,keepDisabled)
1144 % [AVGTABLE,QT,UT,TT] = GETAVGTABLE(SELF,KEEPDISABLED)
1145 % Return table of average station metrics
1147 if nargin<2 %if ~exist('keepDisabled','var')
1148 keepDisabled = false;
1151 [QN,UN,~,TN] = getAvg(self);
1154 Q = self.result.Avg.Q;
1155 U = self.result.Avg.U;
1156 T = self.result.Avg.T;
1162 elseif ~keepDisabled
1163 Qval = []; Uval = []; Tval = [];
1168 if any(sum([QN(ist,k),UN(ist,k),TN(ist,k)])>0)
1169 JobClass{end+1,1} = self.sn{1}.classnames{k};
1170 Station{end+1,1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(ist)};
1171 Qval(end+1) = QN(ist,k);
1172 Uval(end+1) = UN(ist,k);
1173 Tval(end+1) = TN(ist,k);
1177 QLen = Qval(:); % we need to save first in a variable named like the column
1178 QT = Table(Station,JobClass,QLen);
1179 Util = Uval(:); % we need to save first in a variable named like the column
1180 UT = Table(Station,JobClass,Util);
1181 Tput = Tval(:); % we need to save first in a variable named like the column
1182 Station = categorical(Station);
1183 JobClass = categorical(JobClass);
1184 TT = Table(Station,JobClass,Tput);
1185 RespT = QLen ./ Tput;
1186 AvgTable = Table(Station,JobClass,QLen,Util,RespT,Tput);
1188 Qval = zeros(M,K); Uval = zeros(M,K);
1189 JobClass = cell(K*M,1);
1190 Station = cell(K*M,1);
1193 JobClass{(ist-1)*K+k} = Q{ist,k}.class.name;
1194 Station{(ist-1)*K+k} = Q{ist,k}.station.name;
1195 Qval((ist-1)*K+k) = QN(ist,k);
1196 Uval((ist-1)*K+k) = UN(ist,k);
1197 Tval((ist-1)*K+k) = TN(ist,k);
1200 Station = categorical(Station);
1201 JobClass = categorical(JobClass);
1202 QLen = Qval(:); % we need to save first in a variable named like the column
1203 QT = Table(Station,JobClass,QLen);
1204 Util = Uval(:); % we need to save first in a variable named like the column
1205 UT = Table(Station,JobClass,Util);
1206 Tput = Tval(:); % we need to save first in a variable named like the column
1207 TT = Table(Station,JobClass,Tput);
1208 RespT = QLen ./ Tput;
1209 AvgTable = Table(Station,JobClass,QLen,Util,RespT,Tput);
1213 function envsn = getStruct(self)
1214 E = self.getNumberOfModels;
1217 envsn{e} = self.ensemble{e}.getStruct;
1221 function [T, segmentResults] = getSamplePathTable(self, samplePath)
1222 % [T, SEGMENTRESULTS] = GETSAMPLEPATHTABLE(SELF, SAMPLEPATH)
1224 % Compute transient performance metrics
for a sample path through
1225 % environment states. The method runs transient analysis
for each
1226 % segment and extracts initial and
final metric values.
1229 % samplePath - Cell array where each row
is {stage, duration}
1230 % stage: string (name) or integer (1-based index)
1231 % duration: positive scalar (time spent in stage)
1234 % T - Table with columns: Segment, Stage, Duration, Station, JobClass,
1235 % InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput
1236 % segmentResults - Cell array with detailed transient results per segment
1239 % samplePath = {
'Fast', 5.0;
'Slow', 10.0;
'Fast', 3.0};
1240 % [T, results] = solver.getSamplePathTable(samplePath);
1243 if isempty(samplePath)
1244 line_error(mfilename,
'Sample path cannot be empty.');
1247 % Initialize
if needed
1248 if isempty(self.envObj.probEnv)
1252 E = self.getNumberOfModels;
1253 M = self.sn{1}.nstations;
1254 K = self.sn{1}.nclasses;
1255 nSegments = size(samplePath, 1);
1256 segmentResults = cell(nSegments, 1);
1258 % Initialize queue lengths (uniform distribution
for closed
classes)
1259 Q_current = zeros(M, K);
1261 if self.sn{1}.njobs(k) > 0 % closed
class
1262 Q_current(:, k) = self.sn{1}.njobs(k) / M;
1266 % Process each segment
1267 for seg = 1:nSegments
1269 stageSpec = samplePath{seg, 1};
1270 duration = samplePath{seg, 2};
1272 if ischar(stageSpec) || isstring(stageSpec)
1273 e = self.envObj.envGraph.findnode(stageSpec);
1275 line_error(mfilename, sprintf(
'Stage "%s" not found.', stageSpec));
1277 stageName = char(stageSpec);
1281 line_error(mfilename, sprintf(
'Stage index %d out of range [1, %d].', e, E));
1283 stageName = self.envObj.envGraph.Nodes.Name{e};
1287 line_error(mfilename,
'Duration must be positive.');
1290 % Initialize model from current queue lengths
1291 self.ensemble{e}.initFromMarginal(Q_current);
1293 % Set solver timespan
1294 self.solvers{e}.options.timespan = [0, duration];
1295 self.solvers{e}.reset();
1297 % Run transient analysis
1298 [Qt, Ut, Tt] = self.ensemble{e}.getTranHandles;
1299 [QNt, UNt, TNt] = self.solvers{e}.getTranAvg(Qt, Ut, Tt);
1301 % Extract initial and
final metrics
1302 initQ = zeros(M, K);
1303 initU = zeros(M, K);
1304 initT = zeros(M, K);
1305 finalQ = zeros(M, K);
1306 finalU = zeros(M, K);
1307 finalT = zeros(M, K);
1315 if isstruct(Qir) && isfield(Qir,
'metric') && ~isempty(Qir.metric)
1316 initQ(i, r) = Qir.metric(1);
1317 finalQ(i, r) = Qir.metric(end);
1319 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
1320 initU(i, r) = Uir.metric(1);
1321 finalU(i, r) = Uir.metric(end);
1323 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
1324 initT(i, r) = Tir.metric(1);
1325 finalT(i, r) = Tir.metric(end);
1330 % Store segment results
1331 segmentResults{seg}.stage = e;
1332 segmentResults{seg}.stageName = stageName;
1333 segmentResults{seg}.duration = duration;
1334 segmentResults{seg}.QNt = QNt;
1335 segmentResults{seg}.UNt = UNt;
1336 segmentResults{seg}.TNt = TNt;
1337 segmentResults{seg}.initQ = initQ;
1338 segmentResults{seg}.initU = initU;
1339 segmentResults{seg}.initT = initT;
1340 segmentResults{seg}.finalQ = finalQ;
1341 segmentResults{seg}.finalU = finalU;
1342 segmentResults{seg}.finalT = finalT;
1344 % Update current queue lengths
for next segment
1348 % Build output table
1361 for seg = 1:nSegments
1362 res = segmentResults{seg};
1365 % Only include rows with non-zero metrics
1366 if any([res.initQ(i,r), res.initU(i,r), res.initT(i,r), ...
1367 res.finalQ(i,r), res.finalU(i,r), res.finalT(i,r)] > 0)
1368 Segment(end+1, 1) = seg;
1369 Stage{end+1, 1} = res.stageName;
1370 Duration(end+1, 1) = res.duration;
1371 Station{end+1, 1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(i)};
1372 JobClass{end+1, 1} = self.sn{1}.classnames{r};
1373 InitQLen(end+1, 1) = res.initQ(i, r);
1374 InitUtil(end+1, 1) = res.initU(i, r);
1375 InitTput(end+1, 1) = res.initT(i, r);
1376 FinalQLen(end+1, 1) = res.finalQ(i, r);
1377 FinalUtil(end+1, 1) = res.finalU(i, r);
1378 FinalTput(end+1, 1) = res.finalT(i, r);
1384 Stage = categorical(Stage);
1385 Station = categorical(Station);
1386 JobClass = categorical(JobClass);
1387 T = Table(Segment, Stage, Duration, Station, JobClass, ...
1388 InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput);
1390 function [allMethods] = listValidMethods(self)
1391 % allMethods = LISTVALIDMETHODS()
1392 % List valid methods
for this solver
1393 sn = self.model.getStruct();
1394 allMethods = {
'default'};
1397 function Q = roundMarginalForDiscreteSolver(self, Q, snRef)
1398 % ROUNDMARGINALFORDISCRETESOLVER Round fractional queue lengths
1399 % to integers
using the largest remainder method, preserving
1400 % closed chain populations exactly.
1401 for c = 1:snRef.nchains
1402 chainClasses = find(snRef.chains(c,:) > 0);
1403 njobs_chain = sum(snRef.njobs(chainClasses));
1404 if isinf(njobs_chain)
1405 % Open chain: simple rounding
1406 for idx = 1:length(chainClasses)
1407 k = chainClasses(idx);
1409 Q(i,k) = round(Q(i,k));
1413 % Closed chain: largest remainder method
1417 for idx = 1:length(chainClasses)
1418 k = chainClasses(idx);
1419 vals(end+1) = Q(i,k);
1420 indices(end+1,:) = [i, k];
1423 floored = floor(vals);
1424 remainders = vals - floored;
1425 deficit = njobs_chain - sum(floored);
1426 deficit = round(deficit); % ensure integer
1428 [~, sortIdx] = sort(remainders, 'descend');
1429 for d = 1:min(deficit, length(sortIdx))
1430 floored(sortIdx(d)) = floored(sortIdx(d)) + 1;
1433 for j = 1:length(vals)
1434 Q(indices(j,1), indices(j,2)) = floored(j);
1443 function [
bool, featSupported] = supports(model)
1444 % [BOOL, FEATSUPPORTED] = SUPPORTS(MODEL)
1446 featUsed = model.getUsedLangFeatures();
1448 featSupported = SolverFeatureSet;
1451 featSupported.setTrue('ClassSwitch');
1452 featSupported.setTrue('Delay');
1453 featSupported.setTrue('DelayStation');
1454 featSupported.setTrue('Queue');
1455 featSupported.setTrue('Sink');
1456 featSupported.setTrue('Source');
1459 featSupported.setTrue('Coxian');
1460 featSupported.setTrue('Cox2');
1461 featSupported.setTrue('Erlang');
1462 featSupported.setTrue('Exp');
1463 featSupported.setTrue('HyperExp');
1466 featSupported.setTrue('StatelessClassSwitcher'); % Section
1467 featSupported.setTrue('InfiniteServer'); % Section
1468 featSupported.setTrue('SharedServer'); % Section
1469 featSupported.setTrue('Buffer'); % Section
1470 featSupported.setTrue('Dispatcher'); % Section
1471 featSupported.setTrue('Server'); % Section (Non-preemptive)
1472 featSupported.setTrue('JobSink'); % Section
1473 featSupported.setTrue('RandomSource'); % Section
1474 featSupported.setTrue('ServiceTunnel'); % Section
1476 % Scheduling strategy
1477 featSupported.setTrue('SchedStrategy_INF');
1478 featSupported.setTrue('SchedStrategy_PS');
1479 featSupported.setTrue('SchedStrategy_FCFS');
1480 featSupported.setTrue('RoutingStrategy_PROB');
1481 featSupported.setTrue('RoutingStrategy_RAND');
1482 featSupported.setTrue('RoutingStrategy_RROBIN'); % with SolverJMT
1485 featSupported.setTrue('ClosedClass');
1486 featSupported.setTrue('OpenClass');
1488 bool = SolverFeatureSet.supports(featSupported, featUsed);
1493 % ensemble solver options
1494 function options = defaultOptions()
1495 % OPTIONS = DEFAULTOPTIONS()
1496 options = SolverOptions('ENV');
1499 function libs = getLibrariesUsed(sn, options)
1500 % GETLIBRARIESUSED Get list of external libraries used by ENV solver
1501 % ENV uses internal algorithms, no external library attribution needed