LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
SolverENV.m
1classdef SolverENV < EnsembleSolver
2 % ENV - Ensemble environment solver for models with random environment changes
3 %
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.
8 %
9 % @brief Environment solver for networks in random changing environments
10 %
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
17 %
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
24 %
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
31 %
32 % Example:
33 % @code
34 % env_model = Environment(stages, transitions); % Define environment
35 % solver = SolverENV(env_model, @SolverMVA, options);
36 % metrics = solver.getEnsembleAvg(); % Environment-averaged metrics
37 % @endcode
38 %
39 % Copyright (c) 2012-2026, Imperial College London
40 % All rights reserved.
41
42
43 properties
44 env; % user-supplied representation of each stage transition
45 envObj;
46 sn;
47 resetFromMarginal;
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
53 E0; % Rate matrix
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
64 end
65
66 methods
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);
72 else
73 self.setOptions(SolverENV.defaultOptions);
74 end
75
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: SMP method enabled via options.method=''smp''');
80 end
81
82 self.envObj = renv;
83 self.ensemble = renv.getEnsemble;
84 env = renv.getEnv;
85 self.env = env;
86 for e=1:length(self.env)
87 self.sn{e} = self.ensemble{e}.getStruct;
88 self.setSolver(solverFactory(self.ensemble{e}),e);
89 end
90
91 for e=1:length(self.env)
92 for h=1:length(self.env)
93 self.resetFromMarginal{e,h} = renv.resetFun{e,h};
94 end
95 end
96
97 for e=1:length(self.env)
98 for h=1:length(self.env)
99 self.resetEnvRates{e,h} = renv.resetEnvRatesFun{e,h};
100 end
101 end
102
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;
113 break;
114 end
115 end
116 end
117 if hasStateDependentRates
118 break;
119 end
120 end
121
122 if hasStateDependentRates
123 self.stateDepMethod = 'statedep';
124 line_debug('ENV: auto-detected state-dependent environment rates');
125 end
126
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.']);
133 end
134
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));
141 end
142 end
143 end
144
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));
148 end
149 end
150 end
151
152 function setStateDepMethod(self, method)
153 % SETSTATEDEPMETHOD(METHOD) Sets the state-dependent method
154 if isempty(method)
155 line_error(mfilename, 'State-dependent method cannot be null or empty.');
156 end
157 self.stateDepMethod = method;
158 end
159
160 function setSMPMethod(self, flag)
161 % SETSMPMETHOD(FLAG) Enable/disable DTMC-based computation for Semi-Markov Processes
162 self.SMPMethod = flag;
163 end
164
165 function setCompression(self, flag)
166 % SETCOMPRESSION(FLAG) Enable/disable Courtois decomposition
167 self.compression = flag;
168 end
169
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)
173 %
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)
179 %
180 % Returns:
181 % p - steady-state probability vector
182 % eps - NCD index
183 % epsMax - max acceptable eps value
184 % q - randomization coefficient
185
186 % Get decomposition/aggregation method from options
187 if isfield(self.options, 'config') && isfield(self.options.config, 'da')
188 method = self.options.config.da;
189 else
190 method = 'courtois';
191 end
192
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;
196 else
197 numsteps = 10;
198 end
199
200 switch lower(method)
201 case 'courtois'
202 [p, ~, ~, eps, epsMax, ~, ~, ~, q] = ctmc_courtois(Q, MS);
203 case 'kms'
204 [p, ~, ~, eps, epsMax] = ctmc_kms(Q, MS, numsteps);
205 q = 1.05 * max(max(abs(Q)));
206 case 'takahashi'
207 [p, ~, ~, ~, eps, epsMax] = ctmc_takahashi(Q, MS, numsteps);
208 q = 1.05 * max(max(abs(Q)));
209 case 'multi'
210 % Multi requires MSS (macro-macro-states), default to singletons
211 nMacro = size(MS, 1);
212 MSS = cell(nMacro, 1);
213 for i = 1:nMacro
214 MSS{i} = i;
215 end
216 [p, ~, ~, ~, eps, epsMax] = ctmc_multi(Q, MS, MSS);
217 q = 1.05 * max(max(abs(Q)));
218 otherwise
219 line_error(mfilename, sprintf('Unknown decomposition method: %s', method));
220 end
221 end
222
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
227
228 bool = false;
229 if it <= 1
230 return
231 end
232
233 E = self.getNumberOfModels;
234 M = self.ensemble{1}.getNumberOfStations;
235 K = self.ensemble{1}.getNumberOfClasses;
236
237 % Check convergence per class (aligned with JAR structure)
238 for k = 1:K
239 % Build QEntry and QExit matrices (M x E) for this class
240 QEntry = zeros(M, E);
241 QExit = zeros(M, E);
242 for e = 1:E
243 % Skip stages where analysis failed
244 res_curr = self.results{it,e};
245 res_prev = self.results{it-1,e};
246 for i = 1:M
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);
252 end
253 end
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);
259 end
260 end
261 end
262 end
263
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(:));
268 if isempty(maxDiff)
269 maxDiff = 0; % Handle case where all QEntry values are zero
270 end
271 if isnan(maxDiff) || isinf(maxDiff)
272 return % Non-convergence on invalid values
273 end
274 if maxDiff >= self.options.iter_tol
275 return % Not converged
276 end
277 end
278 line_debug('ENV converged: iteration %d, all classes within tolerance (iter_tol=%e)', it, self.options.iter_tol);
279 bool = true;
280 end
281
282
283 function runAnalyzer(self)
284 % RUNANALYZER()
285 % Run the ensemble solver iteration
286 line_debug('ENV: runAnalyzer starting (nstages=%d, method=%s)', self.getNumberOfModels, self.options.method);
287
288 % Show library attribution if verbose and not yet shown
289 if self.options.verbose ~= VerboseLevel.SILENT && ~GlobalConstants.isLibraryAttributionShown()
290 libs = SolverENV.getLibrariesUsed([], self.options);
291 if ~isempty(libs)
292 line_printf('The solver will leverage %s.\n', strjoin(libs, ', '));
293 GlobalConstants.setLibraryAttributionShown(true);
294 end
295 end
296
297 iterate(self);
298 end
299
300 function init(self)
301 % INIT()
302 % Initialize the environment solver with enhanced data structures
303 % aligned with JAR SolverEnv implementation
304 line_debug('ENV init: initializing environment data structures');
305 options = self.options;
306 if isfield(options,'seed')
307 Solver.resetRandomGeneratorSeed(options.seed);
308 end
309 self.envObj.init();
310
311 % Initialize ServerNum and SRates matrices (aligned with JAR)
312 E = self.getNumberOfModels;
313 M = self.sn{1}.nstations;
314 K = self.sn{1}.nclasses;
315
316 self.ServerNum = cell(K, 1);
317 self.SRates = cell(K, 1);
318 for k = 1:K
319 self.ServerNum{k} = zeros(M, E);
320 self.SRates{k} = zeros(M, E);
321 for e = 1:E
322 for m = 1:M
323 self.ServerNum{k}(m, e) = self.sn{e}.nservers(m);
324 self.SRates{k}(m, e) = self.sn{e}.rates(m, k);
325 end
326 end
327 end
328
329 line_debug('ENV init: %d stages, %d stations, %d classes', E, M, K);
330
331 % Build rate matrix E0
332 self.E0 = zeros(E, E);
333 for e = 1:E
334 for h = 1:E
335 if ~isa(self.envObj.env{e,h}, 'Disabled')
336 self.E0(e, h) = self.envObj.env{e,h}.getRate();
337 end
338 end
339 end
340 self.Eutil = ctmc_makeinfgen(self.E0);
341
342 % Initialize transition CDFs
343 self.transitionCdfs = cell(E, E);
344 for e = 1:E
345 for h = 1:E
346 if ~isa(self.envObj.env{e,h}, 'Disabled')
347 envDist = self.envObj.env{e,h};
348 self.transitionCdfs{e,h} = @(t) envDist.evalCDF(t);
349 else
350 self.transitionCdfs{e,h} = @(t) 0;
351 end
352 end
353 end
354
355 % Initialize sojourn CDFs
356 self.sojournCdfs = cell(E, 1);
357 for e = 1:E
358 self.sojournCdfs{e} = @(t) self.computeSojournCdf(e, t);
359 end
360
361 % SMPMethod: Use DTMC-based computation instead of CTMC for Semi-Markov Processes
362 % Verified numerical integration for Semi-Markov Process DTMC transition probabilities
363 if self.SMPMethod
364 line_debug('ENV init: using DTMC-based computation for Semi-Markov Processes (SMPMethod=true)');
365 self.dtmcP = zeros(E, E);
366 for k = 1:E
367 for e = 1:E
368 if k == e || isa(self.envObj.env{k,e}, 'Disabled')
369 self.dtmcP(k, e) = 0.0;
370 else
371 % Compute the upper limit of the sojourn time
372 epsilon = 1e-8;
373 T = 1;
374 while self.transitionCdfs{k,e}(T) < 1.0 - epsilon
375 T = T * 2;
376 if T > 1e6 % safety limit
377 break;
378 end
379 end
380 % Adaptive number of integration intervals based on T
381 N = max(1000, round(T * 100));
382 dt = T / N;
383 sumVal = 0;
384 for i = 0:(N-1)
385 t0 = i * dt;
386 t1 = t0 + dt;
387 deltaF = self.transitionCdfs{k,e}(t1) - self.transitionCdfs{k,e}(t0);
388 survival = 1;
389 for h = 1:E
390 if h ~= k && h ~= e && ~isa(self.envObj.env{k,h}, 'Disabled')
391 % Use midpoint for better accuracy
392 tmid = (t0 + t1) / 2.0;
393 survival = survival * (1.0 - self.envObj.env{k,h}.evalCDF(tmid));
394 end
395 end
396 sumVal = sumVal + deltaF * survival;
397 end
398 self.dtmcP(k, e) = sumVal;
399 end
400 end
401 end
402
403 line_debug('ENV init: DTMC transition matrix computed, solving for stationary distribution');
404 % Solve DTMC for stationary distribution
405 dtmcPie = dtmc_solve(self.dtmcP);
406
407 % Calculate hold times using numerical integration
408 self.holdTimeMatrix = self.computeHoldTime(E);
409
410 % Compute steady-state probabilities
411 pi = zeros(1, E);
412 denomSum = 0;
413 for e = 1:E
414 denomSum = denomSum + dtmcPie(e) * self.holdTimeMatrix(e);
415 end
416 for k = 1:E
417 pi(k) = dtmcPie(k) * self.holdTimeMatrix(k) / denomSum;
418 end
419 self.envObj.probEnv = pi;
420
421 % Update embedding weights
422 newEmbweight = zeros(E, E);
423 for e = 1:E
424 sumVal = 0.0;
425 for h = 1:E
426 if h ~= e
427 sumVal = sumVal + pi(h) * self.E0(h, e);
428 end
429 end
430 for k = 1:E
431 if k == e
432 newEmbweight(k, e) = 0;
433 else
434 if sumVal > 0
435 newEmbweight(k, e) = pi(k) * self.E0(k, e) / sumVal;
436 end
437 end
438 end
439 end
440 self.envObj.probOrig = newEmbweight;
441 end
442
443 % Compression: Use Courtois decomposition for large environments
444 if self.compression
445 line_debug('ENV init: using compression (Courtois decomposition) for %d stages', E);
446 self.applyCompression(E, M, K);
447 end
448 end
449
450 function applyCompression(self, E, M, K)
451 % APPLYCOMPRESSION Apply Courtois decomposition to reduce environment size
452 % This method finds a good partition of the environment states and
453 % creates compressed macro-state networks.
454
455 % Find best partition
456 if E <= 10
457 self.MS = self.findBestPartition(E);
458 else
459 % Beam search for large environments
460 self.MS = self.beamSearchPartition(E);
461 end
462
463 if isempty(self.MS)
464 % No compression possible, use singletons
465 self.MS = cell(E, 1);
466 for i = 1:E
467 self.MS{i} = i;
468 end
469 self.Ecompress = E;
470 return;
471 end
472
473 self.Ecompress = length(self.MS);
474 line_debug('ENV compression: %d stages compressed to %d macro-states', E, self.Ecompress);
475
476 % Apply decomposition/aggregation
477 [p, eps, epsMax, q] = self.ctmc_decompose(self.Eutil, self.MS);
478
479 if eps > epsMax
480 line_warning(mfilename, 'Environment cannot be effectively compressed (eps > epsMax).');
481 end
482
483 % Store compression results
484 self.compressionResult.p = p;
485 self.compressionResult.eps = eps;
486 self.compressionResult.epsMax = epsMax;
487 self.compressionResult.q = q;
488
489 % Update probEnv with macro-state probabilities
490 pMacro = zeros(1, self.Ecompress);
491 for i = 1:self.Ecompress
492 pMacro(i) = sum(p(self.MS{i}));
493 end
494 self.envObj.probEnv = pMacro;
495
496 % Compute micro-state probabilities within each macro-state
497 pmicro = zeros(E, 1);
498 for i = 1:self.Ecompress
499 blockProb = p(self.MS{i});
500 if sum(blockProb) > 0
501 pmicro(self.MS{i}) = blockProb / sum(blockProb);
502 end
503 end
504 self.compressionResult.pmicro = pmicro;
505 self.compressionResult.pMacro = pMacro;
506
507 % Update embedding weights for macro-states
508 Ecomp = self.Ecompress;
509 newEmbweight = zeros(Ecomp, Ecomp);
510 for e = 1:Ecomp
511 sumVal = 0.0;
512 for h = 1:Ecomp
513 if h ~= e
514 sumVal = sumVal + pMacro(h) * self.computeMacroRate(h, e);
515 end
516 end
517 for k = 1:Ecomp
518 if k == e
519 newEmbweight(k, e) = 0;
520 elseif sumVal > 0
521 newEmbweight(k, e) = pMacro(k) * self.computeMacroRate(k, e) / sumVal;
522 end
523 end
524 end
525 self.envObj.probOrig = newEmbweight;
526
527 % Build macro-state networks with weighted-average rates
528 macroEnsemble = cell(self.Ecompress, 1);
529 macroSolvers = cell(self.Ecompress, 1);
530 macroSn = cell(self.Ecompress, 1);
531
532 for i = 1:self.Ecompress
533 % Copy the first micro-state network
534 firstMicro = self.MS{i}(1);
535 macroEnsemble{i} = self.ensemble{firstMicro}.copy();
536
537 % Compute weighted-average rates
538 for m = 1:M
539 for k = 1:K
540 rateSum = 0;
541 for r = 1:length(self.MS{i})
542 microIdx = self.MS{i}(r);
543 w = pmicro(microIdx);
544 rateSum = rateSum + w * self.sn{microIdx}.rates(m, k);
545 end
546
547 % Update service rate
548 jobclass = macroEnsemble{i}.classes{k};
549 station = macroEnsemble{i}.stations{m};
550 if isa(station, 'Queue') || isa(station, 'Delay')
551 if rateSum > 0
552 station.setService(jobclass, Exp(rateSum));
553 end
554 end
555 end
556 end
557
558 macroEnsemble{i}.refreshStruct(true);
559 macroSn{i} = macroEnsemble{i}.getStruct(true);
560
561 % Create solver for macro-state
562 % Use the same solver factory pattern as original
563 macroSolvers{i} = SolverFluid(macroEnsemble{i}, self.solvers{firstMicro}.options);
564 end
565
566 % Replace ensemble and solvers with compressed versions
567 self.ensemble = macroEnsemble;
568 self.solvers = macroSolvers;
569 self.sn = macroSn;
570 end
571
572 function rate = computeMacroRate(self, fromMacro, toMacro)
573 % COMPUTEMACRORATE Compute transition rate between macro-states
574 rate = 0;
575 for i = 1:length(self.MS{fromMacro})
576 mi = self.MS{fromMacro}(i);
577 for j = 1:length(self.MS{toMacro})
578 mj = self.MS{toMacro}(j);
579 rate = rate + self.compressionResult.pmicro(mi) * self.E0(mi, mj);
580 end
581 end
582 end
583
584 function MS = findBestPartition(self, E)
585 % FINDBESTPARTITION Find the best partition for small environments (E <= 10)
586 % Uses exhaustive search over all possible partitions
587
588 % Start with singletons
589 bestMS = cell(E, 1);
590 for i = 1:E
591 bestMS{i} = i;
592 end
593
594 [~, bestEps, bestEpsMax] = self.ctmc_decompose(self.Eutil, bestMS);
595 if isempty(bestEps) || isnan(bestEps)
596 MS = bestMS;
597 return;
598 end
599
600 % Try merging pairs
601 for i = 1:E
602 for j = (i+1):E
603 testMS = cell(E-1, 1);
604 idx = 1;
605 for k = 1:E
606 if k == i
607 testMS{idx} = [i, j];
608 idx = idx + 1;
609 elseif k ~= j
610 testMS{idx} = k;
611 idx = idx + 1;
612 end
613 end
614
615 [~, testEps, testEpsMax] = self.ctmc_decompose(self.Eutil, testMS);
616 if ~isempty(testEps) && ~isnan(testEps) && testEps < bestEps
617 bestEps = testEps;
618 bestEpsMax = testEpsMax;
619 bestMS = testMS;
620 end
621 end
622 end
623
624 MS = bestMS;
625 self.Ecompress = length(MS);
626 end
627
628 function MS = beamSearchPartition(self, E)
629 % BEAMSEARCHPARTITION Beam search for large environments (E > 10)
630
631 B = 3; % Beam width
632 alpha = 0.01; % Coupling threshold
633
634 if isfield(self.options, 'config') && isfield(self.options.config, 'env_alpha')
635 alpha = self.options.config.env_alpha;
636 end
637
638 % Initialize with singletons
639 singletons = cell(E, 1);
640 for i = 1:E
641 singletons{i} = i;
642 end
643 beam = {singletons};
644
645 bestSeen = singletons;
646 [~, bestEps] = self.ctmc_decompose(self.Eutil, bestSeen);
647
648 % Iteratively merge blocks
649 for depth = 1:(E-1)
650 candidates = {};
651
652 for b = 1:length(beam)
653 ms = beam{b};
654 nBlocks = length(ms);
655
656 % Try all pairwise merges
657 for i = 1:nBlocks
658 for j = (i+1):nBlocks
659 % Create merged partition
660 trial = cell(nBlocks - 1, 1);
661 idx = 1;
662 for k = 1:nBlocks
663 if k == i
664 trial{idx} = [ms{i}(:); ms{j}(:)];
665 idx = idx + 1;
666 elseif k ~= j
667 trial{idx} = ms{k};
668 idx = idx + 1;
669 end
670 end
671
672 [~, childEps, childEpsMax] = self.ctmc_decompose(self.Eutil, trial);
673
674 if ~isempty(childEps) && ~isnan(childEps) && childEps > 0
675 cost = childEps - childEpsMax + alpha * depth;
676 candidates{end+1} = {trial, cost};
677
678 if cost < bestEps
679 bestEps = cost;
680 bestSeen = trial;
681 end
682 end
683 end
684 end
685 end
686
687 if isempty(candidates)
688 break;
689 end
690
691 % Sort by cost and keep top B
692 costs = cellfun(@(x) x{2}, candidates);
693 [~, sortIdx] = sort(costs);
694 beam = {};
695 for i = 1:min(B, length(sortIdx))
696 beam{end+1} = candidates{sortIdx(i)}{1};
697 end
698 end
699
700 MS = bestSeen;
701 self.Ecompress = length(MS);
702 end
703
704 function holdTime = computeHoldTime(self, E)
705 % COMPUTEHOLDTIME Compute expected holding times using numerical integration
706 holdTime = zeros(1, E);
707 for k = 1:E
708 % Survival function: 1 - sojournCDF
709 surv = @(t) 1 - self.sojournCdfs{k}(t);
710
711 % Compute upper limit
712 upperLimit = 10;
713 while surv(upperLimit) > 1e-8
714 upperLimit = upperLimit * 2;
715 if upperLimit > 1e6 % safety limit
716 break;
717 end
718 end
719
720 % Simpson's rule integration
721 N = 10000;
722 dt = upperLimit / N;
723 integral = 0;
724 for i = 0:(N-1)
725 t0 = i * dt;
726 t1 = t0 + dt;
727 tmid = (t0 + t1) / 2;
728 % Simpson's rule: (f(a) + 4*f(mid) + f(b)) * h/6
729 integral = integral + (surv(t0) + 4*surv(tmid) + surv(t1)) * dt / 6;
730 end
731 holdTime(k) = integral;
732 end
733 end
734
735 function cdf = computeSojournCdf(self, e, t)
736 % COMPUTESOJOURNCDF Compute sojourn time CDF for environment stage e
737 E = self.getNumberOfModels;
738 surv = 1.0;
739 for h = 1:E
740 if h ~= e
741 surv = surv * (1 - self.transitionCdfs{e,h}(t));
742 end
743 end
744 cdf = 1 - surv;
745 end
746
747 function pre(self, it)
748 % PRE(IT)
749 line_debug('ENV pre: iteration %d', it);
750 if it==1
751 for e=list(self)
752 try
753 if isinf(self.getSolver(e).options.timespan(2))
754 [QN,~,~,~] = self.getSolver(e).getAvg();
755 else
756 [QNt,~,~] = self.getSolver(e).getTranAvg();
757 % Handle case where getTranAvg returns NaN for disabled/empty results
758 QN = zeros(size(QNt));
759 for i = 1:size(QNt,1)
760 for k = 1:size(QNt,2)
761 if isstruct(QNt{i,k}) && isfield(QNt{i,k}, 'metric')
762 QN(i,k) = QNt{i,k}.metric(end);
763 else
764 QN(i,k) = 0; % Default to 0 for NaN/invalid entries
765 end
766 end
767 end
768 end
769 if ~isa(self.solvers{e}, 'SolverFluid')
770 QN = self.roundMarginalForDiscreteSolver(QN, self.sn{e});
771 end
772 self.ensemble{e}.initFromMarginal(QN);
773 catch
774 % Skip pre-initialization for stages where getAvg fails
775 end
776 end
777 end
778 end
779
780 % solves model in stage e
781 function [results_e, runtime] = analyze(self, it, e)
782 % [RESULTS_E, RUNTIME] = ANALYZE(IT, E)
783 line_debug('ENV analyze: iteration %d, stage %d (%s)', it, e, class(self.solvers{e}));
784 results_e = struct();
785 results_e.('Tran') = struct();
786 results_e.Tran.('Avg') = [];
787 T0 = tic;
788 runtime = toc(T0);
789 %% initialize
790 try
791 [Qt,Ut,Tt] = self.ensemble{e}.getTranHandles;
792 self.solvers{e}.reset();
793 [QNt,UNt,TNt] = self.solvers{e}.getTranAvg(Qt,Ut,Tt);
794 results_e.Tran.Avg.Q = QNt;
795 results_e.Tran.Avg.U = UNt;
796 results_e.Tran.Avg.T = TNt;
797 catch
798 % Skip analysis for stages where transient analysis fails
799 end
800 end
801
802
803 function post(self, it)
804 % POST(IT)
805 line_debug('ENV post: iteration %d, computing exit metrics and entry marginals', it);
806 E = self.getNumberOfModels;
807 M = self.ensemble{1}.getNumberOfStations;
808 K = self.ensemble{1}.getNumberOfClasses;
809 for e=1:E
810 if isempty(self.results{it,e}) || ~isfield(self.results{it,e}, 'Tran') ...
811 || ~isstruct(self.results{it,e}.Tran.Avg) || ~isfield(self.results{it,e}.Tran.Avg, 'Q')
812 for h = 1:E
813 Qexit{e,h} = zeros(M, K);
814 Uexit{e,h} = zeros(M, K);
815 Texit{e,h} = zeros(M, K);
816 end
817 continue
818 end
819 for h = 1:E
820 Qexit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.Q));
821 Uexit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.U));
822 Texit{e,h} = zeros(size(self.results{it,e}.Tran.Avg.T));
823 for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
824 for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
825 Qir = self.results{it,e}.Tran.Avg.Q{i,r};
826 % Check if result is a valid struct with required fields
827 if isstruct(Qir) && isfield(Qir, 't') && isfield(Qir, 'metric') && ~isempty(Qir.t)
828 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))]';
829 if ~isnan(w{e,h})
830 Qexit{e,h}(i,r) = Qir.metric'*w{e,h}/sum(w{e,h});
831 Uir = self.results{it,e}.Tran.Avg.U{i,r};
832 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
833 Uexit{e,h}(i,r) = Uir.metric'*w{e,h}/sum(w{e,h});
834 end
835 Tir = self.results{it,e}.Tran.Avg.T{i,r};
836 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
837 Texit{e,h}(i,r) = Tir.metric'*w{e,h}/sum(w{e,h});
838 end
839 else
840 Qexit{e,h}(i,r) = 0;
841 Uexit{e,h}(i,r) = 0;
842 Texit{e,h}(i,r) = 0;
843 end
844 else
845 w{e,h} = 0;
846 end
847 end
848 end
849 end
850 end
851
852 Qentry = cell(1,E); % average entry queue-length
853 for e = 1:E
854 % Skip stages where analysis failed (no valid Tran results)
855 if isempty(self.results{it,e}) || ~isfield(self.results{it,e}, 'Tran') ...
856 || ~isstruct(self.results{it,e}.Tran.Avg) || ~isfield(self.results{it,e}.Tran.Avg, 'Q')
857 continue
858 end
859 Qentry{e} = zeros(size(Qexit{e}));
860 for h=1:E
861 % probability of coming from h to e \times resetFun(Qexit from h to e
862 if self.envObj.probOrig(h,e) > 0
863 Qentry{e} = Qentry{e} + self.envObj.probOrig(h,e) * self.resetFromMarginal{h,e}(Qexit{h,e});
864 end
865 end
866 if ~isa(self.solvers{e}, 'SolverFluid')
867 Qentry{e} = self.roundMarginalForDiscreteSolver(Qentry{e}, self.sn{e});
868 end
869 self.solvers{e}.reset();
870 self.ensemble{e}.initFromMarginal(Qentry{e});
871 end
872
873 % Update transition rates between stages if state-dependent method
874 % Auto-detected or manually specified via options.method='statedep'
875 if strcmp(self.stateDepMethod, 'statedep') || ...
876 (isfield(self.options, 'method') && strcmp(self.options.method, 'statedep'))
877 line_debug('ENV post: updating state-dependent environment transition rates');
878 for e = 1:E
879 for h = 1:E
880 if ~isa(self.envObj.env{e,h}, 'Disabled') && ~isempty(self.resetEnvRates{e,h})
881 self.envObj.env{e,h} = self.resetEnvRates{e,h}(...
882 self.envObj.env{e,h}, Qexit{e,h}, Uexit{e,h}, Texit{e,h});
883 end
884 end
885 end
886 % Reinitialize environment after rate updates
887 self.envObj.init();
888 end
889 end
890
891 function finish(self)
892 % FINISH()
893 line_debug('ENV finish: computing environment-averaged performance metrics');
894 it = size(self.results,1); % use last iteration
895 E = self.getNumberOfModels;
896 M = self.ensemble{1}.getNumberOfStations;
897 K = self.ensemble{1}.getNumberOfClasses;
898 for e=1:E
899 QExit{e}=zeros(M, K);
900 UExit{e}=zeros(M, K);
901 TExit{e}=zeros(M, K);
902 if it>0 && ~isempty(self.results{it,e}) && isfield(self.results{it,e}, 'Tran') ...
903 && isstruct(self.results{it,e}.Tran.Avg) && isfield(self.results{it,e}.Tran.Avg, 'Q')
904 for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
905 for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
906 Qir = self.results{it,e}.Tran.Avg.Q{i,r};
907 % Check if result is a valid struct with required fields
908 if isstruct(Qir) && isfield(Qir, 't') && isfield(Qir, 'metric') && ~isempty(Qir.t)
909 w{e} = [0, map_cdf(self.envObj.holdTime{e}, Qir.t(2:end)) - map_cdf(self.envObj.holdTime{e}, Qir.t(1:end-1))]';
910 QExit{e}(i,r) = Qir.metric'*w{e}/sum(w{e});
911 Uir = self.results{it,e}.Tran.Avg.U{i,r};
912 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
913 UExit{e}(i,r) = Uir.metric'*w{e}/sum(w{e});
914 else
915 UExit{e}(i,r) = 0;
916 end
917 Tir = self.results{it,e}.Tran.Avg.T{i,r};
918 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
919 TExit{e}(i,r) = Tir.metric'*w{e}/sum(w{e});
920 else
921 TExit{e}(i,r) = 0;
922 end
923 else
924 QExit{e}(i,r) = 0;
925 UExit{e}(i,r) = 0;
926 TExit{e}(i,r) = 0;
927 end
928 end
929 end
930 end
931 % for h = 1:E
932 % QE{e,h} = zeros(size(self.results{it,e}.Tran.Avg.Q));
933 % for i=1:size(self.results{it,e}.Tran.Avg.Q,1)
934 % for r=1:size(self.results{it,e}.Tran.Avg.Q,2)
935 % 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))]';
936 % if ~isnan(w{e,h})
937 % QE{e,h}(i,r) = self.results{it,e}.Tran.Avg.Q{i,r}(:,1)'*w{e,h}/sum(w{e,h});
938 % else
939 % QE{e,h}(i,r) = 0;
940 % end
941 % end
942 % end
943 % end
944 end
945
946 Qval=0*QExit{e};
947 Uval=0*UExit{e};
948 Tval=0*TExit{e};
949 for e=1:E
950 Qval = Qval + self.envObj.probEnv(e) * QExit{e}; % to check
951 Uval = Uval + self.envObj.probEnv(e) * UExit{e}; % to check
952 Tval = Tval + self.envObj.probEnv(e) * TExit{e}; % to check
953 end
954 self.result.Avg.Q = Qval;
955 % self.result.Avg.R = R;
956 % self.result.Avg.X = X;
957 self.result.Avg.U = Uval;
958 self.result.Avg.T = Tval;
959 % self.result.Avg.C = C;
960 %self.result.runtime = runtime;
961 %if self.options.verbose
962 % line_printf('\n');
963 %end
964 end
965
966 function name = getName(self)
967 % NAME = GETNAME()
968
969 name = mfilename;
970 end
971
972 function [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
973 % [renvInfGen, stageInfGen, renvEventFilt, stageEventFilt, renvEvents, stageEvents] = getGenerator(self)
974 %
975 % Returns the infinitesimal generator matrices for the random environment model.
976 %
977 % Outputs:
978 % renvInfGen - Combined infinitesimal generator for the random environment (flattened)
979 % stageInfGen - Cell array of infinitesimal generators for each stage
980 % renvEventFilt - Cell array (E x E) of event filtration matrices for environment transitions
981 % stageEventFilt - Cell array of event filtration matrices for each stage
982 % renvEvents - Cell array of Event objects for environment transitions
983 % stageEvents - Cell array of synchronization maps for each stage
984
985 E = self.getNumberOfModels;
986 stageInfGen = cell(1,E);
987 stageEventFilt = cell(1,E);
988 stageEvents = cell(1,E);
989 for e=1:E
990 if isa(self.solvers{e},'SolverCTMC')
991 [stageInfGen{e}, stageEventFilt{e}, stageEvents{e}] = self.solvers{e}.getGenerator();
992 else
993 line_error(mfilename,'This method requires SolverENV to be instantiated with the CTMC solver.');
994 end
995 end
996
997 % Get number of states for each stage
998 nstates = cellfun(@(g) size(g, 1), stageInfGen);
999
1000 % Get number of phases for each transition distribution
1001 nphases = zeros(E, E);
1002 for i=1:E
1003 for j=1:E
1004 if ~isempty(self.env{i,j}) && ~isa(self.env{i,j}, 'Disabled')
1005 nphases(i,j) = self.env{i,j}.getNumberOfPhases();
1006 else
1007 nphases(i,j) = 1;
1008 end
1009 end
1010 end
1011 % Adjust diagonal (self-transitions have one less phase in the Kronecker expansion)
1012 nphases = nphases - eye(E);
1013
1014 % Initialize block cell structure for the random environment generator
1015 renvInfGen = cell(E,E);
1016
1017 for e=1:E
1018 % Diagonal block: stage infinitesimal generator
1019 renvInfGen{e,e} = stageInfGen{e};
1020 for h=1:E
1021 if h~=e
1022 % Off-diagonal blocks: reset matrices (identity with appropriate dimensions)
1023 minStates = min(nstates(e), nstates(h));
1024 resetMatrix_eh = sparse(nstates(e), nstates(h));
1025 for i=1:minStates
1026 resetMatrix_eh(i,i) = 1;
1027 end
1028 renvInfGen{e,h} = resetMatrix_eh;
1029 end
1030 end
1031 end
1032
1033 % Build environment transition events and expand generator with phase structure
1034 renvEvents = cell(1,0);
1035 for e=1:E
1036 for h=1:E
1037 if h~=e
1038 % Get D0 (phase generator) for transition from e to h
1039 if isempty(self.env{e,h}) || isa(self.env{e,h}, 'Disabled')
1040 D0 = zeros(0);
1041 else
1042 proc = self.env{e,h}.getProcess();
1043 D0 = proc{1};
1044 end
1045 % Kronecker sum with diagonal block
1046 renvInfGen{e,e} = krons(renvInfGen{e,e}, D0);
1047
1048 % Get D1 (completion rate matrix) and initial probability vector pie
1049 if isempty(self.env{h,e}) || isa(self.env{h,e}, 'Disabled') || any(isnan(map_pie(self.env{h,e}.getProcess())))
1050 pie = ones(1, nphases(h,e));
1051 else
1052 pie = map_pie(self.env{h,e}.getProcess());
1053 end
1054
1055 if isempty(self.env{e,h}) || isa(self.env{e,h}, 'Disabled')
1056 D1 = zeros(0);
1057 else
1058 proc = self.env{e,h}.getProcess();
1059 D1 = proc{2};
1060 end
1061
1062 % Kronecker product for off-diagonal block
1063 onePhase = ones(nphases(e,h), 1);
1064 kronArg = D1 * onePhase * pie;
1065 renvInfGen{e,h} = kron(renvInfGen{e,h}, sparse(kronArg));
1066
1067 % Create environment transition events
1068 for i=1:self.ensemble{e}.getNumberOfNodes
1069 renvEvents{1,end+1} = Event(EventType.STAGE, i, NaN, NaN, [e,h]); %#ok<AGROW>
1070 end
1071
1072 % Handle other stages (f != e, f != h)
1073 for f=1:E
1074 if f~=h && f~=e
1075 if isempty(self.env{f,h}) || isa(self.env{f,h}, 'Disabled') || any(isnan(map_pie(self.env{f,h}.getProcess())))
1076 pie_fh = ones(1, nphases(f,h));
1077 else
1078 pie_fh = map_pie(self.env{f,h}.getProcess());
1079 end
1080 oneVec = ones(nphases(e,h), 1);
1081 renvInfGen{e,f} = kron(renvInfGen{e,f}, oneVec * pie_fh);
1082 end
1083 end
1084 end
1085 end
1086 end
1087
1088 % Build event filtration matrices for environment transitions
1089 % Each renvEventFilt{e,h} isolates transitions from stage e to stage h
1090 renvEventFilt = cell(E,E);
1091 for e=1:E
1092 for h=1:E
1093 tmpCell = cell(E,E);
1094 for e1=1:E
1095 for h1=1:E
1096 tmpCell{e1,h1} = renvInfGen{e1,h1};
1097 end
1098 end
1099 % Zero out diagonal blocks (internal stage transitions)
1100 for e1=1:E
1101 tmpCell{e1,e1} = tmpCell{e1,e1} * 0;
1102 end
1103 % Zero out off-diagonal blocks that don't match (e,h) transition
1104 for e1=1:E
1105 for h1=1:E
1106 if e~=e1 || h~=h1
1107 if e1~=h1 % Only zero out off-diagonal entries
1108 tmpCell{e1,h1} = tmpCell{e1,h1} * 0;
1109 end
1110 end
1111 end
1112 end
1113 renvEventFilt{e,h} = cell2mat(tmpCell);
1114 end
1115 end
1116
1117 % Flatten block structure into single matrix and normalize
1118 renvInfGen = cell2mat(renvInfGen);
1119 renvInfGen = ctmc_makeinfgen(renvInfGen);
1120 end
1121
1122 function varargout = getAvg(varargin)
1123 % [QNCLASS, UNCLASS, TNCLASS] = GETAVG()
1124 [varargout{1:nargout}] = getEnsembleAvg( varargin{:} );
1125 end
1126
1127 function [QNclass, UNclass, RNclass, TNclass, ANclass, WNclass] = getEnsembleAvg(self)
1128 % [QNCLASS, UNCLASS, TNCLASS] = GETENSEMBLEAVG()
1129 RNclass=[];
1130 ANclass=[];
1131 WNclass=[];
1132
1133 if isempty(self.result) || (isfield(self.options,'force') && self.options.force)
1134 iterate(self);
1135 if isempty(self.result)
1136 QNclass=[];
1137 UNclass=[];
1138 TNclass=[];
1139 return
1140 end
1141 end
1142 QNclass = self.result.Avg.Q;
1143 UNclass = self.result.Avg.U;
1144 TNclass = self.result.Avg.T;
1145 WNclass = QNclass ./ TNclass;
1146 RNclass = NaN*WNclass;
1147 ANclass = NaN*TNclass;
1148 end
1149
1150 function [AvgTable,QT,UT,TT] = getAvgTable(self,keepDisabled)
1151 % [AVGTABLE,QT,UT,TT] = GETAVGTABLE(SELF,KEEPDISABLED)
1152 % Return table of average station metrics
1153
1154 if nargin<2 %if ~exist('keepDisabled','var')
1155 keepDisabled = false;
1156 end
1157
1158 [QN,UN,~,TN] = getAvg(self);
1159 M = size(QN,1);
1160 K = size(QN,2);
1161 Q = self.result.Avg.Q;
1162 U = self.result.Avg.U;
1163 T = self.result.Avg.T;
1164 if isempty(QN)
1165 AvgTable = Table();
1166 QT = Table();
1167 UT = Table();
1168 TT = Table();
1169 elseif ~keepDisabled
1170 Qval = []; Uval = []; Tval = [];
1171 JobClass = {};
1172 Station = {};
1173 for ist=1:M
1174 for k=1:K
1175 if any(sum([QN(ist,k),UN(ist,k),TN(ist,k)])>0)
1176 JobClass{end+1,1} = self.sn{1}.classnames{k};
1177 Station{end+1,1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(ist)};
1178 Qval(end+1) = QN(ist,k);
1179 Uval(end+1) = UN(ist,k);
1180 Tval(end+1) = TN(ist,k);
1181 end
1182 end
1183 end
1184 QLen = Qval(:); % we need to save first in a variable named like the column
1185 QT = Table(Station,JobClass,QLen);
1186 Util = Uval(:); % we need to save first in a variable named like the column
1187 UT = Table(Station,JobClass,Util);
1188 Tput = Tval(:); % we need to save first in a variable named like the column
1189 Station = categorical(Station);
1190 JobClass = categorical(JobClass);
1191 TT = Table(Station,JobClass,Tput);
1192 RespT = QLen ./ Tput;
1193 AvgTable = Table(Station,JobClass,QLen,Util,RespT,Tput);
1194 else
1195 Qval = zeros(M,K); Uval = zeros(M,K);
1196 JobClass = cell(K*M,1);
1197 Station = cell(K*M,1);
1198 for ist=1:M
1199 for k=1:K
1200 JobClass{(ist-1)*K+k} = Q{ist,k}.class.name;
1201 Station{(ist-1)*K+k} = Q{ist,k}.station.name;
1202 Qval((ist-1)*K+k) = QN(ist,k);
1203 Uval((ist-1)*K+k) = UN(ist,k);
1204 Tval((ist-1)*K+k) = TN(ist,k);
1205 end
1206 end
1207 Station = categorical(Station);
1208 JobClass = categorical(JobClass);
1209 QLen = Qval(:); % we need to save first in a variable named like the column
1210 QT = Table(Station,JobClass,QLen);
1211 Util = Uval(:); % we need to save first in a variable named like the column
1212 UT = Table(Station,JobClass,Util);
1213 Tput = Tval(:); % we need to save first in a variable named like the column
1214 TT = Table(Station,JobClass,Tput);
1215 RespT = QLen ./ Tput;
1216 AvgTable = Table(Station,JobClass,QLen,Util,RespT,Tput);
1217 end
1218 end
1219
1220 function envsn = getStruct(self)
1221 E = self.getNumberOfModels;
1222 envsn = cell(1,E);
1223 for e=1:E
1224 envsn{e} = self.ensemble{e}.getStruct;
1225 end
1226 end
1227
1228 function [T, segmentResults] = getSamplePathTable(self, samplePath)
1229 % [T, SEGMENTRESULTS] = GETSAMPLEPATHTABLE(SELF, SAMPLEPATH)
1230 %
1231 % Compute transient performance metrics for a sample path through
1232 % environment states. The method runs transient analysis for each
1233 % segment and extracts initial and final metric values.
1234 %
1235 % Inputs:
1236 % samplePath - Cell array where each row is {stage, duration}
1237 % stage: string (name) or integer (1-based index)
1238 % duration: positive scalar (time spent in stage)
1239 %
1240 % Outputs:
1241 % T - Table with columns: Segment, Stage, Duration, Station, JobClass,
1242 % InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput
1243 % segmentResults - Cell array with detailed transient results per segment
1244 %
1245 % Example:
1246 % samplePath = {'Fast', 5.0; 'Slow', 10.0; 'Fast', 3.0};
1247 % [T, results] = solver.getSamplePathTable(samplePath);
1248
1249 % Validate input
1250 if isempty(samplePath)
1251 line_error(mfilename, 'Sample path cannot be empty.');
1252 end
1253
1254 % Initialize if needed
1255 if isempty(self.envObj.probEnv)
1256 self.init();
1257 end
1258
1259 E = self.getNumberOfModels;
1260 M = self.sn{1}.nstations;
1261 K = self.sn{1}.nclasses;
1262 nSegments = size(samplePath, 1);
1263 segmentResults = cell(nSegments, 1);
1264
1265 % Initialize queue lengths (uniform distribution for closed classes)
1266 Q_current = zeros(M, K);
1267 for k = 1:K
1268 if self.sn{1}.njobs(k) > 0 % closed class
1269 Q_current(:, k) = self.sn{1}.njobs(k) / M;
1270 end
1271 end
1272
1273 % Process each segment
1274 for seg = 1:nSegments
1275 % Resolve stage
1276 stageSpec = samplePath{seg, 1};
1277 duration = samplePath{seg, 2};
1278
1279 if ischar(stageSpec) || isstring(stageSpec)
1280 e = self.envObj.envGraph.findnode(stageSpec);
1281 if e == 0
1282 line_error(mfilename, sprintf('Stage "%s" not found.', stageSpec));
1283 end
1284 stageName = char(stageSpec);
1285 else
1286 e = stageSpec;
1287 if e < 1 || e > E
1288 line_error(mfilename, sprintf('Stage index %d out of range [1, %d].', e, E));
1289 end
1290 stageName = self.envObj.envGraph.Nodes.Name{e};
1291 end
1292
1293 if duration <= 0
1294 line_error(mfilename, 'Duration must be positive.');
1295 end
1296
1297 % Initialize model from current queue lengths
1298 self.ensemble{e}.initFromMarginal(Q_current);
1299
1300 % Set solver timespan
1301 self.solvers{e}.options.timespan = [0, duration];
1302 self.solvers{e}.reset();
1303
1304 % Run transient analysis
1305 [Qt, Ut, Tt] = self.ensemble{e}.getTranHandles;
1306 [QNt, UNt, TNt] = self.solvers{e}.getTranAvg(Qt, Ut, Tt);
1307
1308 % Extract initial and final metrics
1309 initQ = zeros(M, K);
1310 initU = zeros(M, K);
1311 initT = zeros(M, K);
1312 finalQ = zeros(M, K);
1313 finalU = zeros(M, K);
1314 finalT = zeros(M, K);
1315
1316 for i = 1:M
1317 for r = 1:K
1318 Qir = QNt{i, r};
1319 Uir = UNt{i, r};
1320 Tir = TNt{i, r};
1321
1322 if isstruct(Qir) && isfield(Qir, 'metric') && ~isempty(Qir.metric)
1323 initQ(i, r) = Qir.metric(1);
1324 finalQ(i, r) = Qir.metric(end);
1325 end
1326 if isstruct(Uir) && isfield(Uir, 'metric') && ~isempty(Uir.metric)
1327 initU(i, r) = Uir.metric(1);
1328 finalU(i, r) = Uir.metric(end);
1329 end
1330 if isstruct(Tir) && isfield(Tir, 'metric') && ~isempty(Tir.metric)
1331 initT(i, r) = Tir.metric(1);
1332 finalT(i, r) = Tir.metric(end);
1333 end
1334 end
1335 end
1336
1337 % Store segment results
1338 segmentResults{seg}.stage = e;
1339 segmentResults{seg}.stageName = stageName;
1340 segmentResults{seg}.duration = duration;
1341 segmentResults{seg}.QNt = QNt;
1342 segmentResults{seg}.UNt = UNt;
1343 segmentResults{seg}.TNt = TNt;
1344 segmentResults{seg}.initQ = initQ;
1345 segmentResults{seg}.initU = initU;
1346 segmentResults{seg}.initT = initT;
1347 segmentResults{seg}.finalQ = finalQ;
1348 segmentResults{seg}.finalU = finalU;
1349 segmentResults{seg}.finalT = finalT;
1350
1351 % Update current queue lengths for next segment
1352 Q_current = finalQ;
1353 end
1354
1355 % Build output table
1356 Segment = [];
1357 Stage = {};
1358 Duration = [];
1359 Station = {};
1360 JobClass = {};
1361 InitQLen = [];
1362 InitUtil = [];
1363 InitTput = [];
1364 FinalQLen = [];
1365 FinalUtil = [];
1366 FinalTput = [];
1367
1368 for seg = 1:nSegments
1369 res = segmentResults{seg};
1370 for i = 1:M
1371 for r = 1:K
1372 % Only include rows with non-zero metrics
1373 if any([res.initQ(i,r), res.initU(i,r), res.initT(i,r), ...
1374 res.finalQ(i,r), res.finalU(i,r), res.finalT(i,r)] > 0)
1375 Segment(end+1, 1) = seg;
1376 Stage{end+1, 1} = res.stageName;
1377 Duration(end+1, 1) = res.duration;
1378 Station{end+1, 1} = self.sn{1}.nodenames{self.sn{1}.stationToNode(i)};
1379 JobClass{end+1, 1} = self.sn{1}.classnames{r};
1380 InitQLen(end+1, 1) = res.initQ(i, r);
1381 InitUtil(end+1, 1) = res.initU(i, r);
1382 InitTput(end+1, 1) = res.initT(i, r);
1383 FinalQLen(end+1, 1) = res.finalQ(i, r);
1384 FinalUtil(end+1, 1) = res.finalU(i, r);
1385 FinalTput(end+1, 1) = res.finalT(i, r);
1386 end
1387 end
1388 end
1389 end
1390
1391 Stage = categorical(Stage);
1392 Station = categorical(Station);
1393 JobClass = categorical(JobClass);
1394 T = Table(Segment, Stage, Duration, Station, JobClass, ...
1395 InitQLen, InitUtil, InitTput, FinalQLen, FinalUtil, FinalTput);
1396 end
1397 function [allMethods] = listValidMethods(self)
1398 % allMethods = LISTVALIDMETHODS()
1399 % List valid methods for this solver
1400 sn = self.model.getStruct();
1401 allMethods = {'default'};
1402 end
1403
1404 function Q = roundMarginalForDiscreteSolver(self, Q, snRef)
1405 % ROUNDMARGINALFORDISCRETESOLVER Round fractional queue lengths
1406 % to integers using the largest remainder method, preserving
1407 % closed chain populations exactly.
1408 for c = 1:snRef.nchains
1409 chainClasses = find(snRef.chains(c,:) > 0);
1410 njobs_chain = sum(snRef.njobs(chainClasses));
1411 if isinf(njobs_chain)
1412 % Open chain: simple rounding
1413 for idx = 1:length(chainClasses)
1414 k = chainClasses(idx);
1415 for i = 1:size(Q,1)
1416 Q(i,k) = round(Q(i,k));
1417 end
1418 end
1419 else
1420 % Closed chain: largest remainder method
1421 vals = [];
1422 indices = [];
1423 for i = 1:size(Q,1)
1424 for idx = 1:length(chainClasses)
1425 k = chainClasses(idx);
1426 vals(end+1) = Q(i,k);
1427 indices(end+1,:) = [i, k];
1428 end
1429 end
1430 floored = floor(vals);
1431 remainders = vals - floored;
1432 deficit = njobs_chain - sum(floored);
1433 deficit = round(deficit); % ensure integer
1434 if deficit > 0
1435 [~, sortIdx] = sort(remainders, 'descend');
1436 for d = 1:min(deficit, length(sortIdx))
1437 floored(sortIdx(d)) = floored(sortIdx(d)) + 1;
1438 end
1439 end
1440 for j = 1:length(vals)
1441 Q(indices(j,1), indices(j,2)) = floored(j);
1442 end
1443 end
1444 end
1445 end
1446 end
1447
1448 methods (Static)
1449
1450 function [bool, featSupported] = supports(model)
1451 % [BOOL, FEATSUPPORTED] = SUPPORTS(MODEL)
1452
1453 featUsed = model.getUsedLangFeatures();
1454
1455 featSupported = SolverFeatureSet;
1456
1457 % Nodes
1458 featSupported.setTrue('ClassSwitch');
1459 featSupported.setTrue('Delay');
1460 featSupported.setTrue('DelayStation');
1461 featSupported.setTrue('Queue');
1462 featSupported.setTrue('Sink');
1463 featSupported.setTrue('Source');
1464
1465 % Distributions
1466 featSupported.setTrue('Coxian');
1467 featSupported.setTrue('Cox2');
1468 featSupported.setTrue('Erlang');
1469 featSupported.setTrue('Exp');
1470 featSupported.setTrue('HyperExp');
1471
1472 % Sections
1473 featSupported.setTrue('StatelessClassSwitcher'); % Section
1474 featSupported.setTrue('InfiniteServer'); % Section
1475 featSupported.setTrue('SharedServer'); % Section
1476 featSupported.setTrue('Buffer'); % Section
1477 featSupported.setTrue('Dispatcher'); % Section
1478 featSupported.setTrue('Server'); % Section (Non-preemptive)
1479 featSupported.setTrue('JobSink'); % Section
1480 featSupported.setTrue('RandomSource'); % Section
1481 featSupported.setTrue('ServiceTunnel'); % Section
1482
1483 % Scheduling strategy
1484 featSupported.setTrue('SchedStrategy_INF');
1485 featSupported.setTrue('SchedStrategy_PS');
1486 featSupported.setTrue('SchedStrategy_FCFS');
1487 featSupported.setTrue('RoutingStrategy_PROB');
1488 featSupported.setTrue('RoutingStrategy_RAND');
1489 featSupported.setTrue('RoutingStrategy_RROBIN'); % with SolverJMT
1490
1491 % Customer Classes
1492 featSupported.setTrue('ClosedClass');
1493 featSupported.setTrue('OpenClass');
1494
1495 bool = SolverFeatureSet.supports(featSupported, featUsed);
1496 end
1497 end
1498
1499 methods (Static)
1500 % ensemble solver options
1501 function options = defaultOptions()
1502 % OPTIONS = DEFAULTOPTIONS()
1503 options = SolverOptions('ENV');
1504 end
1505
1506 function libs = getLibrariesUsed(sn, options)
1507 % GETLIBRARIESUSED Get list of external libraries used by ENV solver
1508 % ENV uses internal algorithms, no external library attribution needed
1509 libs = {};
1510 end
1511 end
1512end
1513