LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
solver_ctmc_analyzer.m
1function [QN,UN,RN,TN,CN,XN,InfGen,StateSpace,StateSpaceAggr,EventFiltration,runtime,fname,sncopy] = solver_ctmc_analyzer(sn, options)
2% [QN,UN,RN,TN,CN,XN,INFGEN,STATESPACE,STATESPACEAGGR,EVENTFILTRATION,RUNTIME,FNAME,sn] = SOLVER_CTMC_ANALYZER(sn, OPTIONS)
3%
4% Copyright (c) 2012-2026, Imperial College London
5% All rights reserved.
6
7%if options.remote
8% sn.rtfun = {};
9% sn.lst = {};
10% qn_json = jsonencode(sn);
11% sn = NetworkStruct.fromJSON(qn_json)
12%return
13%end
14
15M = sn.nstations; %number of stations
16K = sn.nclasses; %number of classes
17S = sn.nservers;
18NK = sn.njobs'; % initial population per class
19sched = sn.sched;
20
21Tstart = tic;
22PH = sn.proc;
23
24line_debug('CTMC analyzer starting: nstations=%d, nclasses=%d, njobs=%s', M, K, mat2str(NK));
25
26% Note: hide_immediate now selectively preserves Cache immediate transitions
27% in solver_ctmc.m, so we no longer need to disable it entirely for Cache nodes
28
29line_debug('Building state space and infinitesimal generator via solver_ctmc');
30[InfGen,StateSpace,StateSpaceAggr,EventFiltration,arvRates,depRates,sn] = solver_ctmc(sn, options); % sn is updated with the state space
31
32% if the initial state does not reflect the final size of the state
33% vectors, attempt to correct it
34for isf=1:sn.nstateful
35 if size(sn.state{isf},2) < size(sn.space{isf},2)
36 sn.state{isf} = [zeros(1,size(sn.space{isf},2)-size(sn.state{isf},2)),sn.state{isf}];
37 end
38end
39sncopy = sn;
40
41if options.keep
42 line_debug('Saving CTMC data to file (options.keep=true)');
43 fname = lineTempName;
44 save([fname,'.mat'],'InfGen','StateSpace','StateSpaceAggr','EventFiltration')
45 line_printf('CTMC infinitesimal generator and state space saved in: ');
46 line_printf(strrep(sprintf('%s.mat\n',fname),'\','\\'))
47else
48 fname = '';
49end
50
51wset = 1:length(InfGen);
52
53line_debug('State space built: %d states, solving CTMC', length(InfGen));
54
55use_ctmc_solve_stable = true;
56if use_ctmc_solve_stable
57 % stable version
58 % Note: solver_ctmc now selectively preserves Cache immediate transitions
59 % to enable hit/miss rate computation while hiding other immediate transitions
60 [probSysState, ~, nConnComp, connComp] = ctmc_solve(InfGen, options);
61
62 if nConnComp > 1
63 line_debug('CTMC is reducible: %d connected components', nConnComp);
64 % the matrix was reducible
65 initState = matchrow(StateSpace, cell2mat(sn.state'));
66 if initState <= 0
67 % Initial state may have been removed by stochcomp (e.g., SPN with
68 % immediate ENABLE states). Use the largest connected component.
69 compSizes = accumarray(connComp(:), 1);
70 [~, largestComp] = max(compSizes);
71 wset = find(connComp == largestComp);
72 else
73 % determine the weakly connected component associated to the initial state
74 wset = find(connComp == connComp(initState));
75 end
76 if initState > 0
77 line_debug('Using component %d with %d states (from initial state)', connComp(initState), length(wset));
78 else
79 line_debug('Using largest component with %d states (initial state removed by stochcomp)', length(wset));
80 end
81 probSysState = ctmc_solve(InfGen(wset, wset), options);
82 InfGen = InfGen(wset, wset);
83 % reduce all per-state arrays to the retained component and remap wset
84 % to local indices so matrix-form and loop-form estimators stay aligned
85 StateSpace = StateSpace(wset,:);
86 StateSpaceAggr = StateSpaceAggr(wset,:);
87 arvRates = arvRates(wset,:,:);
88 depRates = depRates(wset,:,:);
89 wset = 1:numel(wset);
90 else
91 line_debug('CTMC is irreducible, using full state space');
92 end
93else
94 % development version
95
96 % we now find the initial state and then solver the CTMC allowing for the
97 % case where it is reducible
98 initState = matchrow(StateSpace, cell2mat(sn.state'));
99 pi0 = zeros(1,length(InfGen)); pi0(initState) = 1.0;
100 [pi,pis,~,scc,~] = ctmc_solve_reducible(InfGen, pi0, options);
101
102 if size(pis,1)==1
103 probSysState = pi;
104 else
105 wset = scc == scc(initState);
106 InfGen = InfGen(wset, wset);
107 StateSpace = StateSpace(wset,:);
108 probSysState = pis(scc(initState),scc == scc(initState));
109 end
110end
111probSysState(probSysState<GlobalConstants.Zero)=0;
112probSysState = probSysState/sum(probSysState);
113
114XN = NaN*zeros(1,K);
115UN = NaN*zeros(M,K);
116QN = NaN*zeros(M,K);
117RN = NaN*zeros(M,K);
118TN = NaN*zeros(M,K);
119CN = NaN*zeros(1,K);
120
121istSpaceShift = zeros(1,M);
122for ist=1:M
123 if ist==1
124 istSpaceShift(ist) = 0;
125 else
126 istSpaceShift(ist) = istSpaceShift(ist-1) + size(sn.space{ist-1},2);
127 end
128end
129
130for k=1:K
131 refsf = sn.stationToStateful(sn.refstat(k));
132 XN(k) = probSysState*arvRates(wset,refsf,k);
133end
134
135% Stations that participate in a finite capacity region with a loss (DROP)
136% rule can shed offered load exactly like a station-level capacity limit, so
137% for such stations only the departure-based (carried) utilization estimator
138% is valid. Precompute per-station membership in any DROP region.
139inDropRegion = false(1,M);
140if isfield(sn,'nregions') && sn.nregions > 0
141 for f=1:sn.nregions
142 if sn.regionrule(f) == DropStrategy.DROP
143 memb = any(sn.region{f} ~= -1, 2); % stations constrained by region f
144 memb = memb(:)';
145 inDropRegion(1:min(M,numel(memb))) = inDropRegion(1:min(M,numel(memb))) | memb(1:min(M,numel(memb)));
146 end
147 end
148end
149
150for ist=1:M
151 isf = sn.stationToStateful(ist);
152 ind = sn.stationToNode(ist);
153 for k=1:K
154 TN(ist,k) = probSysState*depRates(wset,isf,k);
155 QN(ist,k) = probSysState*StateSpaceAggr(wset,(ist-1)*K+k);
156 end
157 if sn.nodetype(ind) ~= NodeType.Source
158 % For a capacity-constrained OPEN class arrivals can be dropped, so the
159 % arrival-based estimator UNarv_ik counts the OFFERED load (including
160 % dropped jobs) and overestimates utilization; only the departure-based
161 % (carried load) estimator is valid for such a class. Closed classes are
162 % never dropped (their classcap is finite by population, not by a buffer).
163 % A station inside a DROP-rule finite capacity region can also shed load
164 % even when its own station capacity is infinite (the limit is on the
165 % region), so treat its open classes as droppable too.
166 canDropClass = isinf(sn.njobs(:)') & (isfinite(sn.cap(ist)) | isfinite(sn.classcap(ist,:)) | inDropRegion(ist));
167 % G-network signals annihilate jobs in place: a removed job leaves
168 % without ever completing service, so UNarv_ik again counts offered
169 % rather than carried load (M/M/1 with lambda+=0.5, lambda-=0.4, mu=1
170 % gives UNarv=0.5 against the exact rho=lambda+/(mu+lambda-)=0.35714).
171 % Only the departure-based estimator is valid for a class exposed to a
172 % signal at this station.
173 signalLoss = ctmc_signal_lossy(sn, arvRates, probSysState, wset, isf);
174 canDropClass = canDropClass | signalLoss;
175 switch sched(ist)
176 case SchedStrategy.INF
177 for k=1:K
178 UN(ist,k) = QN(ist,k);
179 end
180 case {SchedStrategy.PS, SchedStrategy.DPS, SchedStrategy.GPS, SchedStrategy.LPS}
181 if isempty(sn.lldscaling) && isempty(sn.cdscaling)
182 for k=1:K
183 if ~isempty(PH{ist}{k})
184 % There are cases where due to remove of
185 % immediate transitions or due to cutoff the
186 % utilization estimator based on arrivals or
187 % departures can be under-estimated. E.g.:
188 % UNarv_ik doesn't work well with example_stateDependentRouting_3
189 % UNdep_ik doesn't work well with test_OQN_JMT_6
190 % Therefore, we take the maximum of the two
191 % Note: the two estimators are normally
192 % identical.
193 UNdep_ik = TN(ist,k)*map_mean(PH{ist}{k})/S(ist); % this is valid because CS in LINE is in a separate node
194 if canDropClass(k)
195 UN(ist,k) = UNdep_ik;
196 else
197 UNarv_ik = probSysState*arvRates(wset,isf,k)*map_mean(PH{ist}{k})/S(ist);
198 UN(ist,k) = max(UNarv_ik,UNdep_ik);
199 end
200 end
201 end
202 else % lld/cd/ljd cases
203 % Busy-server FRACTION under load-dependent scaling (NC
204 % convention): weight the per-class capacity share by the
205 % current scaling and normalize by the peak (effective
206 % capacity); the unweighted share is a P(busy)-style value.
207 ind = sn.stationToNode(ist);
208 ceff = S(ist);
209 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
210 ceff = max(ceff, max(sn.lldscaling(ist,:)));
211 end
212 UN(ist,1:K) = 0;
213 for st = wset
214 [ni,nir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
215 if ni>0
216 lldnow = 1;
217 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
218 lldnow = sn.lldscaling(ist, min(max(sum(ni),1), size(sn.lldscaling,2)));
219 end
220 for k=1:K
221 UN(ist,k) = UN(ist,k) + probSysState(st)*nir(k)*sn.schedparam(ist,k)/(nir*sn.schedparam(ist,:)')*lldnow/ceff;
222 end
223 end
224 end
225 end
226 case SchedStrategy.PAS
227 % Pass-and-swap (order-independent): utilization is the
228 % time-average number of in-service jobs per class divided by
229 % the number of servers. sir already counts the positions whose
230 % marginal rate increment is positive (toMarginal PAS branch),
231 % so a single job served by multiple server types still counts
232 % as one in-service job (not 1/rate).
233 ind = sn.stationToNode(ist);
234 UN(ist,1:K) = 0;
235 for st = wset
236 [~,~,sir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
237 for k=1:K
238 UN(ist,k) = UN(ist,k) + probSysState(st)*sir(k)/S(ist);
239 end
240 end
241 otherwise
242 if isempty(sn.lldscaling) && isempty(sn.cdscaling)
243 for k=1:K
244 if ~isempty(PH{ist}{k})
245 % There are cases where due to remove of
246 % immediate transitions or due to cutoff the
247 % utilization estimator based on arrivals or
248 % departures can be under-estimated. E.g.:
249 % UNarv_ik doesn't work well with example_stateDependentRouting_3
250 % UNdep_ik doesn't work well with test_OQN_JMT_6
251 % Therefore, we take the maximum of the two
252 % Note: the two estimators are normally
253 % identical.
254 UNdep_ik = TN(ist,k)*map_mean(PH{ist}{k})/S(ist); % this is valid because CS in LINE is in a separate node
255 if canDropClass(k)
256 UN(ist,k) = UNdep_ik;
257 else
258 UNarv_ik = probSysState*arvRates(wset,isf,k)*map_mean(PH{ist}{k})/S(ist);
259 UN(ist,k) = max(UNarv_ik,UNdep_ik);
260 end
261 end
262 end
263 else % lld/cd/ljd cases
264 % Busy-server FRACTION under load-dependent scaling (NC
265 % convention): weight the in-service indicator by the current
266 % scaling and normalize by the peak (effective capacity);
267 % plain sir/S collapses to P(busy).
268 ind = sn.stationToNode(ist);
269 ceff = S(ist);
270 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
271 ceff = max(ceff, max(sn.lldscaling(ist,:)));
272 end
273 UN(ist,1:K) = 0;
274 for st = wset
275 [ni,~,sir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
276 if ni>0
277 lldnow = 1;
278 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
279 lldnow = sn.lldscaling(ist, min(max(sum(ni),1), size(sn.lldscaling,2)));
280 end
281 sirtot = sum(sir);
282 for k=1:K
283 if sirtot > 0
284 UN(ist,k) = UN(ist,k) + probSysState(st)*(sir(k)/sirtot)*lldnow/ceff;
285 end
286 end
287 end
288 end
289 end
290 end
291 % A signal that destroys a partially served job leaves busy time behind
292 % with no completion, so T*E[S]/c is exact only for exponential service
293 % (M/Er2/1 with lambda+=0.5, lambda-=0.4 gives 0.34941 against a true
294 % 0.37696). Read the busy-server occupancy off the state space instead;
295 % it agrees with T*E[S]/c in the exponential case. The lld/cd branches
296 % already accumulate in-service occupancy, so only the plain ones need
297 % it, and INF stations report the queue length by definition.
298 if any(signalLoss) && sched(ist) ~= SchedStrategy.INF && ...
299 isempty(sn.lldscaling) && isempty(sn.cdscaling)
300 UNb = ctmc_signal_busy(sn, ind, ist, sched(ist), S(ist), StateSpace, istSpaceShift, wset, probSysState);
301 UN(ist,signalLoss) = UNb(signalLoss);
302 end
303 end
304end
305
306% Stations with limited class dependence report utilization as T*S/peak,
307% where peak is the user-declared peak rate scaling per class
308% (sn.cdscalingpeak), matching the T*S/c convention of ordinary multiserver
309% stations; the in-service accumulation above would instead report P(n>=1).
310if ~isempty(sn.cdscaling) && ~any(isinf(sn.njobs))
311 for ist=1:M
312 if length(sn.cdscaling) >= ist && ~isempty(sn.cdscaling{ist})
313 for k=1:K
314 bmax = sn.cdscalingpeak(ist,k);
315 if isfinite(sn.rates(ist,k)) && sn.rates(ist,k) > 0 && bmax > 0
316 UN(ist,k) = TN(ist,k) / sn.rates(ist,k) / bmax;
317 else
318 UN(ist,k) = 0;
319 end
320 end
321 end
322 end
323end
324
325% True BAS queue-length attribution. A job that completed service at a BAS station
326% and is held there (blocked marker = 1) awaiting room downstream is ATTRIBUTED TO
327% THE DESTINATION, not to the station it physically occupies. That is the JMT
328% convention, and it is what LDES already implements (Solver_ssj.hasBASBlocking
329% declares the policy on the destination queue), so CTMC was the odd one out inside
330% LINE; aligning removes an internal inconsistency rather than deferring to JMT.
331%
332% This is a REPORTING change only: the chain, the state space and the become-blocked
333% edge are untouched. The shift is exactly P(blocked) per station.
334%
335% Metric conventions, each verified against JMT and LDES on cqn_bas_blocking
336% (N=2, Q1 Exp(1), Q2 Exp(2), Q2 cap=1; exact X=6/7, P(blocked)=1/7):
337% QLen : blocked job counted at the DESTINATION. Q1 11/7->10/7=1.428571 (JMT
338% 1.42850, LDES 1.42710), Q2 3/7->4/7=0.571429 (JMT 0.57150, LDES
339% 0.57286).
340% RespT : follows from QN by Little below (RN=QN/TN), giving R1=10/6=1.666667
341% (JMT 1.66795, LDES 1.66610) and R2=4/6=0.666667 (JMT 0.66687, LDES
342% 0.66873). Both are self-consistent under Little, which therefore cannot
343% arbitrate the convention -- it is a placement choice, not physics.
344% Util : UNCHANGED. The blocked job holds the server but is not being served, so
345% it does not add to utilization: all engines already satisfy U=X*E[S]
346% (U1=6/7=0.857143 vs JMT 0.85735/LDES 0.85692; U2=3/7=0.428571 vs JMT
347% 0.42942/LDES 0.42978). Util is computed from the service process above,
348% not from QN, so it is untouched by construction.
349%
350% Attribution needs the destination of the blocked job. The marker records only THAT
351% a job is blocked, not for which destination, so this is applied only when the
352% station has exactly ONE downstream station, which is the case for every BAS model
353% in the tree (tandem/cyclic). With several possible destinations the state cannot
354% say which one is meant, so the job is left where it sits rather than guessed.
355if ~isempty(sn.isbasblocking)
356 for ist=1:M
357 ind = sn.stationToNode(ist);
358 % Identify the blocking station by the dedicated sn.isbasblocking field
359 % (set by refreshLocalVars for exactly the blocking/upstream stations,
360 % under BOTH the upstream and destination declaration forms), not by
361 % re-testing its own BAS drop rule -- the latter fails for a
362 % destination-declared BAS and left its QLen in the where-it-sits
363 % convention. The field also disambiguates the shared marker column from
364 % the polling controller. See BUG-83.
365 if numel(sn.isbasblocking) < ind || sn.isbasblocking(ind) ~= 1
366 continue % no blocked marker at this station
367 end
368 dests = find(sn.connmatrix(ind,:) == 1 & sn.isstation(:)' == 1);
369 if numel(dests) ~= 1
370 continue % ambiguous destination: leave the job where it sits
371 end
372 jst = sn.nodeToStation(dests(1));
373 if isnan(jst) || jst < 1
374 continue
375 end
376 cols = (istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2));
377 blocked = StateSpace(wset, cols(end)) == 1; % marker is the trailing column
378 for k=1:K
379 % Only the held job itself moves, not the whole queue at ist: a blocked
380 % state holds exactly one completed job, so cap the per-state count at 1.
381 shift = probSysState(blocked) * min(StateSpaceAggr(wset(blocked),(ist-1)*K+k), 1);
382 if shift > 0
383 QN(ist,k) = QN(ist,k) - shift;
384 QN(jst,k) = QN(jst,k) + shift;
385 end
386 end
387 end
388end
389
390for k=1:K
391 for ist=1:M
392 if TN(ist,k)>0
393 RN(ist,k) = QN(ist,k)./TN(ist,k);
394 else
395 RN(ist,k)=0;
396 end
397 end
398 CN(k) = NK(k)./XN(k);
399end
400
401QN(isnan(QN))=0;
402CN(isnan(CN))=0;
403RN(isnan(RN))=0;
404UN(isnan(UN))=0;
405XN(isnan(XN))=0;
406TN(isnan(TN))=0;
407
408runtime = toc(Tstart);
409
410% now update the routing probabilities in nodes with state-dependent routing
411TNcache = zeros(sn.nstateful,K);
412XNcache = zeros(sn.nstateful,K);
413for k=1:K
414 for isf=1:sn.nstateful
415 ind = sncopy.statefulToNode(isf);
416 if sncopy.nodetype(ind) == NodeType.Cache
417 TNcache(isf,k) = probSysState*depRates(wset,isf,k);
418 XNcache(isf,k) = probSysState*arvRates(wset,isf,k);
419 end
420 end
421end
422
423% updates cache actual hit and miss data + retrieval-system expected latency.
424% Hit / miss for class k are derived from departure rates of the configured
425% hitClass / missClass at the cache; for retrieval-aware caches the
426% miss-class departure rate equals the true miss rate because
427% afterEventCache fires the miss event on a retrieval-complete READ.
428retrievalLatencyWarned = false;
429for k=1:K
430 for isf=1:sncopy.nstateful
431 ind = sncopy.statefulToNode(isf);
432 if sncopy.nodetype(ind) == NodeType.Cache
433 np = sncopy.nodeparam{ind};
434 if length(np.hitclass)>=k
435 h = np.hitclass(k);
436 m = np.missclass(k);
437 if h>0 && m>0
438 sncopy.nodeparam{ind}.actualhitprob(k) = TNcache(isf,h)/sum(TNcache(isf,[h,m]));
439 sncopy.nodeparam{ind}.actualmissprob(k) = TNcache(isf,m)/sum(TNcache(isf,[h,m]));
440 else
441 sncopy.nodeparam{ind}.actualhitprob(k) = NaN;
442 sncopy.nodeparam{ind}.actualmissprob(k) = NaN;
443 end
444
445 % The Eq. 8 retrieval-system expected latency is not currently
446 % implemented; report NaN whenever a retrieval system is
447 % configured for this class.
448 expectedLatency = NaN;
449 if isfield(np, 'retrievalSystemQueueIndices') ...
450 && isKey(np.retrievalSystemQueueIndices, int32(k-1)) ...
451 && ~isempty(np.retrievalSystemQueueIndices(int32(k-1)))
452 if ~retrievalLatencyWarned
453 line_warning(mfilename, 'Retrieval-system expected latency is not currently implemented; reporting NaN.');
454 retrievalLatencyWarned = true;
455 end
456 end
457 sncopy.nodeparam{ind}.actualresidt(k) = expectedLatency;
458 end
459 end
460 end
461end
462end
Definition fjtag.m:157
Definition Station.m:245