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 (flat station x class tables used
260 % only by the statevec analyzer; skipped for LQN stages).
261 % see _kb/06-solver-catalog.md for rationale
262 E = self.getNumberOfModels;
263 if isfield(self.sn{1},
'nstations')
264 M = self.sn{1}.nstations;
265 K = self.sn{1}.nclasses;
267 self.ServerNum = cell(K, 1);
268 self.SRates = cell(K, 1);
270 self.ServerNum{k} = zeros(M, E);
271 self.SRates{k} = zeros(M, E);
274 self.ServerNum{k}(m, e) = self.sn{e}.nservers(m);
275 self.SRates{k}(m, e) = self.sn{e}.rates(m, k);
281 % Build rate matrix E0
282 self.E0 = zeros(E, E);
285 if ~isa(self.envObj.env{e,h},
'Disabled')
286 self.E0(e, h) = self.envObj.env{e,h}.getRate();
290 self.Eutil = ctmc_makeinfgen(self.E0);
292 % Initialize transition CDFs
293 self.transitionCdfs = cell(E, E);
296 if ~isa(self.envObj.env{e,h},
'Disabled')
297 envDist = self.envObj.env{e,h};
298 self.transitionCdfs{e,h} = @(t) envDist.evalCDF(t);
300 self.transitionCdfs{e,h} = @(t) 0;
305 % Initialize sojourn CDFs
306 self.sojournCdfs = cell(E, 1);
308 self.sojournCdfs{e} = @(t) self.computeSojournCdf(e, t);
311 % Select the inter-stage coupling analyzer (statevec vs default
312 % mean-field). see _kb/06-solver-catalog.md for rationale
313 if isfield(self.options,'method') && any(strcmpi(self.options.method,{
'statevec',
'blend'}))
314 % statevec needs a single per-stage CTMC generator, which an LQN
315 % stage cannot provide. see _kb/06-solver-catalog.md for rationale
317 if isa(self.ensemble{e}, 'LayeredNetwork')
318 line_error(mfilename, sprintf(['The state-vector (statevec) analyzer does not support ' ...
319 'LayeredNetwork stages (stage %d): an LQN has no single stage generator. ' ...
320 'Use the default mean-field analyzer (omit method=''statevec'').'], e));
323 self.analyzerMode =
'statevec';
324 self.analyzerFcn = @solver_env_statevec_analyzer;
326 self.analyzerMode =
'meanfield';
327 self.analyzerFcn = @solver_env_meanfield_analyzer;
330 % newMethod: Use DTMC-based computation instead of CTMC
331 % Verified numerical integration for Semi-Markov Process DTMC transition probabilities
333 line_debug('ENV using DTMC-based computation (newMethod=true)');
334 self.dtmcP = zeros(E, E);
337 if k == e || isa(self.envObj.env{k,e},
'Disabled')
338 self.dtmcP(k, e) = 0.0;
340 % Compute the upper limit of the sojourn time
343 while self.transitionCdfs{k,e}(T) < 1.0 - epsilon
345 if T > 1e6 % safety limit
349 % Adaptive number of integration intervals based on T
350 N = max(1000, round(T * 100));
356 deltaF = self.transitionCdfs{k,e}(t1) - self.transitionCdfs{k,e}(t0);
359 if h ~= k && h ~= e && ~isa(self.envObj.env{k,h},
'Disabled')
360 % Use midpoint
for better accuracy
361 tmid = (t0 + t1) / 2.0;
362 survival = survival * (1.0 - self.envObj.env{k,h}.evalCDF(tmid));
365 sumVal = sumVal + deltaF * survival;
367 self.dtmcP(k, e) = sumVal;
372 % Solve DTMC
for stationary distribution
373 dtmcPie = dtmc_solve(self.dtmcP);
375 % Calculate hold times
using numerical integration
376 self.holdTimeMatrix = self.computeHoldTime(E);
378 % Compute steady-state probabilities
382 denomSum = denomSum + dtmcPie(e) * self.holdTimeMatrix(e);
385 pi(k) = dtmcPie(k) * self.holdTimeMatrix(k) / denomSum;
387 self.envObj.probEnv = pi;
389 % Update embedding weights
390 newEmbweight = zeros(E, E);
395 sumVal = sumVal + pi(h) * self.E0(h, e);
400 newEmbweight(k, e) = 0;
403 newEmbweight(k, e) = pi(k) * self.E0(k, e) / sumVal;
408 self.envObj.probOrig = newEmbweight;
411 % Compression: Use Courtois decomposition
for large environments
413 line_debug(
'ENV using compression (Courtois decomposition)');
414 self.applyCompression(E, M, K);
418 function applyCompression(self, E, M, K)
419 % APPLYCOMPRESSION Apply Courtois decomposition to reduce environment size
420 % This method finds a good partition of the environment states and
421 % creates compressed macro-state networks.
423 % Find best partition
425 self.MS = self.findBestPartition(E);
427 % Beam search
for large environments
428 self.MS = self.beamSearchPartition(E);
432 % No compression possible, use singletons
433 self.MS = cell(E, 1);
441 self.Ecompress = length(self.MS);
443 % Apply decomposition/aggregation
444 [p, eps, epsMax, q] = self.ctmc_decompose(self.Eutil, self.MS);
447 line_warning(mfilename,
'Environment cannot be effectively compressed (eps > epsMax).');
450 % Store compression results
451 self.compressionResult.p = p;
452 self.compressionResult.eps = eps;
453 self.compressionResult.epsMax = epsMax;
454 self.compressionResult.q = q;
456 % Update probEnv with macro-state probabilities
457 pMacro = zeros(1, self.Ecompress);
458 for i = 1:self.Ecompress
459 pMacro(i) = sum(p(self.MS{i}));
461 self.envObj.probEnv = pMacro;
463 % Compute micro-state probabilities within each macro-state
464 pmicro = zeros(E, 1);
465 for i = 1:self.Ecompress
466 blockProb = p(self.MS{i});
467 if sum(blockProb) > 0
468 pmicro(self.MS{i}) = blockProb / sum(blockProb);
471 self.compressionResult.pmicro = pmicro;
472 self.compressionResult.pMacro = pMacro;
474 % Update embedding weights
for macro-states
475 Ecomp = self.Ecompress;
476 newEmbweight = zeros(Ecomp, Ecomp);
481 sumVal = sumVal + pMacro(h) * self.computeMacroRate(h, e);
486 newEmbweight(k, e) = 0;
488 newEmbweight(k, e) = pMacro(k) * self.computeMacroRate(k, e) / sumVal;
492 self.envObj.probOrig = newEmbweight;
494 % Build macro-state networks with weighted-average rates
495 macroEnsemble = cell(self.Ecompress, 1);
496 macroSolvers = cell(self.Ecompress, 1);
497 macroSn = cell(self.Ecompress, 1);
499 for i = 1:self.Ecompress
500 % Copy the first micro-state network
501 firstMicro = self.MS{i}(1);
502 macroEnsemble{i} = self.ensemble{firstMicro}.copy();
504 % Compute weighted-average rates
508 for r = 1:length(self.MS{i})
509 microIdx = self.MS{i}(r);
510 w = pmicro(microIdx);
511 rateSum = rateSum + w * self.sn{microIdx}.rates(m, k);
514 % Update service rate
515 jobclass = macroEnsemble{i}.classes{k};
516 station = macroEnsemble{i}.stations{m};
517 if isa(station,
'Queue') || isa(station,
'Delay')
519 station.setService(
jobclass, Exp(rateSum));
525 macroEnsemble{i}.refreshStruct(
true);
526 macroSn{i} = macroEnsemble{i}.getStruct(
true);
528 % Create solver
for macro-state
529 % Use the same solver factory pattern as original
530 macroSolvers{i} = SolverFluid(macroEnsemble{i}, self.solvers{firstMicro}.options);
533 % Replace ensemble and solvers with compressed versions
534 self.ensemble = macroEnsemble;
535 self.solvers = macroSolvers;
539 function rate = computeMacroRate(self, fromMacro, toMacro)
540 % COMPUTEMACRORATE Compute transition rate between macro-states
542 for i = 1:length(self.MS{fromMacro})
543 mi = self.MS{fromMacro}(i);
544 for j = 1:length(self.MS{toMacro})
545 mj = self.MS{toMacro}(j);
546 rate = rate + self.compressionResult.pmicro(mi) * self.E0(mi, mj);
551 function MS = findBestPartition(self, E)
552 % FINDBESTPARTITION Find the best partition
for small environments (E <= 10)
553 % Uses exhaustive search over all possible partitions
555 % Start with singletons
561 [~, bestEps, bestEpsMax] = self.ctmc_decompose(self.Eutil, bestMS);
562 if isempty(bestEps) || isnan(bestEps)
570 testMS = cell(E-1, 1);
574 testMS{idx} = [i, j];
582 [~, testEps, testEpsMax] = self.ctmc_decompose(self.Eutil, testMS);
583 if ~isempty(testEps) && ~isnan(testEps) && testEps < bestEps
585 bestEpsMax = testEpsMax;
592 self.Ecompress = length(MS);
595 function MS = beamSearchPartition(self, E)
596 % BEAMSEARCHPARTITION Beam search
for large environments (E > 10)
599 alpha = 0.01; % Coupling threshold
601 if isfield(self.options, 'config') && isfield(self.options.config, 'env_alpha')
602 alpha = self.options.config.env_alpha;
605 % Initialize with singletons
606 singletons = cell(E, 1);
612 bestSeen = singletons;
613 [~, bestEps] = self.ctmc_decompose(self.Eutil, bestSeen);
615 % Iteratively merge blocks
619 for b = 1:length(beam)
621 nBlocks = length(ms);
623 % Try all pairwise merges
625 for j = (i+1):nBlocks
626 % Create merged partition
627 trial = cell(nBlocks - 1, 1);
631 trial{idx} = [ms{i}(:); ms{j}(:)];
639 [~, childEps, childEpsMax] = self.ctmc_decompose(self.Eutil, trial);
641 if ~isempty(childEps) && ~isnan(childEps) && childEps > 0
642 cost = childEps - childEpsMax + alpha * depth;
643 candidates{end+1} = {trial, cost};
654 if isempty(candidates)
658 % Sort by cost and keep top B
659 costs = cellfun(@(x) x{2}, candidates);
660 [~, sortIdx] = sort(costs);
662 for i = 1:min(B, length(sortIdx))
663 beam{end+1} = candidates{sortIdx(i)}{1};
668 self.Ecompress = length(MS);
671 function holdTime = computeHoldTime(self, E)
672 % COMPUTEHOLDTIME Compute expected holding times
using numerical integration
673 holdTime = zeros(1, E);
675 % Survival function: 1 - sojournCDF
676 surv = @(t) 1 - self.sojournCdfs{k}(t);
678 % Compute upper limit
680 while surv(upperLimit) > 1e-8
681 upperLimit = upperLimit * 2;
682 if upperLimit > 1e6 % safety limit
687 % Simpson
's rule integration
694 tmid = (t0 + t1) / 2;
695 % Simpson's rule: (f(a) + 4*f(mid) + f(b)) * h/6
696 integral = integral + (surv(t0) + 4*surv(tmid) + surv(t1)) * dt / 6;
698 holdTime(k) = integral;
702 function cdf = computeSojournCdf(self, e, t)
703 % COMPUTESOJOURNCDF Compute sojourn time CDF
for environment stage e
704 E = self.getNumberOfModels;
708 surv = surv * (1 - self.transitionCdfs{e,h}(t));
714 function pre(self, it)
715 % PRE(IT) Delegated to the active analyzer (marginal | statevec).
716 self.analyzerFcn(self,
'pre', it, []);
719 % solves model in stage e
720 function [results_e, runtime] = analyze(self, it, e)
721 % [RESULTS_E, RUNTIME] = ANALYZE(IT, E) Delegated to the active analyzer.
722 [results_e, runtime] = self.analyzerFcn(self,
'analyze', it, e);
726 function post(self, it)
727 % POST(IT) Delegated to the active analyzer.
728 self.analyzerFcn(self,
'post', it, []);
731 function finish(self)
732 % FINISH() Delegated to the active analyzer.
733 self.analyzerFcn(self, 'finish', [], []);
736 function name = getName(self)
742 function [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
743 % [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
745 % Returns the infinitesimal generator matrices for the random environment model.
748 % renvInfGen - Combined infinitesimal generator for the random environment (flattened)
749 % stageInfGen - Cell array of infinitesimal generators for each stage
750 % renvEventFilt - Cell array (E x E) of event filtration matrices for environment transitions
751 % stageEventFilt - Cell array of event filtration matrices for each stage
752 % renvEvents - Cell array of Event objects for environment transitions
753 % stageEvents - Cell array of synchronization maps for each stage
755 E = self.getNumberOfModels;
756 stageInfGen = cell(1,E);
757 stageEventFilt = cell(1,E);
758 stageEvents = cell(1,E);
760 if isa(self.solvers{e},
'SolverCTMC')
761 [stageInfGen{e}, stageEventFilt{e}, stageEvents{e}] = self.solvers{e}.getGenerator();
763 line_error(mfilename,
'This method requires SolverENV to be instantiated with the CTMC solver.');
767 % Get number of states
for each stage
768 nstates = cellfun(@(g) size(g, 1), stageInfGen);
770 % Get number of phases
for each transition distribution
771 nphases = zeros(E, E);
774 if ~isempty(self.env{i,j}) && ~isa(self.env{i,j},
'Disabled')
775 nphases(i,j) = self.env{i,j}.getNumberOfPhases();
781 % Adjust diagonal (self-transitions have one less phase in the Kronecker expansion)
782 nphases = nphases - eye(E);
784 % Initialize block cell structure
for the random environment generator
785 renvInfGen = cell(E,E);
788 % Diagonal block: stage infinitesimal generator
789 renvInfGen{e,e} = stageInfGen{e};
792 % Off-diagonal blocks: reset matrices (identity with appropriate dimensions)
793 minStates = min(nstates(e), nstates(h));
794 resetMatrix_eh = sparse(nstates(e), nstates(h));
796 resetMatrix_eh(i,i) = 1;
798 renvInfGen{e,h} = resetMatrix_eh;
803 % Build environment transition events and expand generator with phase structure
804 renvEvents = cell(1,0);
808 % Get D0 (phase generator)
for transition from e to h
809 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
812 proc = self.env{e,h}.getProcess();
815 % Kronecker sum with diagonal block
816 renvInfGen{e,e} = krons(renvInfGen{e,e}, D0);
818 % Get D1 (completion rate matrix) and initial probability vector pie
819 if isempty(self.env{h,e}) || isa(self.env{h,e},
'Disabled') || any(isnan(map_pie(self.env{h,e}.getProcess())))
820 pie = ones(1, nphases(h,e));
822 pie = map_pie(self.env{h,e}.getProcess());
825 if isempty(self.env{e,h}) || isa(self.env{e,h},
'Disabled')
828 proc = self.env{e,h}.getProcess();
832 % Kronecker product
for off-diagonal block
833 onePhase = ones(nphases(e,h), 1);
834 kronArg = D1 * onePhase * pie;
835 renvInfGen{e,h} = kron(renvInfGen{e,h}, sparse(kronArg));
837 % Create environment transition events
838 for i=1:self.ensemble{e}.getNumberOfNodes
839 renvEvents{1,end+1} = Event(EventType.STAGE, i, NaN, NaN, [e,h]); %#ok<AGROW>
842 % Handle other stages (f != e, f != h)
845 if isempty(self.env{f,h}) || isa(self.env{f,h},
'Disabled') || any(isnan(map_pie(self.env{f,h}.getProcess())))
846 pie_fh = ones(1, nphases(f,h));
848 pie_fh = map_pie(self.env{f,h}.getProcess());
850 oneVec = ones(nphases(e,h), 1);
851 renvInfGen{e,f} = kron(renvInfGen{e,f}, oneVec * pie_fh);
858 % Build
event filtration matrices
for environment transitions
859 % Each renvEventFilt{e,h} isolates transitions from stage e to stage h
860 renvEventFilt = cell(E,E);
866 tmpCell{e1,h1} = renvInfGen{e1,h1};
869 % Zero out diagonal blocks (internal stage transitions)
871 tmpCell{e1,e1} = tmpCell{e1,e1} * 0;
873 % Zero out off-diagonal blocks that don't match (e,h) transition
877 if e1~=h1 % Only zero out off-diagonal entries
878 tmpCell{e1,h1} = tmpCell{e1,h1} * 0;
883 renvEventFilt{e,h} = cell2mat(tmpCell);
887 % Flatten block structure into single matrix and normalize
888 renvInfGen = cell2mat(renvInfGen);
889 renvInfGen = ctmc_makeinfgen(renvInfGen);
892 function varargout = getAvg(varargin)
893 % [QNCLASS, UNCLASS, TNCLASS] = GETAVG()
894 [varargout{1:nargout}] = getEnsembleAvg( varargin{:} );
897 function [QNclass, UNclass, RNclass, TNclass, ANclass, WNclass] = getEnsembleAvg(self)
898 % [QNCLASS, UNCLASS, TNCLASS] = GETENSEMBLEAVG()
903 if isfield(self.options,'lang') && strcmp(self.options.lang,'python')
904 [QNclass, UNclass, TNclass] = PYLINE.getEnvAvg(self.envObj, self.options);
905 WNclass = QNclass ./ TNclass;
906 RNclass = NaN*WNclass;
907 ANclass = NaN*TNclass;
908 self.result.Avg.Q = QNclass;
909 self.result.Avg.U = UNclass;
910 self.result.Avg.T = TNclass;
914 if isempty(self.result) || (isfield(self.options,'force') && self.options.force)
915 if isfield(self.options,'method') && any(strcmpi(self.options.method,{
'avg',
'dec'}))
916 self.solveEnvLimit();
920 if isempty(self.result)
927 QNclass = self.result.Avg.Q;
928 UNclass = self.result.Avg.U;
929 TNclass = self.result.Avg.T;
930 WNclass = QNclass ./ TNclass;
931 RNclass = NaN*WNclass;
932 ANclass = NaN*TNclass;
935 function solveEnvLimit(self)
936 % SOLVEENVLIMIT() Closed-
form fast/slow random-environment limits.
938 % These treat the environment (stage) process as either infinitely
939 % fast or infinitely slow relative to the base-model dynamics, and
940 % therefore require no inter-stage coupling iteration:
942 % 'avg' (fast-environment limit): the base model sees the
943 % stationary-probability-weighted average of the modulated
944 % rates. A single rate-averaged model
is built and solved
945 % once. Exact as the stage-switching rate -> Inf.
947 % 'dec' (slow-environment / quasi-stationary decomposition): each
948 % stage
is solved independently in steady state and the
949 % per-stage metrics are averaged with weights probEnv(e).
950 % Exact as the stage-switching rate -> 0.
952 % Both populate self.result.Avg.{Q,U,T};
for stateful Cache
nodes
953 % the aggregated hit/miss ratios are written back onto the stage-1
954 % reference model (self.ensemble{1}), so
955 % self.ensemble{1}.getNodeByName(
'Cache').getHitRatio() returns the
956 % environment-aggregated value.
958 E = self.getNumberOfModels;
959 probEnv = self.envObj.probEnv(:);
960 M = self.ensemble{1}.getNumberOfStations;
961 K = self.ensemble{1}.getNumberOfClasses;
962 method = lower(self.options.method);
964 Qval = zeros(M,K); Uval = zeros(M,K); Tval = zeros(M,K);
965 nnodes = length(self.ensemble{1}.nodes);
966 cacheIdx = find(cellisa(self.ensemble{1}.nodes,
'Cache'))
';
967 cacheHit = cell(1,nnodes);
968 cacheMiss = cell(1,nnodes);
969 cacheHitL = cell(1,nnodes);
974 se = self.solvers{e};
976 [Qe,Ue,~,Te] = se.getAvg();
977 Qval = Qval + probEnv(e)*Qe;
978 Uval = Uval + probEnv(e)*Ue;
979 Tval = Tval + probEnv(e)*Te;
980 se.getAvgNodeTable(); % populate cache metrics on stage e
982 cacheNode = self.ensemble{e}.getNodeByIndex(c);
983 [cacheHit, cacheMiss, cacheHitL] = SolverENV.accumCacheMetric(...
984 cacheHit, cacheMiss, cacheHitL, c, cacheNode, probEnv(e));
988 avgModel = self.buildRateAveragedModel(probEnv);
989 innerSolver = feval(class(self.solvers{1}), avgModel, self.solvers{1}.options);
990 [Qval,Uval,~,Tval] = innerSolver.getAvg();
991 innerSolver.getAvgNodeTable(); % populate cache metrics
993 cacheNode = avgModel.getNodeByIndex(c);
994 [cacheHit, cacheMiss, cacheHitL] = SolverENV.accumCacheMetric(...
995 cacheHit, cacheMiss, cacheHitL, c, cacheNode, 1.0);
998 line_error(mfilename, sprintf('solveEnvLimit called with unsupported method %s.
', method));
1001 % Write aggregated cache metrics onto the stage-1 reference model
1003 refCache = self.ensemble{1}.getNodeByIndex(c);
1004 if ~isempty(cacheHit{c}); refCache.setResultHitProb(cacheHit{c}); end
1005 if ~isempty(cacheMiss{c}); refCache.setResultMissProb(cacheMiss{c}); end
1006 if ~isempty(cacheHitL{c}); refCache.setResultHitProbList(cacheHitL{c}); end
1009 self.result.Avg.Q = Qval;
1010 self.result.Avg.U = Uval;
1011 self.result.Avg.T = Tval;
1014 function avgModel = buildRateAveragedModel(self, probEnv)
1015 % AVGMODEL = BUILDRATEAVERAGEDMODEL(PROBENV) Fast-environment model.
1016 % Replace every environment-modulated (i.e. stage-varying) station
1017 % rate by its probEnv-weighted average, represented as an
1018 % exponential. Non-modulated parameters keep their original
1019 % distribution, so the base model is preserved exactly outside the
1021 E = self.getNumberOfModels;
1022 avgModel = self.ensemble{1}.copy();
1023 M = avgModel.getNumberOfStations;
1024 K = avgModel.getNumberOfClasses;
1026 node = avgModel.stations{i};
1027 if isa(node,'Cache
') || isa(node,'Sink
')
1028 continue % stateful/absorbing nodes carry no service rate
1034 r(e) = self.sn{e}.rates(i,k);
1036 if any(isnan(r)) || any(r<=0)
1037 continue % disabled for some stage: leave as configured
1039 if (max(r)-min(r)) <= 1e-12*max(1,max(r))
1040 continue % not modulated: keep the original distribution
1042 ravg = probEnv(:)'*r(:);
1043 if isa(node,
'Source')
1044 node.setArrival(avgModel.classes{k}, Exp(ravg));
1045 elseif isa(node,
'Queue') || isa(node,
'Delay')
1046 node.setService(avgModel.classes{k}, Exp(ravg));
1050 % The copy inherited a cached NetworkStruct from the stage model;
1051 % force a hard rebuild so the averaged rates take effect.
1052 avgModel.refreshStruct(
true);
1055 function [AvgTable,QT,UT,TT] = getAvgTable(self,keepDisabled)
1056 % [AVGTABLE,QT,UT,TT] = GETAVGTABLE(SELF,KEEPDISABLED)
1057 % Return table of average station metrics
1059 if nargin<2 %
if ~exist(
'keepDisabled',
'var')
1060 keepDisabled = false;
1063 [QN,UN,~,TN] = getAvg(self);
1066 Q = self.result.Avg.Q;
1067 U = self.result.Avg.U;
1068 T = self.result.Avg.T;
1069 % Aggregate stage-1 station/class labels (from the layer networks
1070 % for LQN stages). see _kb/06-solver-catalog.md for rationale
1071 [stationLabels, classLabels] = self.aggregateStationClassNames(M, K);
1077 elseif ~keepDisabled
1078 Qval = []; Uval = []; Tval = [];
1083 if any(sum([QN(ist,k),UN(ist,k),TN(ist,k)])>0)
1084 JobClass{end+1,1} = classLabels{k};
1085 Station{end+1,1} = stationLabels{ist};
1086 Qval(end+1) = QN(ist,k);
1087 Uval(end+1) = UN(ist,k);
1088 Tval(end+1) = TN(ist,k);
1092 QLen = Qval(:); % we need to save first in a variable named like the
column
1093 QT = Table(
Station,JobClass,QLen);
1094 Util = Uval(:); % we need to save first in a variable named like the
column
1095 UT = Table(
Station,JobClass,Util);
1096 Tput = Tval(:); % we need to save first in a variable named like the
column
1098 JobClass = categorical(JobClass);
1099 TT = Table(
Station,JobClass,Tput);
1100 RespT = QLen ./ Tput;
1101 AvgTable = Table(
Station,JobClass,QLen,Util,RespT,Tput);
1103 Qval = zeros(M,K); Uval = zeros(M,K);
1104 JobClass = cell(K*M,1);
1108 JobClass{(ist-1)*K+k} = Q{ist,k}.class.name;
1109 Station{(ist-1)*K+k} = Q{ist,k}.station.name;
1110 Qval((ist-1)*K+k) = QN(ist,k);
1111 Uval((ist-1)*K+k) = UN(ist,k);
1112 Tval((ist-1)*K+k) = TN(ist,k);
1116 JobClass = categorical(JobClass);
1117 QLen = Qval(:); % we need to save first in a variable named like the
column
1118 QT = Table(
Station,JobClass,QLen);
1119 Util = Uval(:); % we need to save first in a variable named like the
column
1120 UT = Table(
Station,JobClass,Util);
1121 Tput = Tval(:); % we need to save first in a variable named like the
column
1122 TT = Table(
Station,JobClass,Tput);
1123 RespT = QLen ./ Tput;
1124 AvgTable = Table(
Station,JobClass,QLen,Util,RespT,Tput);
1128 function envsn = getStruct(self)
1129 E = self.getNumberOfModels;
1132 envsn{e} = self.ensemble{e}.getStruct;
1136 function [T, segmentResults] = getSamplePathTable(self, samplePath)
1137 % [T, SEGMENTRESULTS] = GETSAMPLEPATHTABLE(SELF, SAMPLEPATH)
1139 % Compute transient performance metrics
for a sample path through
1140 % environment states. The method runs transient analysis
for each
1141 % segment and extracts initial and
final metric values.
1144 % samplePath - Cell array where each row
is {stage, duration}
1145 % stage: string (name) or integer (1-based index)
1146 % duration: positive scalar (time spent in stage)
1149 % T - Table with columns: Segment, Stage, Duration, Station, JobClass,
1150 % InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput
1151 % segmentResults - Cell array with detailed transient results per segment
1154 % samplePath = {
'Fast', 5.0;
'Slow', 10.0;
'Fast', 3.0};
1155 % [T, results] = solver.getSamplePathTable(samplePath);
1158 if isempty(samplePath)
1159 line_error(mfilename,
'Sample path cannot be empty.');
1162 % Initialize
if needed
1163 if isempty(self.envObj.probEnv)
1167 E = self.getNumberOfModels;
1168 M = self.sn{1}.nstations;
1169 K = self.sn{1}.nclasses;
1170 nSegments = size(samplePath, 1);
1171 segmentResults = cell(nSegments, 1);
1173 % Initialize queue lengths (uniform distribution
for closed classes)
1174 Q_current = zeros(M, K);
1176 if self.sn{1}.njobs(k) > 0 % closed
class
1177 Q_current(:, k) = self.sn{1}.njobs(k) / M;
1181 % Process each segment
1182 for seg = 1:nSegments
1184 stageSpec = samplePath{seg, 1};
1185 duration = samplePath{seg, 2};
1187 if ischar(stageSpec) || isstring(stageSpec)
1188 e = self.envObj.envGraph.findnode(stageSpec);
1190 line_error(mfilename, sprintf(
'Stage "%s" not found.', stageSpec));
1192 stageName = char(stageSpec);
1196 line_error(mfilename, sprintf(
'Stage index %d out of range [1, %d].', e, E));
1198 stageName = self.envObj.envGraph.Nodes.Name{e};
1202 line_error(mfilename,
'Duration must be positive.');
1205 % Initialize model from current queue lengths
1206 self.ensemble{e}.initFromMarginal(Q_current);
1208 % Set solver timespan
1209 self.solvers{e}.options.timespan = [0, duration];
1210 self.solvers{e}.reset();
1212 % Run transient analysis
1213 [Qt, Ut, Tt] = self.ensemble{e}.getTranHandles;
1214 [QNt, UNt, TNt] = self.solvers{e}.getTranAvg(Qt, Ut, Tt);
1216 % Extract initial and
final metrics
1217 initQ = zeros(M, K);
1218 initU = zeros(M, K);
1219 initT = zeros(M, K);
1220 finalQ = zeros(M, K);
1221 finalU = zeros(M, K);
1222 finalT = zeros(M, K);
1230 if isstruct(Qir) && isfield(Qir,
'metric') && ~isempty(Qir.metric)
1231 initQ(i, r) = Qir.metric(1);
1232 finalQ(i, r) = Qir.metric(end);
1234 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
1235 initU(i, r) = Uir.metric(1);
1236 finalU(i, r) = Uir.metric(end);
1238 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
1239 initT(i, r) = Tir.metric(1);
1240 finalT(i, r) = Tir.metric(end);
1245 % Store segment results
1246 segmentResults{seg}.stage = e;
1247 segmentResults{seg}.stageName = stageName;
1248 segmentResults{seg}.duration = duration;
1249 segmentResults{seg}.QNt = QNt;
1250 segmentResults{seg}.UNt = UNt;
1251 segmentResults{seg}.TNt = TNt;
1252 segmentResults{seg}.initQ = initQ;
1253 segmentResults{seg}.initU = initU;
1254 segmentResults{seg}.initT = initT;
1255 segmentResults{seg}.finalQ = finalQ;
1256 segmentResults{seg}.finalU = finalU;
1257 segmentResults{seg}.finalT = finalT;
1259 % Update current queue lengths
for next segment
1263 % Build output table
1276 for seg = 1:nSegments
1277 res = segmentResults{seg};
1280 % Only include rows with non-zero metrics
1281 if any([res.initQ(i,r), res.initU(i,r), res.initT(i,r), ...
1282 res.finalQ(i,r), res.finalU(i,r), res.finalT(i,r)] > 0)
1283 Segment(end+1, 1) = seg;
1284 Stage{end+1, 1} = res.stageName;
1285 Duration(end+1, 1) = res.duration;
1286 Station{end+1, 1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(i)};
1287 JobClass{end+1, 1} = self.sn{1}.classnames{r};
1288 InitQLen(end+1, 1) = res.initQ(i, r);
1289 InitUtil(end+1, 1) = res.initU(i, r);
1290 InitTput(end+1, 1) = res.initT(i, r);
1291 FinalQLen(end+1, 1) = res.finalQ(i, r);
1292 FinalUtil(end+1, 1) = res.finalU(i, r);
1293 FinalTput(end+1, 1) = res.finalT(i, r);
1299 Stage = categorical(Stage);
1301 JobClass = categorical(JobClass);
1302 T = Table(Segment, Stage, Duration,
Station, JobClass, ...
1303 InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput);
1305 function [allMethods] = listValidMethods(self)
1306 % allMethods = LISTVALIDMETHODS()
1307 % List valid methods
for this solver
1308 sn = self.model.getStruct();
1309 allMethods = {
'default'};
1312 function [stationLabels, classLabels] = aggregateStationClassNames(self, M, K)
1313 % [STATIONLABELS, CLASSLABELS] = AGGREGATESTATIONCLASSNAMES(M,K)
1314 % Aggregate station/
class label vectors for the stage-1 result
1315 % layout. For flat NetworkStruct stages these are the usual node
1316 % and class names;
for LQN stages the layout
is the block-diagonal
1317 % union of the layer networks, so labels are drawn from each layer
1318 % network (prefixed by the layer name to disambiguate).
1319 stationLabels = cell(M,1);
1320 classLabels = cell(K,1);
1321 if isfield(self.sn{1},
'nstations')
1323 stationLabels{ist} = self.sn{1}.nodenames{self.sn{1}.stationToNode(ist)};
1326 classLabels{k} = self.sn{1}.classnames{k};
1328 elseif isa(self.ensemble{1},
'LayeredNetwork')
1329 model = self.ensemble{1};
1330 [Roff, Coff, Msz, Ksz] = model.layerBlocks;
1331 for e=1:length(model.ensemble)
1332 layer = model.ensemble{e};
1333 lname = layer.getName;
1335 stationLabels{Roff(e)+i} = sprintf('%s.%s', lname, layer.stations{i}.name);
1338 classLabels{Coff(e)+r} = sprintf('%s.%s', lname, layer.classes{r}.name);
1343 stationLabels{ist} = sprintf(
'Station%d', ist);
1346 classLabels{k} = sprintf(
'Class%d', k);
1351 function Q = roundMarginalForDiscreteSolver(self, Q, snRef)
1352 % ROUNDMARGINALFORDISCRETESOLVER Round fractional queue lengths
1353 % to integers
using the largest remainder method, preserving
1354 % closed chain populations exactly.
1355 for c = 1:snRef.nchains
1356 chainClasses = find(snRef.chains(c,:) > 0);
1357 njobs_chain = sum(snRef.njobs(chainClasses));
1358 if isinf(njobs_chain)
1359 % Open chain: simple rounding
1360 for idx = 1:length(chainClasses)
1361 k = chainClasses(idx);
1363 Q(i,k) = round(Q(i,k));
1367 % Closed chain: largest remainder method
1371 for idx = 1:length(chainClasses)
1372 k = chainClasses(idx);
1373 vals(end+1) = Q(i,k);
1374 indices(end+1,:) = [i, k];
1377 floored = floor(vals);
1378 remainders = vals - floored;
1379 deficit = njobs_chain - sum(floored);
1380 deficit = round(deficit); % ensure integer
1382 [~, sortIdx] = sort(remainders, 'descend');
1383 for d = 1:min(deficit, length(sortIdx))
1384 floored(sortIdx(d)) = floored(sortIdx(d)) + 1;
1387 for j = 1:length(vals)
1388 Q(indices(j,1), indices(j,2)) = floored(j);
1397 function [
bool, featSupported] = supports(model)
1398 % [BOOL, FEATSUPPORTED] = SUPPORTS(MODEL)
1400 featUsed = model.getUsedLangFeatures();
1402 featSupported = SolverFeatureSet;
1405 featSupported.setTrue('ClassSwitch');
1406 featSupported.setTrue('Delay');
1407 featSupported.setTrue('DelayStation');
1408 featSupported.setTrue('Queue');
1409 featSupported.setTrue('Sink');
1410 featSupported.setTrue('Source');
1413 featSupported.setTrue('Coxian');
1414 featSupported.setTrue('Cox2');
1415 featSupported.setTrue('Erlang');
1416 featSupported.setTrue('Exp');
1417 featSupported.setTrue('HyperExp');
1420 featSupported.setTrue('StatelessClassSwitcher'); % Section
1421 featSupported.setTrue('InfiniteServer'); % Section
1422 featSupported.setTrue('SharedServer'); % Section
1423 featSupported.setTrue('Buffer'); % Section
1424 featSupported.setTrue('Dispatcher'); % Section
1425 featSupported.setTrue('Server'); % Section (Non-preemptive)
1426 featSupported.setTrue('JobSink'); % Section
1427 featSupported.setTrue('RandomSource'); % Section
1428 featSupported.setTrue('ServiceTunnel'); % Section
1430 % Scheduling strategy
1431 featSupported.setTrue('SchedStrategy_INF');
1432 featSupported.setTrue('SchedStrategy_PS');
1433 featSupported.setTrue('SchedStrategy_FCFS');
1434 featSupported.setTrue('RoutingStrategy_PROB');
1435 featSupported.setTrue('RoutingStrategy_RAND');
1436 featSupported.setTrue('RoutingStrategy_RROBIN'); % with SolverJMT
1439 featSupported.setTrue('ClosedClass');
1440 featSupported.setTrue('OpenClass');
1442 bool = SolverFeatureSet.supports(featSupported, featUsed);
1447 % ensemble solver options
1448 function options = defaultOptions()
1449 % OPTIONS = DEFAULTOPTIONS()
1450 options = SolverOptions('ENV');
1453 function libs = getLibrariesUsed(sn, options)
1454 % GETLIBRARIESUSED Get list of external libraries used by ENV solver
1455 % ENV uses internal algorithms, no external library attribution needed
1459 function [cacheHit, cacheMiss, cacheHitL] = accumCacheMetric(cacheHit, cacheMiss, cacheHitL, c, cacheNode, w)
1460 % Accumulate w-weighted cache hit/miss ratios of node index c
1461 % (used by solveEnvLimit for the 'avg'/'dec' environment limits).
1462 h = full(cacheNode.getHitRatio);
1464 if isempty(cacheHit{c}); cacheHit{c} = zeros(size(h)); end
1465 cacheHit{c} = cacheHit{c} + w*h;
1467 mval = full(cacheNode.getMissRatio);
1469 if isempty(cacheMiss{c}); cacheMiss{c} = zeros(size(mval)); end
1470 cacheMiss{c} = cacheMiss{c} + w*mval;
1472 hl = cacheNode.getHitRatioByList;
1475 if isempty(cacheHitL{c}); cacheHitL{c} = zeros(size(hl)); end
1476 cacheHitL{c} = cacheHitL{c} + w*hl;