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)
4% Copyright (c) 2012-2026, Imperial College London
10% qn_json = jsonencode(sn);
11% sn = NetworkStruct.fromJSON(qn_json)
15M = sn.nstations; %number of stations
16K = sn.nclasses; %number of classes
18NK = sn.njobs
'; % initial population per class
24line_debug('CTMC analyzer starting: nstations=%d, nclasses=%d, njobs=%s
', M, K, mat2str(NK));
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
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
32% if the initial state does not reflect the final size of the state
33% vectors, attempt to correct it
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}];
42 line_debug('Saving CTMC data to file (options.keep=
true)
');
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),'\
','\\
'))
51wset = 1:length(InfGen);
53line_debug('State space built: %d states, solving CTMC
', length(InfGen));
55use_ctmc_solve_stable = true;
56if use_ctmc_solve_stable
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);
63 line_debug('CTMC
is reducible: %d connected components
', nConnComp);
64 % the matrix was reducible
65 initState = matchrow(StateSpace, cell2mat(sn.state'));
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);
73 % determine
the weakly connected component associated to
the initial state
74 wset = find(connComp == connComp(initState));
77 line_debug(
'Using component %d with %d states (from initial state)', connComp(initState), length(wset));
79 line_debug(
'Using largest component with %d states (initial state removed by stochcomp)', length(wset));
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,:,:);
91 line_debug(
'CTMC is irreducible, using full state space');
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);
105 wset = scc == scc(initState);
106 InfGen = InfGen(wset, wset);
107 StateSpace = StateSpace(wset,:);
108 probSysState = pis(scc(initState),scc == scc(initState));
111probSysState(probSysState<GlobalConstants.Zero)=0;
112probSysState = probSysState/sum(probSysState);
121istSpaceShift = zeros(1,M);
124 istSpaceShift(ist) = 0;
126 istSpaceShift(ist) = istSpaceShift(ist-1) + size(sn.space{ist-1},2);
131 refsf = sn.stationToStateful(sn.refstat(k));
132 XN(k) = probSysState*arvRates(wset,refsf,k);
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
142 if sn.regionrule(f) == DropStrategy.DROP
143 memb = any(sn.region{f} ~= -1, 2); % stations constrained by region f
145 inDropRegion(1:min(M,numel(memb))) = inDropRegion(1:min(M,numel(memb))) | memb(1:min(M,numel(memb)));
151 isf = sn.stationToStateful(ist);
152 ind = sn.stationToNode(ist);
154 TN(ist,k) = probSysState*depRates(wset,isf,k);
155 QN(ist,k) = probSysState*StateSpaceAggr(wset,(ist-1)*K+k);
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;
176 case SchedStrategy.INF
178 UN(ist,k) = QN(ist,k);
180 case {SchedStrategy.PS, SchedStrategy.DPS, SchedStrategy.GPS, SchedStrategy.LPS}
181 if isempty(sn.lldscaling) && isempty(sn.cdscaling)
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
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
195 UN(ist,k) = UNdep_ik;
197 UNarv_ik = probSysState*arvRates(wset,isf,k)*map_mean(PH{ist}{k})/S(ist);
198 UN(ist,k) = max(UNarv_ik,UNdep_ik);
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);
209 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
210 ceff = max(ceff, max(sn.lldscaling(ist,:)));
214 [ni,nir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
217 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
218 lldnow = sn.lldscaling(ist, min(max(sum(ni),1), size(sn.lldscaling,2)));
221 UN(ist,k) = UN(ist,k) + probSysState(st)*nir(k)*sn.schedparam(ist,k)/(nir*sn.schedparam(ist,:)')*lldnow/ceff;
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);
236 [~,~,sir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
238 UN(ist,k) = UN(ist,k) + probSysState(st)*sir(k)/S(ist);
242 if isempty(sn.lldscaling) && isempty(sn.cdscaling)
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
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
256 UN(ist,k) = UNdep_ik;
258 UNarv_ik = probSysState*arvRates(wset,isf,k)*map_mean(PH{ist}{k})/S(ist);
259 UN(ist,k) = max(UNarv_ik,UNdep_ik);
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);
270 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
271 ceff = max(ceff, max(sn.lldscaling(ist,:)));
275 [ni,~,sir] = State.toMarginal(sn, ind, StateSpace(st,(istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2))));
278 if ~isempty(sn.lldscaling) && ist <= size(sn.lldscaling,1)
279 lldnow = sn.lldscaling(ist, min(max(sum(ni),1), size(sn.lldscaling,2)));
284 UN(ist,k) = UN(ist,k) + probSysState(st)*(sir(k)/sirtot)*lldnow/ceff;
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);
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))
312 if length(sn.cdscaling) >= ist && ~isempty(sn.cdscaling{ist})
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;
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.
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.
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
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.
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)
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
368 dests = find(sn.connmatrix(ind,:) == 1 & sn.isstation(:)' == 1);
370 continue % ambiguous destination: leave
the job where it sits
372 jst = sn.nodeToStation(dests(1));
373 if isnan(jst) || jst < 1
376 cols = (istSpaceShift(ist)+1):(istSpaceShift(ist)+size(sn.space{ist},2));
377 blocked = StateSpace(wset, cols(end)) == 1; % marker
is the trailing
column
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);
383 QN(ist,k) = QN(ist,k) - shift;
384 QN(jst,k) = QN(jst,k) + shift;
393 RN(ist,k) = QN(ist,k)./TN(ist,k);
398 CN(k) = NK(k)./XN(k);
408runtime = toc(Tstart);
410% now update
the routing probabilities in
nodes with state-dependent routing
411TNcache = zeros(sn.nstateful,K);
412XNcache = zeros(sn.nstateful,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);
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;
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
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]));
441 sncopy.nodeparam{ind}.actualhitprob(k) = NaN;
442 sncopy.nodeparam{ind}.actualmissprob(k) = NaN;
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;
457 sncopy.
nodeparam{ind}.actualresidt(k) = expectedLatency;