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 SMPMethod = false; % Use DTMC-based computation for Semi-Markov Processes
51 % Enhanced init properties (aligned with JAR)
52 ServerNum; % Cell array of server counts per
class
53 SRates; % Cell array of service rates per
class
55 Eutil; % Infinitesimal generator
56 transitionCdfs; % Transition CDF functions
57 sojournCdfs; % Sojourn time CDF functions
58 dtmcP; % DTMC transition matrix
59 holdTimeMatrix; % Hold time matrix
60 newMethod =
false; % Use DTMC-based computation
61 compression = false; % Use Courtois decomposition
62 compressionResult; % Results from Courtois decomposition
63 Ecompress; % Number of macro-states after compression
64 MS; % Macro-state partition
65 % Analyzer strategy: which inter-stage coupling to use.
66 analyzerMode = 'meanfield'; %
'meanfield' (
default) |
'statevec'
67 analyzerFcn = @solver_env_meanfield_analyzer; % phase-dispatched analyzer handle
68 % State-vector analyzer working data (options.method='statevec' only)
69 Qgen; % Cell{E}: per-stage infinitesimal generator
70 SS; % Cell{E}: per-stage state space (rows = states)
71 SSaggr; % Cell{E}: per-stage aggregated (marginal) state space
72 piEnter; % Cell{E}: per-stage entry distribution (row vector)
73 piEnterPrev; % Cell{E}: previous-iteration entry distribution (convergence)
74 piExitDest; % Cell{E}{E}: per-stage exit distribution toward each destination
75 piTimeAvg; % Cell{E}: per-stage sojourn-end distribution used in
the blend
76 statevecData;% Cell{E}:
struct(arvRates,depRates,sn,options) per stage
77 resetStateFun; % Cell{E,E}: state-vector reset maps (default identity)
81 function self = SolverENV(renv, solverFactory, options)
82 % SELF = SOLVERENV(ENV,SOLVERFACTORY,OPTIONS)
83 self@EnsembleSolver(renv, mfilename);
84 if nargin>=3 %exist(
'options',
'var')
85 self.setOptions(options);
87 self.setOptions(SolverENV.defaultOptions);
90 % Enable SMP method if specified in options
91 if isfield(self.options, 'method') && strcmpi(self.options.method, 'smp')
92 self.SMPMethod = true;
93 line_debug('ENV solver: SMP method enabled via options.method=''smp''');
97 self.ensemble = renv.getEnsemble;
100 for e=1:length(self.env)
101 self.sn{e} = self.ensemble{e}.getStruct;
102 self.setSolver(solverFactory(self.ensemble{e}),e);
105 for e=1:length(self.env)
106 for h=1:length(self.env)
107 self.resetFromMarginal{e,h} = renv.resetFun{e,h};
111 for e=1:length(self.env)
112 for h=1:length(self.env)
113 self.resetEnvRates{e,h} = renv.resetEnvRatesFun{e,h};
117 % State-vector reset maps (statevec analyzer). Default to identity
118 % for any (e,h)
the environment left unset.
119 for e=1:length(self.env)
120 for h=1:length(self.env)
121 if ~isempty(renv.resetStateFun) && e<=size(renv.resetStateFun,1) ...
122 && h<=size(renv.resetStateFun,2) && ~isempty(renv.resetStateFun{e,h})
123 self.resetStateFun{e,h} = renv.resetStateFun{e,h};
125 self.resetStateFun{e,h} = @(pi) pi;
130 for e=1:length(self.env)
131 for h=1:length(self.env)
132 if isa(self.env{e,h},
'Disabled')
133 self.env{e,h} = Exp(0);
134 elseif ~isa(self.env{e,h},
'Markovian') && ~self.SMPMethod
135 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));
140 for e=1:length(self.ensemble)
141 if ~self.
solvers{e}.supports(self.ensemble{e})
142 line_error(mfilename,sprintf(
'Model in the environment stage %d is not supported by the %s solver.',e,self.getName));
147 function setStateDepMethod(self, method)
148 % SETSTATEDEPMETHOD(METHOD) Sets
the state-dependent method
150 line_error(mfilename, 'State-dependent method cannot be null or empty.');
152 self.stateDepMethod = method;
155 function setNewMethod(self, flag)
156 % SETNEWMETHOD(FLAG) Enable/disable DTMC-based computation
157 self.newMethod = flag;
160 function setCompression(self, flag)
161 % SETCOMPRESSION(FLAG) Enable/disable Courtois decomposition
162 self.compression = flag;
165 function [p, eps, epsMax, q] = ctmc_decompose(self, Q, MS)
166 % CTMC_DECOMPOSE Perform CTMC decomposition
using configured method
167 % [p, eps, epsMax, q] = CTMC_DECOMPOSE(Q, MS)
169 % Uses options.config.decomp to select
the decomposition algorithm:
170 %
'courtois' - Courtois decomposition (default)
171 % 'kms' - Koury-McAllister-Stewart method
172 % 'takahashi' - Takahashi's method
173 % 'multi' - Multigrid method (requires MSS)
176 % p - steady-state probability vector
178 % epsMax - max acceptable eps value
179 % q - randomization coefficient
181 % Get decomposition/aggregation method from options
182 if isfield(self.options, 'config') && isfield(self.options.config, 'da')
183 method = self.options.config.da;
188 % Get numsteps
for iterative methods
189 if isfield(self.options,
'config') && isfield(self.options.config,
'da_iter')
190 numsteps = self.options.config.da_iter;
197 [p, ~, ~, eps, epsMax, ~, ~, ~, q] = ctmc_courtois(Q, MS);
199 [p, ~, ~, eps, epsMax] = ctmc_kms(Q, MS, numsteps);
200 q = 1.05 * max(max(abs(Q)));
202 [p, ~, ~, ~, eps, epsMax] = ctmc_takahashi(Q, MS, numsteps);
203 q = 1.05 * max(max(abs(Q)));
205 % Multi requires MSS (macro-macro-states), default to singletons
206 nMacro = size(MS, 1);
207 MSS = cell(nMacro, 1);
211 [p, ~, ~, ~, eps, epsMax] = ctmc_multi(Q, MS, MSS);
212 q = 1.05 * max(max(abs(Q)));
214 line_error(mfilename, sprintf(
'Unknown decomposition method: %s', method));
218 function
bool = converged(self, it)
219 % BOOL = CONVERGED(IT) Convergence test, delegated to
the active analyzer.
220 bool = self.analyzerFcn(self,
'converged', it, []);
224 function runAnalyzer(self)
226 % Run
the ensemble solver iteration
227 line_debug(
'ENV solver starting: nstages=%d, method=%s', self.getNumberOfModels, self.options.method);
229 % Show library attribution
if verbose and not yet shown
230 if self.options.verbose ~= VerboseLevel.SILENT && ~GlobalConstants.isLibraryAttributionShown()
231 libs = SolverENV.getLibrariesUsed([], self.options);
233 line_printf(
'The solver will leverage %s.\n', strjoin(libs,
', '));
234 GlobalConstants.setLibraryAttributionShown(
true);
238 % Closed-form fast/slow environment limits bypass
the transient
239 % mean-field/statevec iteration (see solveEnvLimit).
240 if isfield(self.options,'method') && any(strcmpi(self.options.method,{
'avg',
'dec'}))
241 self.solveEnvLimit();
250 % Initialize
the environment solver with enhanced data structures
251 % aligned with JAR SolverEnv implementation
252 line_debug(
'ENV solver init: initializing environment data structures');
253 options = self.options;
254 if isfield(options,
'seed')
255 Solver.resetRandomGeneratorSeed(options.seed);
259 % Initialize ServerNum and SRates matrices (aligned with JAR).
260 % These flat station x class server/rate tables are consumed only
261 % by
the state-vector (CTMC) analyzer and are undefined for a
262 % LayeredNetworkStruct stage; skip them when
the stage struct
is
263 % not a flat NetworkStruct (e.g. LQN stages under
the meanfield
265 E = self.getNumberOfModels;
266 if isfield(self.sn{1},
'nstations')
267 M = self.sn{1}.nstations;
268 K = self.sn{1}.nclasses;
270 self.ServerNum = cell(K, 1);
271 self.SRates = cell(K, 1);
273 self.ServerNum{k} = zeros(M, E);
274 self.SRates{k} = zeros(M, E);
277 self.ServerNum{k}(m, e) = self.sn{e}.nservers(m);
278 self.SRates{k}(m, e) = self.sn{e}.rates(m, k);
284 % Build rate matrix E0
285 self.E0 = zeros(E, E);
288 if ~isa(self.envObj.env{e,h},
'Disabled')
289 self.E0(e, h) = self.envObj.env{e,h}.getRate();
293 self.Eutil = ctmc_makeinfgen(self.E0);
295 % Initialize transition CDFs
296 self.transitionCdfs = cell(E, E);
299 if ~isa(self.envObj.env{e,h},
'Disabled')
300 envDist = self.envObj.env{e,h};
301 self.transitionCdfs{e,h} = @(t) envDist.evalCDF(t);
303 self.transitionCdfs{e,h} = @(t) 0;
308 % Initialize sojourn CDFs
309 self.sojournCdfs = cell(E, 1);
311 self.sojournCdfs{e} = @(t) self.computeSojournCdf(e, t);
314 % Select
the inter-stage coupling analyzer. The state-vector
315 % analyzer carries
the full per-stage distribution (no
316 % initFromMarginal) and requires a CTMC inner solver that can expose
317 %
the stage generator; everything
else uses
the default mean-field
318 % (marginal mean-queue-length) coupling.
319 if isfield(self.options,'method') && any(strcmpi(self.options.method,{
'statevec',
'blend'}))
320 % The state-vector analyzer (exposed also as 'blend') needs a
321 % single per-stage CTMC generator; an LQN stage decomposes into
322 % multiple layer submodels and exposes no single generator, so
323 % it
is supported only by
the mean-field analyzer.
325 if isa(self.ensemble{e}, 'LayeredNetwork')
326 line_error(mfilename, sprintf(['The state-vector (statevec) analyzer does not support ' ...
327 'LayeredNetwork stages (stage %d): an LQN has no single stage generator. ' ...
328 'Use the default mean-field analyzer (omit method=''statevec'').'], e));
331 self.analyzerMode =
'statevec';
332 self.analyzerFcn = @solver_env_statevec_analyzer;
334 self.analyzerMode =
'meanfield';
335 self.analyzerFcn = @solver_env_meanfield_analyzer;
338 % newMethod: Use DTMC-based computation instead of CTMC
339 % Verified numerical integration for Semi-Markov Process DTMC transition probabilities
341 line_debug('ENV using DTMC-based computation (newMethod=true)');
342 self.dtmcP = zeros(E, E);
345 if k == e || isa(self.envObj.env{k,e},
'Disabled')
346 self.dtmcP(k, e) = 0.0;
348 % Compute
the upper limit of
the sojourn time
351 while self.transitionCdfs{k,e}(T) < 1.0 - epsilon
353 if T > 1e6 % safety limit
357 % Adaptive number of integration intervals based on T
358 N = max(1000, round(T * 100));
364 deltaF = self.transitionCdfs{k,e}(t1) - self.transitionCdfs{k,e}(t0);
367 if h ~= k && h ~= e && ~isa(self.envObj.env{k,h},
'Disabled')
368 % Use midpoint
for better accuracy
369 tmid = (t0 + t1) / 2.0;
370 survival = survival * (1.0 - self.envObj.env{k,h}.evalCDF(tmid));
373 sumVal = sumVal + deltaF * survival;
375 self.dtmcP(k, e) = sumVal;
380 % Solve DTMC
for stationary distribution
381 dtmcPie = dtmc_solve(self.dtmcP);
383 % Calculate hold times
using numerical integration
384 self.holdTimeMatrix = self.computeHoldTime(E);
386 % Compute steady-state probabilities
390 denomSum = denomSum + dtmcPie(e) * self.holdTimeMatrix(e);
393 pi(k) = dtmcPie(k) * self.holdTimeMatrix(k) / denomSum;
395 self.envObj.probEnv = pi;
397 % Update embedding weights
398 newEmbweight = zeros(E, E);
403 sumVal = sumVal + pi(h) * self.E0(h, e);
408 newEmbweight(k, e) = 0;
411 newEmbweight(k, e) = pi(k) * self.E0(k, e) / sumVal;
416 self.envObj.probOrig = newEmbweight;
419 % Compression: Use Courtois decomposition
for large environments
421 line_debug(
'ENV using compression (Courtois decomposition)');
422 self.applyCompression(E, M, K);
426 function applyCompression(self, E, M, K)
427 % APPLYCOMPRESSION Apply Courtois decomposition to reduce environment size
428 % This method finds a good partition of
the environment states and
429 % creates compressed macro-state networks.
431 % Find best partition
433 self.MS = self.findBestPartition(E);
435 % Beam search
for large environments
436 self.MS = self.beamSearchPartition(E);
440 % No compression possible, use singletons
441 self.MS = cell(E, 1);
449 self.Ecompress = length(self.MS);
451 % Apply decomposition/aggregation
452 [p, eps, epsMax, q] = self.ctmc_decompose(self.Eutil, self.MS);
455 line_warning(mfilename,
'Environment cannot be effectively compressed (eps > epsMax).');
458 % Store compression results
459 self.compressionResult.p = p;
460 self.compressionResult.eps = eps;
461 self.compressionResult.epsMax = epsMax;
462 self.compressionResult.q = q;
464 % Update probEnv with macro-state probabilities
465 pMacro = zeros(1, self.Ecompress);
466 for i = 1:self.Ecompress
467 pMacro(i) = sum(p(self.MS{i}));
469 self.envObj.probEnv = pMacro;
471 % Compute micro-state probabilities within each macro-state
472 pmicro = zeros(E, 1);
473 for i = 1:self.Ecompress
474 blockProb = p(self.MS{i});
475 if sum(blockProb) > 0
476 pmicro(self.MS{i}) = blockProb / sum(blockProb);
479 self.compressionResult.pmicro = pmicro;
480 self.compressionResult.pMacro = pMacro;
482 % Update embedding weights
for macro-states
483 Ecomp = self.Ecompress;
484 newEmbweight = zeros(Ecomp, Ecomp);
489 sumVal = sumVal + pMacro(h) * self.computeMacroRate(h, e);
494 newEmbweight(k, e) = 0;
496 newEmbweight(k, e) = pMacro(k) * self.computeMacroRate(k, e) / sumVal;
500 self.envObj.probOrig = newEmbweight;
502 % Build macro-state networks with weighted-average rates
503 macroEnsemble = cell(self.Ecompress, 1);
504 macroSolvers = cell(self.Ecompress, 1);
505 macroSn = cell(self.Ecompress, 1);
507 for i = 1:self.Ecompress
508 % Copy
the first micro-state network
509 firstMicro = self.MS{i}(1);
510 macroEnsemble{i} = self.ensemble{firstMicro}.copy();
512 % Compute weighted-average rates
516 for r = 1:length(self.MS{i})
517 microIdx = self.MS{i}(r);
518 w = pmicro(microIdx);
519 rateSum = rateSum + w * self.sn{microIdx}.rates(m, k);
522 % Update service rate
523 jobclass = macroEnsemble{i}.classes{k};
524 station = macroEnsemble{i}.stations{m};
525 if isa(station,
'Queue') || isa(station,
'Delay')
527 station.setService(
jobclass, Exp(rateSum));
533 macroEnsemble{i}.refreshStruct(
true);
534 macroSn{i} = macroEnsemble{i}.getStruct(
true);
536 % Create solver
for macro-state
537 % Use
the same solver factory pattern as original
538 macroSolvers{i} = SolverFluid(macroEnsemble{i}, self.solvers{firstMicro}.options);
541 % Replace ensemble and
solvers with compressed versions
542 self.ensemble = macroEnsemble;
543 self.solvers = macroSolvers;
547 function rate = computeMacroRate(self, fromMacro, toMacro)
548 % COMPUTEMACRORATE Compute transition rate between macro-states
550 for i = 1:length(self.MS{fromMacro})
551 mi = self.MS{fromMacro}(i);
552 for j = 1:length(self.MS{toMacro})
553 mj = self.MS{toMacro}(j);
554 rate = rate + self.compressionResult.pmicro(mi) * self.E0(mi, mj);
559 function MS = findBestPartition(self, E)
560 % FINDBESTPARTITION Find
the best partition
for small environments (E <= 10)
561 % Uses exhaustive search over all possible partitions
563 % Start with singletons
569 [~, bestEps, bestEpsMax] = self.ctmc_decompose(self.Eutil, bestMS);
570 if isempty(bestEps) || isnan(bestEps)
578 testMS = cell(E-1, 1);
582 testMS{idx} = [i, j];
590 [~, testEps, testEpsMax] = self.ctmc_decompose(self.Eutil, testMS);
591 if ~isempty(testEps) && ~isnan(testEps) && testEps < bestEps
593 bestEpsMax = testEpsMax;
600 self.Ecompress = length(MS);
603 function MS = beamSearchPartition(self, E)
604 % BEAMSEARCHPARTITION Beam search
for large environments (E > 10)
607 alpha = 0.01; % Coupling threshold
609 if isfield(self.options, 'config') && isfield(self.options.config, 'env_alpha')
610 alpha = self.options.config.env_alpha;
613 % Initialize with singletons
614 singletons = cell(E, 1);
620 bestSeen = singletons;
621 [~, bestEps] = self.ctmc_decompose(self.Eutil, bestSeen);
623 % Iteratively merge blocks
627 for b = 1:length(beam)
629 nBlocks = length(ms);
631 % Try all pairwise merges
633 for j = (i+1):nBlocks
634 % Create merged partition
635 trial = cell(nBlocks - 1, 1);
639 trial{idx} = [ms{i}(:); ms{j}(:)];
647 [~, childEps, childEpsMax] = self.ctmc_decompose(self.Eutil, trial);
649 if ~isempty(childEps) && ~isnan(childEps) && childEps > 0
650 cost = childEps - childEpsMax + alpha * depth;
651 candidates{end+1} = {trial, cost};
662 if isempty(candidates)
666 % Sort by cost and keep top B
667 costs = cellfun(@(x) x{2}, candidates);
668 [~, sortIdx] = sort(costs);
670 for i = 1:min(B, length(sortIdx))
671 beam{end+1} = candidates{sortIdx(i)}{1};
676 self.Ecompress = length(MS);
679 function holdTime = computeHoldTime(self, E)
680 % COMPUTEHOLDTIME Compute expected holding times
using numerical integration
681 holdTime = zeros(1, E);
683 % Survival function: 1 - sojournCDF
684 surv = @(t) 1 - self.sojournCdfs{k}(t);
686 % Compute upper limit
688 while surv(upperLimit) > 1e-8
689 upperLimit = upperLimit * 2;
690 if upperLimit > 1e6 % safety limit
695 % Simpson
's rule integration
702 tmid = (t0 + t1) / 2;
703 % Simpson's rule: (f(a) + 4*f(mid) + f(b)) * h/6
704 integral = integral + (surv(t0) + 4*surv(tmid) + surv(t1)) * dt / 6;
706 holdTime(k) = integral;
710 function cdf = computeSojournCdf(self, e, t)
711 % COMPUTESOJOURNCDF Compute sojourn time CDF
for environment stage e
712 E = self.getNumberOfModels;
716 surv = surv * (1 - self.transitionCdfs{e,h}(t));
722 function pre(self, it)
723 % PRE(IT) Delegated to
the active analyzer (marginal | statevec).
724 self.analyzerFcn(self,
'pre', it, []);
727 % solves model in stage e
728 function [results_e, runtime] = analyze(self, it, e)
729 % [RESULTS_E, RUNTIME] = ANALYZE(IT, E) Delegated to
the active analyzer.
730 [results_e, runtime] = self.analyzerFcn(self,
'analyze', it, e);
734 function post(self, it)
735 % POST(IT) Delegated to
the active analyzer.
736 self.analyzerFcn(self,
'post', it, []);
739 function finish(self)
740 % FINISH() Delegated to
the active analyzer.
741 self.analyzerFcn(self, 'finish', [], []);
744 function name = getName(self)
750 function [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
751 % [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
753 % Returns
the infinitesimal generator matrices for
the random environment model.
756 % renvInfGen - Combined infinitesimal generator for
the random environment (flattened)
757 % stageInfGen - Cell array of infinitesimal generators for each stage
758 % renvEventFilt - Cell array (E x E) of event filtration matrices for environment transitions
759 % stageEventFilt - Cell array of event filtration matrices for each stage
760 % renvEvents - Cell array of Event objects for environment transitions
761 % stageEvents - Cell array of synchronization maps for each stage
763 E = self.getNumberOfModels;
764 stageInfGen = cell(1,E);
765 stageEventFilt = cell(1,E);
766 stageEvents = cell(1,E);
768 if isa(self.
solvers{e},
'SolverCTMC')
769 [stageInfGen{e}, stageEventFilt{e}, stageEvents{e}] = self.solvers{e}.getGenerator();
771 line_error(mfilename,
'This method requires SolverENV to be instantiated with the CTMC solver.');
775 % Get number of states
for each stage
776 nstates = cellfun(@(g) size(g, 1), stageInfGen);
778 % Get number of phases
for each transition distribution
779 nphases = zeros(E, E);
782 if ~isempty(self.env{i,j}) && ~isa(self.env{i,j},
'Disabled')
783 nphases(i,j) = self.env{i,j}.getNumberOfPhases();
789 % Adjust diagonal (self-transitions have one less phase in
the Kronecker expansion)
790 nphases = nphases - eye(E);
792 % Initialize block cell structure
for the random environment generator
793 renvInfGen = cell(E,E);
796 % Diagonal block: stage infinitesimal generator
797 renvInfGen{e,e} = stageInfGen{e};
800 % Off-diagonal blocks: reset matrices (identity with appropriate dimensions)
801 minStates = min(nstates(e), nstates(h));
802 resetMatrix_eh = sparse(nstates(e), nstates(h));
804 resetMatrix_eh(i,i) = 1;
806 renvInfGen{e,h} = resetMatrix_eh;
811 % Build environment transition events and expand generator with phase structure
812 renvEvents = cell(1,0);
816 % Get D0 (phase generator)
for transition from e to h
817 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
820 proc = self.env{e,h}.getProcess();
823 % Kronecker sum with diagonal block
824 renvInfGen{e,e} = krons(renvInfGen{e,e}, D0);
826 % Get D1 (completion rate matrix) and initial probability vector pie
827 if isempty(self.env{h,e}) || isa(self.env{h,e},
'Disabled') || any(isnan(map_pie(self.env{h,e}.getProcess())))
828 pie = ones(1, nphases(h,e));
830 pie = map_pie(self.env{h,e}.getProcess());
833 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
836 proc = self.env{e,h}.getProcess();
840 % Kronecker product
for off-diagonal block
841 onePhase = ones(nphases(e,h), 1);
842 kronArg = D1 * onePhase * pie;
843 renvInfGen{e,h} = kron(renvInfGen{e,h}, sparse(kronArg));
845 % Create environment transition events
846 for i=1:self.ensemble{e}.getNumberOfNodes
847 renvEvents{1,end+1} = Event(EventType.STAGE, i, NaN, NaN, [e,h]); %#ok<AGROW>
850 % Handle other stages (f != e, f != h)
853 if isempty(self.env{f,h}) || isa(self.env{f,h},
'Disabled') || any(isnan(map_pie(self.env{f,h}.getProcess())))
854 pie_fh = ones(1, nphases(f,h));
856 pie_fh = map_pie(self.env{f,h}.getProcess());
858 oneVec = ones(nphases(e,h), 1);
859 renvInfGen{e,f} = kron(renvInfGen{e,f}, oneVec * pie_fh);
866 % Build
event filtration matrices
for environment transitions
867 % Each renvEventFilt{e,h} isolates transitions from stage e to stage h
868 renvEventFilt = cell(E,E);
874 tmpCell{e1,h1} = renvInfGen{e1,h1};
877 % Zero out diagonal blocks (internal stage transitions)
879 tmpCell{e1,e1} = tmpCell{e1,e1} * 0;
881 % Zero out off-diagonal blocks that don't match (e,h) transition
885 if e1~=h1 % Only zero out off-diagonal entries
886 tmpCell{e1,h1} = tmpCell{e1,h1} * 0;
891 renvEventFilt{e,h} = cell2mat(tmpCell);
895 % Flatten block structure into single matrix and normalize
896 renvInfGen = cell2mat(renvInfGen);
897 renvInfGen = ctmc_makeinfgen(renvInfGen);
900 function varargout = getAvg(varargin)
901 % [QNCLASS, UNCLASS, TNCLASS] = GETAVG()
902 [varargout{1:nargout}] = getEnsembleAvg( varargin{:} );
905 function [QNclass, UNclass, RNclass, TNclass, ANclass, WNclass] = getEnsembleAvg(self)
906 % [QNCLASS, UNCLASS, TNCLASS] = GETENSEMBLEAVG()
911 if isfield(self.options,'lang') && strcmp(self.options.lang,'python')
912 [QNclass, UNclass, TNclass] = PYLINE.getEnvAvg(self.envObj, self.options);
913 WNclass = QNclass ./ TNclass;
914 RNclass = NaN*WNclass;
915 ANclass = NaN*TNclass;
916 self.result.Avg.Q = QNclass;
917 self.result.Avg.U = UNclass;
918 self.result.Avg.T = TNclass;
922 if isempty(self.result) || (isfield(self.options,'force') && self.options.force)
923 if isfield(self.options,'method') && any(strcmpi(self.options.method,{
'avg',
'dec'}))
924 self.solveEnvLimit();
928 if isempty(self.result)
935 QNclass = self.result.Avg.Q;
936 UNclass = self.result.Avg.U;
937 TNclass = self.result.Avg.T;
938 WNclass = QNclass ./ TNclass;
939 RNclass = NaN*WNclass;
940 ANclass = NaN*TNclass;
943 function solveEnvLimit(self)
944 % SOLVEENVLIMIT() Closed-form fast/slow random-environment limits.
946 % These treat
the environment (stage) process as either infinitely
947 % fast or infinitely slow relative to
the base-model dynamics, and
948 % therefore require no inter-stage coupling iteration:
950 % 'avg' (fast-environment limit):
the base model sees
the
951 % stationary-probability-weighted average of
the modulated
952 % rates. A single rate-averaged model
is built and solved
953 % once. Exact as
the stage-switching rate -> Inf.
955 % 'dec' (slow-environment / quasi-stationary decomposition): each
956 % stage
is solved independently in steady state and
the
957 % per-stage metrics are averaged with weights probEnv(e).
958 % Exact as
the stage-switching rate -> 0.
960 % Both populate self.result.Avg.{Q,U,T};
for stateful Cache
nodes
961 %
the aggregated hit/miss ratios are written back onto
the stage-1
962 % reference model (self.ensemble{1}), so
963 % self.ensemble{1}.getNodeByName(
'Cache').getHitRatio() returns
the
964 % environment-aggregated value.
966 E = self.getNumberOfModels;
967 probEnv = self.envObj.probEnv(:);
968 M = self.ensemble{1}.getNumberOfStations;
969 K = self.ensemble{1}.getNumberOfClasses;
970 method = lower(self.options.method);
972 Qval = zeros(M,K); Uval = zeros(M,K); Tval = zeros(M,K);
973 nnodes = length(self.ensemble{1}.nodes);
974 cacheIdx = find(cellisa(self.ensemble{1}.nodes,
'Cache'))
';
975 cacheHit = cell(1,nnodes);
976 cacheMiss = cell(1,nnodes);
977 cacheHitL = cell(1,nnodes);
982 se = self.solvers{e};
984 [Qe,Ue,~,Te] = se.getAvg();
985 Qval = Qval + probEnv(e)*Qe;
986 Uval = Uval + probEnv(e)*Ue;
987 Tval = Tval + probEnv(e)*Te;
988 se.getAvgNodeTable(); % populate cache metrics on stage e
990 cacheNode = self.ensemble{e}.getNodeByIndex(c);
991 [cacheHit, cacheMiss, cacheHitL] = SolverENV.accumCacheMetric(...
992 cacheHit, cacheMiss, cacheHitL, c, cacheNode, probEnv(e));
996 avgModel = self.buildRateAveragedModel(probEnv);
997 innerSolver = feval(class(self.solvers{1}), avgModel, self.solvers{1}.options);
998 [Qval,Uval,~,Tval] = innerSolver.getAvg();
999 innerSolver.getAvgNodeTable(); % populate cache metrics
1001 cacheNode = avgModel.getNodeByIndex(c);
1002 [cacheHit, cacheMiss, cacheHitL] = SolverENV.accumCacheMetric(...
1003 cacheHit, cacheMiss, cacheHitL, c, cacheNode, 1.0);
1006 line_error(mfilename, sprintf('solveEnvLimit called with unsupported method %s.
', method));
1009 % Write aggregated cache metrics onto the stage-1 reference model
1011 refCache = self.ensemble{1}.getNodeByIndex(c);
1012 if ~isempty(cacheHit{c}); refCache.setResultHitProb(cacheHit{c}); end
1013 if ~isempty(cacheMiss{c}); refCache.setResultMissProb(cacheMiss{c}); end
1014 if ~isempty(cacheHitL{c}); refCache.setResultHitProbList(cacheHitL{c}); end
1017 self.result.Avg.Q = Qval;
1018 self.result.Avg.U = Uval;
1019 self.result.Avg.T = Tval;
1022 function avgModel = buildRateAveragedModel(self, probEnv)
1023 % AVGMODEL = BUILDRATEAVERAGEDMODEL(PROBENV) Fast-environment model.
1024 % Replace every environment-modulated (i.e. stage-varying) station
1025 % rate by its probEnv-weighted average, represented as an
1026 % exponential. Non-modulated parameters keep their original
1027 % distribution, so the base model is preserved exactly outside the
1029 E = self.getNumberOfModels;
1030 avgModel = self.ensemble{1}.copy();
1031 M = avgModel.getNumberOfStations;
1032 K = avgModel.getNumberOfClasses;
1034 node = avgModel.stations{i};
1035 if isa(node,'Cache
') || isa(node,'Sink
')
1036 continue % stateful/absorbing nodes carry no service rate
1042 r(e) = self.sn{e}.rates(i,k);
1044 if any(isnan(r)) || any(r<=0)
1045 continue % disabled for some stage: leave as configured
1047 if (max(r)-min(r)) <= 1e-12*max(1,max(r))
1048 continue % not modulated: keep the original distribution
1050 ravg = probEnv(:)'*r(:);
1051 if isa(node,
'Source')
1052 node.setArrival(avgModel.classes{k}, Exp(ravg));
1053 elseif isa(node,
'Queue') || isa(node,
'Delay')
1054 node.setService(avgModel.classes{k}, Exp(ravg));
1058 % The copy inherited a cached NetworkStruct from
the stage model;
1059 % force a hard rebuild so
the averaged rates take effect.
1060 avgModel.refreshStruct(
true);
1063 function [AvgTable,QT,UT,TT] = getAvgTable(self,keepDisabled)
1064 % [AVGTABLE,QT,UT,TT] = GETAVGTABLE(SELF,KEEPDISABLED)
1065 % Return table of average station metrics
1067 if nargin<2 %
if ~exist(
'keepDisabled',
'var')
1068 keepDisabled = false;
1071 [QN,UN,~,TN] = getAvg(self);
1074 Q = self.result.Avg.Q;
1075 U = self.result.Avg.U;
1076 T = self.result.Avg.T;
1077 % Aggregate station/class labels for
the stage-1 layout. For LQN
1078 % stages
the flat NetworkStruct fields are absent, so labels come
1079 % from
the block-diagonal layer networks instead.
1080 [stationLabels, classLabels] = self.aggregateStationClassNames(M, K);
1086 elseif ~keepDisabled
1087 Qval = []; Uval = []; Tval = [];
1092 if any(sum([QN(ist,k),UN(ist,k),TN(ist,k)])>0)
1093 JobClass{end+1,1} = classLabels{k};
1094 Station{end+1,1} = stationLabels{ist};
1095 Qval(end+1) = QN(ist,k);
1096 Uval(end+1) = UN(ist,k);
1097 Tval(end+1) = TN(ist,k);
1101 QLen = Qval(:); % we need to save first in a variable named like
the column
1102 QT = Table(
Station,JobClass,QLen);
1103 Util = Uval(:); % we need to save first in a variable named like
the column
1104 UT = Table(
Station,JobClass,Util);
1105 Tput = Tval(:); % we need to save first in a variable named like
the column
1107 JobClass = categorical(JobClass);
1108 TT = Table(
Station,JobClass,Tput);
1109 RespT = QLen ./ Tput;
1110 AvgTable = Table(
Station,JobClass,QLen,Util,RespT,Tput);
1112 Qval = zeros(M,K); Uval = zeros(M,K);
1113 JobClass = cell(K*M,1);
1117 JobClass{(ist-1)*K+k} = Q{ist,k}.class.name;
1118 Station{(ist-1)*K+k} = Q{ist,k}.station.name;
1119 Qval((ist-1)*K+k) = QN(ist,k);
1120 Uval((ist-1)*K+k) = UN(ist,k);
1121 Tval((ist-1)*K+k) = TN(ist,k);
1125 JobClass = categorical(JobClass);
1126 QLen = Qval(:); % we need to save first in a variable named like
the column
1127 QT = Table(
Station,JobClass,QLen);
1128 Util = Uval(:); % we need to save first in a variable named like
the column
1129 UT = Table(
Station,JobClass,Util);
1130 Tput = Tval(:); % we need to save first in a variable named like
the column
1131 TT = Table(
Station,JobClass,Tput);
1132 RespT = QLen ./ Tput;
1133 AvgTable = Table(
Station,JobClass,QLen,Util,RespT,Tput);
1137 function envsn = getStruct(self)
1138 E = self.getNumberOfModels;
1141 envsn{e} = self.ensemble{e}.getStruct;
1145 function [T, segmentResults] = getSamplePathTable(self, samplePath)
1146 % [T, SEGMENTRESULTS] = GETSAMPLEPATHTABLE(SELF, SAMPLEPATH)
1148 % Compute transient performance metrics
for a sample path through
1149 % environment states. The method runs transient analysis
for each
1150 % segment and extracts initial and
final metric values.
1153 % samplePath - Cell array where each row
is {stage, duration}
1154 % stage: string (name) or integer (1-based index)
1155 % duration: positive scalar (time spent in stage)
1158 % T - Table with columns: Segment, Stage, Duration, Station, JobClass,
1159 % InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput
1160 % segmentResults - Cell array with detailed transient results per segment
1163 % samplePath = {
'Fast', 5.0;
'Slow', 10.0;
'Fast', 3.0};
1164 % [T, results] = solver.getSamplePathTable(samplePath);
1167 if isempty(samplePath)
1168 line_error(mfilename,
'Sample path cannot be empty.');
1171 % Initialize
if needed
1172 if isempty(self.envObj.probEnv)
1176 E = self.getNumberOfModels;
1177 M = self.sn{1}.nstations;
1178 K = self.sn{1}.nclasses;
1179 nSegments = size(samplePath, 1);
1180 segmentResults = cell(nSegments, 1);
1182 % Initialize queue lengths (uniform distribution
for closed classes)
1183 Q_current = zeros(M, K);
1185 if self.sn{1}.njobs(k) > 0 % closed
class
1186 Q_current(:, k) = self.sn{1}.njobs(k) / M;
1190 % Process each segment
1191 for seg = 1:nSegments
1193 stageSpec = samplePath{seg, 1};
1194 duration = samplePath{seg, 2};
1196 if ischar(stageSpec) || isstring(stageSpec)
1197 e = self.envObj.envGraph.findnode(stageSpec);
1199 line_error(mfilename, sprintf(
'Stage "%s" not found.', stageSpec));
1201 stageName = char(stageSpec);
1205 line_error(mfilename, sprintf(
'Stage index %d out of range [1, %d].', e, E));
1207 stageName = self.envObj.envGraph.Nodes.Name{e};
1211 line_error(mfilename,
'Duration must be positive.');
1214 % Initialize model from current queue lengths
1215 self.ensemble{e}.initFromMarginal(Q_current);
1217 % Set solver timespan
1218 self.solvers{e}.options.timespan = [0, duration];
1219 self.solvers{e}.reset();
1221 % Run transient analysis
1222 [Qt, Ut, Tt] = self.ensemble{e}.getTranHandles;
1223 [QNt, UNt, TNt] = self.solvers{e}.getTranAvg(Qt, Ut, Tt);
1225 % Extract initial and
final metrics
1226 initQ = zeros(M, K);
1227 initU = zeros(M, K);
1228 initT = zeros(M, K);
1229 finalQ = zeros(M, K);
1230 finalU = zeros(M, K);
1231 finalT = zeros(M, K);
1239 if isstruct(Qir) && isfield(Qir,
'metric') && ~isempty(Qir.metric)
1240 initQ(i, r) = Qir.metric(1);
1241 finalQ(i, r) = Qir.metric(end);
1243 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
1244 initU(i, r) = Uir.metric(1);
1245 finalU(i, r) = Uir.metric(end);
1247 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
1248 initT(i, r) = Tir.metric(1);
1249 finalT(i, r) = Tir.metric(end);
1254 % Store segment results
1255 segmentResults{seg}.stage = e;
1256 segmentResults{seg}.stageName = stageName;
1257 segmentResults{seg}.duration = duration;
1258 segmentResults{seg}.QNt = QNt;
1259 segmentResults{seg}.UNt = UNt;
1260 segmentResults{seg}.TNt = TNt;
1261 segmentResults{seg}.initQ = initQ;
1262 segmentResults{seg}.initU = initU;
1263 segmentResults{seg}.initT = initT;
1264 segmentResults{seg}.finalQ = finalQ;
1265 segmentResults{seg}.finalU = finalU;
1266 segmentResults{seg}.finalT = finalT;
1268 % Update current queue lengths
for next segment
1272 % Build output table
1285 for seg = 1:nSegments
1286 res = segmentResults{seg};
1289 % Only include rows with non-zero metrics
1290 if any([res.initQ(i,r), res.initU(i,r), res.initT(i,r), ...
1291 res.finalQ(i,r), res.finalU(i,r), res.finalT(i,r)] > 0)
1292 Segment(end+1, 1) = seg;
1293 Stage{end+1, 1} = res.stageName;
1294 Duration(end+1, 1) = res.duration;
1295 Station{end+1, 1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(i)};
1296 JobClass{end+1, 1} = self.sn{1}.classnames{r};
1297 InitQLen(end+1, 1) = res.initQ(i, r);
1298 InitUtil(end+1, 1) = res.initU(i, r);
1299 InitTput(end+1, 1) = res.initT(i, r);
1300 FinalQLen(end+1, 1) = res.finalQ(i, r);
1301 FinalUtil(end+1, 1) = res.finalU(i, r);
1302 FinalTput(end+1, 1) = res.finalT(i, r);
1308 Stage = categorical(Stage);
1310 JobClass = categorical(JobClass);
1311 T = Table(Segment, Stage, Duration,
Station, JobClass, ...
1312 InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput);
1314 function [allMethods] = listValidMethods(self)
1315 % allMethods = LISTVALIDMETHODS()
1316 % List valid methods
for this solver
1317 sn = self.model.getStruct();
1318 allMethods = {
'default'};
1321 function [stationLabels, classLabels] = aggregateStationClassNames(self, M, K)
1322 % [STATIONLABELS, CLASSLABELS] = AGGREGATESTATIONCLASSNAMES(M,K)
1323 % Aggregate station/
class label vectors for
the stage-1 result
1324 % layout. For flat NetworkStruct stages these are
the usual node
1325 % and class names;
for LQN stages
the layout
is the block-diagonal
1326 % union of
the layer networks, so labels are drawn from each layer
1327 % network (prefixed by
the layer name to disambiguate).
1328 stationLabels = cell(M,1);
1329 classLabels = cell(K,1);
1330 if isfield(self.sn{1},
'nstations')
1332 stationLabels{ist} = self.sn{1}.nodenames{self.sn{1}.stationToNode(ist)};
1335 classLabels{k} = self.sn{1}.classnames{k};
1337 elseif isa(self.ensemble{1},
'LayeredNetwork')
1338 model = self.ensemble{1};
1339 [Roff, Coff, Msz, Ksz] = model.layerBlocks;
1340 for e=1:length(model.ensemble)
1341 layer = model.ensemble{e};
1342 lname = layer.getName;
1344 stationLabels{Roff(e)+i} = sprintf('%s.%s', lname, layer.stations{i}.name);
1347 classLabels{Coff(e)+r} = sprintf('%s.%s', lname, layer.classes{r}.name);
1352 stationLabels{ist} = sprintf(
'Station%d', ist);
1355 classLabels{k} = sprintf(
'Class%d', k);
1360 function Q = roundMarginalForDiscreteSolver(self, Q, snRef)
1361 % ROUNDMARGINALFORDISCRETESOLVER Round fractional queue lengths
1362 % to integers
using the largest remainder method, preserving
1363 % closed chain populations exactly.
1364 for c = 1:snRef.nchains
1365 chainClasses = find(snRef.chains(c,:) > 0);
1366 njobs_chain = sum(snRef.njobs(chainClasses));
1367 if isinf(njobs_chain)
1368 % Open chain: simple rounding
1369 for idx = 1:length(chainClasses)
1370 k = chainClasses(idx);
1372 Q(i,k) = round(Q(i,k));
1376 % Closed chain: largest remainder method
1380 for idx = 1:length(chainClasses)
1381 k = chainClasses(idx);
1382 vals(end+1) = Q(i,k);
1383 indices(end+1,:) = [i, k];
1386 floored = floor(vals);
1387 remainders = vals - floored;
1388 deficit = njobs_chain - sum(floored);
1389 deficit = round(deficit); % ensure integer
1391 [~, sortIdx] = sort(remainders, 'descend');
1392 for d = 1:min(deficit, length(sortIdx))
1393 floored(sortIdx(d)) = floored(sortIdx(d)) + 1;
1396 for j = 1:length(vals)
1397 Q(indices(j,1), indices(j,2)) = floored(j);
1406 function [
bool, featSupported] = supports(model)
1407 % [BOOL, FEATSUPPORTED] = SUPPORTS(MODEL)
1409 featUsed = model.getUsedLangFeatures();
1411 featSupported = SolverFeatureSet;
1414 featSupported.setTrue('ClassSwitch');
1415 featSupported.setTrue('Delay');
1416 featSupported.setTrue('DelayStation');
1417 featSupported.setTrue('Queue');
1418 featSupported.setTrue('Sink');
1419 featSupported.setTrue('Source');
1422 featSupported.setTrue('Coxian');
1423 featSupported.setTrue('Cox2');
1424 featSupported.setTrue('Erlang');
1425 featSupported.setTrue('Exp');
1426 featSupported.setTrue('HyperExp');
1429 featSupported.setTrue('StatelessClassSwitcher'); % Section
1430 featSupported.setTrue('InfiniteServer'); % Section
1431 featSupported.setTrue('SharedServer'); % Section
1432 featSupported.setTrue('Buffer'); % Section
1433 featSupported.setTrue('Dispatcher'); % Section
1434 featSupported.setTrue('Server'); % Section (Non-preemptive)
1435 featSupported.setTrue('JobSink'); % Section
1436 featSupported.setTrue('RandomSource'); % Section
1437 featSupported.setTrue('ServiceTunnel'); % Section
1439 % Scheduling strategy
1440 featSupported.setTrue('SchedStrategy_INF');
1441 featSupported.setTrue('SchedStrategy_PS');
1442 featSupported.setTrue('SchedStrategy_FCFS');
1443 featSupported.setTrue('RoutingStrategy_PROB');
1444 featSupported.setTrue('RoutingStrategy_RAND');
1445 featSupported.setTrue('RoutingStrategy_RROBIN'); % with SolverJMT
1448 featSupported.setTrue('ClosedClass');
1449 featSupported.setTrue('OpenClass');
1451 bool = SolverFeatureSet.supports(featSupported, featUsed);
1456 % ensemble solver options
1457 function options = defaultOptions()
1458 % OPTIONS = DEFAULTOPTIONS()
1459 options = SolverOptions('ENV');
1462 function libs = getLibrariesUsed(sn, options)
1463 % GETLIBRARIESUSED Get list of external libraries used by ENV solver
1464 % ENV uses internal algorithms, no external library attribution needed
1468 function [cacheHit, cacheMiss, cacheHitL] = accumCacheMetric(cacheHit, cacheMiss, cacheHitL, c, cacheNode, w)
1469 % Accumulate w-weighted cache hit/miss ratios of node index c
1470 % (used by solveEnvLimit for
the 'avg'/'dec' environment limits).
1471 h = full(cacheNode.getHitRatio);
1473 if isempty(cacheHit{c}); cacheHit{c} = zeros(size(h)); end
1474 cacheHit{c} = cacheHit{c} + w*h;
1476 mval = full(cacheNode.getMissRatio);
1478 if isempty(cacheMiss{c}); cacheMiss{c} = zeros(size(mval)); end
1479 cacheMiss{c} = cacheMiss{c} + w*mval;
1481 hl = cacheNode.getHitRatioByList;
1484 if isempty(cacheHitL{c}); cacheHitL{c} = zeros(size(hl)); end
1485 cacheHitL{c} = cacheHitL{c} + w*hl;