1function [Q,stateSpace,stateSpaceAggr,Dfilt,arvRates,depRates,sn]=solver_ctmc(sn,options)
2% [Q,SS,SSQ,DFILT,ARVRATES,DEPRATES,QN]=SOLVER_CTMC(QN,OPTIONS)
4% Copyright (c) 2012-2026, Imperial College London
7%% impatience support checks
8% Reneging
is supported only
for exponential (memoryless) patience: PH/MAP
9% patience would require a per-waiting-job phase dimension. Balking
is
10% supported only
for the QUEUE_LENGTH strategy (a pure state function).
11% Reject other cases
explicitly rather than silently ignoring them.
12if isfield(sn,
'impatienceClass') && ~isempty(sn.impatienceClass)
13 badRenege = (sn.impatienceClass==ImpatienceType.RENEGING) & (sn.impatienceType~=ProcessType.EXP);
15 line_error(mfilename,'SolverCTMC supports only exponential (memoryless) patience for reneging. Use SolverLDES or SolverJMT for phase-type patience.');
18if isfield(sn,'balkingStrategy') && ~isempty(sn.balkingStrategy)
19 badBalk = (sn.balkingStrategy~=0) & (sn.balkingStrategy~=BalkingStrategy.QUEUE_LENGTH);
21 line_error(mfilename,'SolverCTMC supports only QUEUE_LENGTH balking. Use SolverLDES or SolverJMT for wait-time-based balking.');
24% Retrial orbit
is supported only for exponential (memoryless) retrial delay
25% with unlimited attempts; phase-type delay or finite max-attempts would need
26% a per-orbiting-job phase/attempt dimension.
27if isfield(sn,'retrialProc') && ~isempty(sn.retrialProc)
28 hasRetrial = ~cellfun(@isempty, sn.retrialProc);
30 if any(hasRetrial(:) & (sn.retrialType(:)~=ProcessType.EXP))
31 line_error(mfilename,'SolverCTMC supports only exponential (memoryless) retrial delay. Use SolverLDES or SolverMAM for phase-type retrials.');
33 if any(hasRetrial(:) & (sn.retrialMaxAttempts(:)>=0))
34 line_error(mfilename,'SolverCTMC supports only unlimited retrials (maxAttempts=-1). Use SolverLDES for finite max-attempts.');
36 % A retrial orbit
is enumerated per single populated class; reject a
37 % retrial station that serves more than one class.
38 for ii = find(any(hasRetrial,2))'
40 for rr = 1:sn.nclasses
41 if ~isempty(sn.proc{ii}{rr}) && ~any(any(isnan(sn.proc{ii}{rr}{1})))
46 line_error(mfilename,
'SolverCTMC supports retrial only for single-class stations. Use SolverLDES for multi-class retrial.');
51% Signal (G-network negative-customer) classes are annihilated on arrival and
52% never occupy a station; cap their per-station capacity at 0 (except at
the
53% Source/EXT node, whose infinite buffer must stay intact) so
the state space
54% does not enumerate unreachable states holding signal jobs.
55if isfield(sn,
'issignal') && ~isempty(sn.issignal) && any(sn.issignal)
56 for ii = 1:sn.nstations
57 if sn.sched(ii) ~= SchedStrategy.EXT
58 sn.classcap(ii, sn.issignal(:)') = 0;
62% Heterogeneous servers (single-class, ORDER policy): a station whose server
63% types have different per-type rates
is mapped to a load-dependent service
64% rate mu(n) = sum of
the rates of
the first min(n,c) servers (types filled in
65% definition order). For a single served class this reproduces
the exact
66% QLen / RespT / Tput because
the birth-death dynamics depend only on mu(n).
67% Multi-class heterogeneous servers (per-type-per-class rates + compatibility)
68% need a per-server-type in-service state and are rejected here.
71 && ~isempty(sn.
nodeparam{ind}) && isstruct(sn.nodeparam{ind}) ...
72 && isfield(sn.nodeparam{ind},
'nservertypes') && sn.nodeparam{ind}.nservertypes > 0
73 ist = sn.nodeToStation(ind);
74 % PAS/OI stations model heterogeneous compatible servers through
the OI
75 % rank rate (svcRateFun), not a single-
class load-dependent scaling.
76 if sn.sched(ist) == SchedStrategy.PAS || sn.sched(ist) == SchedStrategy.OI
79 np = sn.nodeparam{ind};
82 if ~isempty(sn.proc{ist}{r}) && ~any(any(isnan(sn.proc{ist}{r}{1}))) && sn.rates(ist,r) > 0
83 served(end+1) = r; %#ok<AGROW>
87 line_error(mfilename,
'SolverCTMC supports heterogeneous servers only for single-class stations. Use SolverJMT or SolverLDES for multi-class heterogeneous servers.');
92 for t = 1:np.nservertypes
93 if np.servercompat(t,r) && np.heterorates(t,r) > 0
94 srvrates = [srvrates, repmat(np.heterorates(t,r), 1, np.serverspertype(t))]; %#ok<AGROW>
98 mu_base = sn.rates(ist,r);
99 if c > 0 && mu_base > 0
100 if isempty(sn.lldscaling)
101 sn.lldscaling = ones(sn.nstations, max([c, sum(sn.njobs(isfinite(sn.njobs))), 1]));
102 elseif size(sn.lldscaling,2) < c
103 sn.lldscaling(:, (size(sn.lldscaling,2)+1):c) = repmat(sn.lldscaling(:,end), 1, c-size(sn.lldscaling,2));
105 for n = 1:size(sn.lldscaling,2)
106 mun = sum(srvrates(1:min(n,c)));
107 sn.lldscaling(ist,n) = mun / (mu_base * min(n,c));
114%% generate state space
116nstateful = sn.nstateful;
117nclasses = sn.nclasses;
122line_debug('CTMC solver starting: nstateful=%d, nclasses=%d, sync_events=%d', nstateful, nclasses, A);
124if ~isfield(options.config, 'hide_immediate')
125 options.config.hide_immediate = true;
128if ~isfield(options.config, 'state_space_gen')
129 options.config.state_space_gen = 'default';
132%% generate state spaces, detailed and aggregate
133switch options.config.state_space_gen
134 case 'reachable' % does not handle open models yet (no cutoff)
135 line_debug('Using reachable state space generation, calling ctmc_ssg_reachability');
136 [stateSpace, stateSpaceAggr, stateSpaceHashed,~,sn] = ctmc_ssg_reachability(sn,options);
137 case {
'default',
'full'}
138 line_debug(
'Using full state space generation, calling ctmc_ssg');
139 [stateSpace, stateSpaceAggr, stateSpaceHashed,~,sn] = ctmc_ssg(sn,options);
142line_debug(
'State space generated: %d states', size(stateSpaceHashed,1));
144%% Finite Capacity Region handling
145% Both rules need
the reachability-based augmented generator
146% (solver_ctmc_fcr_waitq): WAITQ parks refused jobs in a per-region FIFO
147% released head-of-line; DROP destroys them (JMT semantics:
the job vanishes;
148%
for closed chains
the stationary regime
is the absorbed surviving
150fcrWaitq = isfield(sn,
'nregions') && sn.nregions > 0;
154 % WAITQ finite capacity region: reachability-based augmented state space
155 % (station states + per-region FIFO of blocked jobs) with exact JMT
156 % semantics; returns
the per-action rate filters directly
157 [stateSpace,stateSpaceAggr,stateSpaceHashed,Dfilt,sn] = solver_ctmc_fcr_waitq(sn,options);
158 Q = speye(size(stateSpaceHashed,1)); %
the diagonal elements will be removed later
159 % This path resolves blocking inside its own augmented state space, so it has
160 % no separate
true-BAS become-blocked transitions to fold in. basBlockQ
is
161 % still added to Q below on both branches, so it must be defined here too.
163 % See
the Qimm comment on
the else branch.
166Q = speye(size(stateSpaceHashed,1)); %
the diagonal elements will be removed later
171% True BAS become-blocked transitions (a completed job held at its server when
the
172% downstream
is full): change
the chain but are NOT station departures (
the job stays).
173% Kept out of
the departure filters Dfilt so throughput
is not double-counted; added
174% straight into Q below.
176% Immediate-provenance accumulator, filled in parallel with Q by every arc
177% emitted on
the GlobalConstants.Immediate scale (Router/Fork/Join pass-through,
178% fork firings, SPN ENABLE phase moves and TimingStrategy.IMMEDIATE firings). In
179% a vanishing marking
the exact GSPN semantics give
the timed arcs probability
180% zero, but a finite Immediate rate lets a timed arc win
the race with
181% probability mu/(mu+Immediate), which survives stochastic complementation as an
182% O(mu/Immediate) ~ 1e-8 relative bias on every marginal. Overwriting each
183% vanishing row with its immediate-only counterpart below removes that bias
184% exactly rather than merely shrinking it.
186local = sn.nnodes+1; % passive action
189% Adj_t = zeros(size(SSh,1),size(SSh,1));
190% Adj_m = zeros(size(SSh,1),size(SSh,1));
191%
if ~isempty(Adj) && ~isempty(ST)
192% edges = adj_to_mat(Adj);
195%%
for all synchronizations
197 stateCell = cell(nstateful,1);
198 %sn.sync{a}.active{1}.print
199 for s=1:size(stateSpaceHashed,1)
201 state = stateSpaceHashed(s,:);
203 % ustate = stateSpace(s,:);
205 %
for st=1:length(ustate)
206 %
if ~isempty(sn.varsparam{st}) && isfield(sn.varsparam{st},
'nodeToPlace')
207 % state_pn(sn.varsparam{st}.nodeToPlace) = ustate(st);
211 % update state cell array and SSq
212 for ind = 1:sn.nnodes
213 if sn.isstateful(ind)
214 isf = sn.nodeToStateful(ind);
215 stateCell{isf} = sn.space{isf}(state(isf),:);
216 %
if sn.isstation(ind)
217 % ist = sn.nodeToStation(ind);
218 % [~,nir] = State.toMarginal(sn,ind,stateCell{isf});
222 node_a = sync{a}.active{1}.node;
223 state_a = state(sn.nodeToStateful(node_a));
224 class_a = sync{a}.active{1}.class;
225 event_a = sync{a}.active{1}.event;
226 [new_state_a, rate_a] = State.afterEventHashed( sn, node_a, state_a, event_a, class_a);
228 %[new_state_a, rate_a,~,trans_a, modes_a] = State.afterEventHashed( qn, node_a, state_a, event_a, class_a);
231 %
if true%options.verbose == 2
232 % line_printf(
'---\n');
233 % sync{a}.active{1}.print,
236 if new_state_a == -1 % hash not found
239 for ia=1:length(new_state_a)
242 %
if rate_a(ia)>0 || modes_a(ia) > 0
243 node_p = sync{a}.passive{1}.node;
245 % Skip
if the active transition hash was not found
246 if new_state_a(ia) == -1
249 state_p = state(sn.nodeToStateful(node_p));
250 class_p = sync{a}.passive{1}.class;
251 event_p = sync{a}.passive{1}.event;
255 %
if ia <= length(trans_a)
256 % % check
if other input places of
the transition contains as many token as
the multiplicity of
the input arcs
258 % mode = modes_a(ia);
259 % bmatrix = sn.varsparam{tr}.back(:,mode);
260 % inmatrix = sn.varsparam{tr}.inh(:,mode);
261 % enabled = all(state_pn >= bmatrix
' & ~any(inmatrix'>0 & inmatrix
' <= state_pn));
264 %prob_sync_p = sync{a}.passive{1}.prob(state_a, state_p)
267 %if options.verbose == 2
268 % line_printf('---\n
');
269 % sync{a}.active{1}.print,
270 % sync{a}.passive{1}.print
273 if node_p == node_a %self-loop
274 [new_state_p, ~, outprob_p] = State.afterEventHashed( sn, node_p, new_state_a(ia), event_p, class_p);
276 [new_state_p, ~, outprob_p] = State.afterEventHashed( sn, node_p, state_p, event_p, class_p);
279 % if node_p == node_a %self-loop
280 % [new_state_p, ~, outprob_p, trans_p, modes_p] = State.afterEventHashed( qn, node_p, new_state_a(ia), event_p, class_p);
282 % [new_state_p, ~, outprob_p, trans_p, modes_p] = State.afterEventHashed( qn, node_p, state_p, event_p, class_p);
284 for ip=1:size(new_state_p,1)
287 if sn.isstatedep(node_a,3)
288 newStateCell = stateCell;
289 newStateCell{sn.nodeToStateful(node_a)} = sn.space{sn.nodeToStateful(node_a)}(new_state_a(ia),:);
290 newStateCell{sn.nodeToStateful(node_p)} = sn.space{sn.nodeToStateful(node_p)}(new_state_p(ip),:);
291 prob_sync_p = sync{a}.passive{1}.prob(stateCell, newStateCell) * outprob_p(ip); %state-dependent
293 prob_sync_p = sync{a}.passive{1}.prob * outprob_p(ip);
299 if ~isempty(new_state_a(ia))
300 if node_p == local % local action
302 new_state(sn.nodeToStateful(node_a)) = new_state_a(ia);
303 prob_sync_p = outprob_p(ip);
304 elseif ~isempty(new_state_p)
306 new_state(sn.nodeToStateful(node_a)) = new_state_a(ia);
307 new_state(sn.nodeToStateful(node_p)) = new_state_p(ip);
311 % ns = find(ismember(SSh(:,[sn.nodeToStateful(node_a),sn.nodeToStateful(node_p)]),[new_state_a(ia),new_state_p(ip)],'rows
'));
312 % for ins=1:length(ns)
313 % if ns(ins) > 0 && ~isempty(trans_p)
315 % mode = modes_p(ip);
316 % bmatrix = sn.varsparam{tr}.back(:,mode);
317 % fmatrix = sn.varsparam{tr}.forw(:,mode);
318 % cmatrix = fmatrix - bmatrix;
319 % if isequal(state_pn + cmatrix',SS(ns(ins),3:end))
320 % [ex_a,seq_a] = ST.search(state_pn
');
321 % [ex_p,seq_p] = ST.search(SS(ns(ins),3:end)');
322 %
if ex_a && ex_p && edges(seq_a, seq_p)
323 % Adj_m(s, ns(ins)) = modes_p(ip);
324 % Adj_t(s, ns(ins)) = trans_p(ip);
326 %
if ~isnan(rate_a(ia))
327 %
if node_p < local && ~csmask(class_a, class_p) && rate_a(ia) * prob_sync_p >0 && (sn.nodetype(node_p)~=NodeType.Source)
328 % error(
'Error: state-dependent routing at node %d (%s) violates the class switching mask (node %d -> node %d, class %d -> class %d).', node_a, sn.nodenames{node_a}, node_a, node_p, class_a, class_p);
330 %
if size(Dfilt{a}) >= [s,ns(ins)] % check needed as D{a}
is a sparse matrix
331 % Dfilt{a}(s,ns(ins)) = Dfilt{a}(s,ns(ins)) + rate_a(ia) * prob_sync_p;
333 % Dfilt{a}(s,ns(ins)) = rate_a(ia) * prob_sync_p;
342 ns = matchrow(stateSpaceHashed, new_state);
345 if node_p < local && ~csmask(class_a, class_p) && rate_a(ia) * prob_sync_p >0 && (sn.nodetype(node_p)~=NodeType.Source)
346 line_error(mfilename,sprintf(
'Error: state-dependent routing at node %d (%s) violates the class switching mask (node %s -> node %s, class %s -> class %s).', node_a, sn.nodenames{node_a}, sn.nodenames{node_a}, sn.nodenames{node_p}, sn.classnames{class_a}, sn.classnames{class_p}));
348 if size(Dfilt{a}) >= [s,ns] % check needed as D{a}
is a sparse matrix
349 Dfilt{a}(s,ns) = Dfilt{a}(s,ns) + rate_a(ia) * prob_sync_p;
351 Dfilt{a}(s,ns) = rate_a(ia) * prob_sync_p;
359 % True BAS: a service completion (DEP, b=0) at a finite-capacity BAS
360 % station whose destination
is full (passive ARV yielded no admissible
361 % state). Rather than voiding
the departure (= repetitive service), hold
362 %
the completed job at
the server: transition to node_a's SAME local state
363 % with
the blocked marker set (b:0->1), at rate mu_i. The held job later
364 % transfers to
the destination at rate 1e7 (afterEventStation). Added to
365 % basBlockQ (Q only), NOT Dfilt, so it
is not counted as a departure.
367 if event_a == EventType.DEP && rate_a(ia) > 0 ...
368 && ~isempty(sn.isbasblocking) && numel(sn.isbasblocking) >= node_a ...
369 && sn.isbasblocking(node_a) == 1 ...
370 && all(new_state_p(:) == -1)
371 isfA = sn.nodeToStateful(node_a);
372 curVecA = sn.space{isfA}(state(isfA),:);
374 blockedVec = curVecA; blockedVec(end) = 1;
375 blockedIdx = matchrow(sn.space{isfA}, blockedVec);
378 new_state_b(isfA) = blockedIdx;
379 nsb = matchrow(stateSpaceHashed, new_state_b);
381 basBlockQ(s,nsb) = basBlockQ(s,nsb) + rate_a(ia);
386 else % node_p == local
387 if ~isempty(new_state_a(ia))
389 new_state(sn.nodeToStateful(node_a)) = new_state_a(ia);
391 ns = matchrow(stateSpaceHashed, new_state);
394 if size(Dfilt{a}) >= [s,ns] % needed
for sparse matrix
395 Dfilt{a}(s,ns) = Dfilt{a}(s,ns) + rate_a(ia) * prob_sync_p;
397 Dfilt{a}(s,ns) = rate_a(ia) * prob_sync_p;
409% An action emits on
the Immediate scale when its active node
is one of
the
410% pass-through node types whose occupancy ctmc_find_vanishing_states marks as
411% vanishing (State.afterEventRouter / State.afterEventJoin
return
412% GlobalConstants.Immediate). Keep
the two predicates in step: a node marked
413% vanishing but not recognised here would have its whole row zeroed.
414isfjaug = isfield(sn,
'fjsync') && ~isempty(sn.fjsync);
415immAction =
false(1,A);
417 nt_a = sn.nodetype(sync{a}.active{1}.node);
418 immAction(a) = (nt_a == NodeType.Router) || (nt_a == NodeType.Fork) || (isfjaug && nt_a == NodeType.Join);
423 Qimm = Qimm + Dfilt{a};
426% Fold in
true-BAS become-blocked transitions (not counted as departures).
429%%
for all global synchronizations (SPN support)
430if isfield(sn,
'gsync') && ~isempty(sn.gsync)
431 gsyncEvents = sn.gsync;
432 G = length(gsyncEvents);
434 % Track FIRE completion rates for arvRates/depRates
435 Dfilt_gsync_comp = cell(1, G);
437 Dfilt_gsync_comp{g} = sparse(size(stateSpaceHashed,1), size(stateSpaceHashed,1));
439 immGsync =
false(1,G);
442 gind = gsyncEvents{g}.active{1}.node;
443 isf_transition = sn.nodeToStateful(gind);
444 nmodes_g = sn.nodeparam{gind}.nmodes;
445 % ENABLE phase moves and firings of a TimingStrategy.IMMEDIATE mode are
446 %
the two gsync sources emitted at
the GlobalConstants.Immediate scale.
447 if gsyncEvents{g}.active{1}.event == EventType.ENABLE
449 elseif gsyncEvents{g}.active{1}.event == EventType.FIRE
450 mode_g = gsyncEvents{g}.active{1}.mode;
451 immGsync(g) = isfield(sn.nodeparam{gind},
'timing') && ~isempty(sn.nodeparam{gind}.timing) ...
452 && mode_g <= numel(sn.nodeparam{gind}.timing) ...
453 && sn.nodeparam{gind}.timing(mode_g) == TimingStrategy.IMMEDIATE;
456 for s = 1:size(stateSpaceHashed, 1)
457 state = stateSpaceHashed(s, :);
459 % Build glspace cell array from hashed state
460 glspace = cell(nstateful, 1);
461 for isf = 1:nstateful
462 glspace{isf} = sn.space{isf}(state(isf), :);
465 % Process event (both ENABLE and FIRE)
466 [outglspace, outrate, outprob, outcomp] = State.afterGlobalEvent(sn, gind, glspace, gsyncEvents{g},
false);
472 for io = 1:length(outrate)
477 % Build
new hashed state
480 % Hash transition
's new state
481 if size(outglspace{isf_transition}, 1) < io
484 trans_state = outglspace{isf_transition}(io, :);
485 hash_t = matchrow(sn.space{isf_transition}, trans_state);
486 if hash_t <= 0, continue; end
487 new_state(isf_transition) = hash_t;
490 % For FIRE events: apply the place updates that afterGlobalEvent
491 % already staged in outglspace (PRE consumption / POST production
492 % on completion). Take the completion flag from afterGlobalEvent
493 % rather than re-deriving it here. Two heuristics have been wrong:
494 % the transition idle-count columns are interleaved per mode
495 % ([idle_m, phases_m]) and so are not contiguous over 1:nmodes,
496 % and "some place marking changed" is false for a firing whose
497 % outcome returns exactly what its enabling condition consumed
498 % (e.g. P1 -> T1 -> P1), which is marking-invariant yet fires at
499 % a nonzero rate -- that reported Tput = 0 for such nets.
500 if gsyncEvents{g}.active{1}.event == EventType.FIRE
501 if ~isempty(outcomp) && io <= numel(outcomp)
502 is_comp = outcomp(io);
504 for isf = 1:nstateful
505 if isf ~= isf_transition && ~isequal(glspace{isf}, outglspace{isf})
506 hash_p = matchrow(sn.space{isf}, outglspace{isf});
507 if hash_p <= 0, continue; end
508 new_state(isf) = hash_p;
512 % For ENABLE events: only Transition state changes, Places unchanged
514 ns = matchrow(stateSpaceHashed, new_state);
517 if ~isempty(outprob) && io <= length(outprob)
518 prob_val = outprob(io);
520 rate_val = outrate(io) * prob_val;
521 Q(s, ns) = Q(s, ns) + rate_val;
523 Qimm(s, ns) = Qimm(s, ns) + rate_val;
526 Dfilt_gsync_comp{g}(s, ns) = Dfilt_gsync_comp{g}(s, ns) + rate_val;
534%% for all fork firing synchronizations (native fork-join support)
536if isfield(sn,'fjsync
') && ~isempty(sn.fjsync)
537 FJ = length(sn.fjsync);
538 Dfilt_fjsync = cell(1,FJ);
540 Dfilt_fjsync{k} = sparse(size(stateSpaceHashed,1), size(stateSpaceHashed,1));
543 for s=1:size(stateSpaceHashed,1)
544 state = stateSpaceHashed(s,:);
545 glspace = cell(nstateful,1);
547 glspace{isf} = sn.space{isf}(state(isf),:);
549 [fjStates, fjrate, fjprob] = State.afterFJEvent(sn, sn.fjsync{k}, glspace, false);
550 for io=1:length(fjStates)
557 if ~isequal(glspace{isf}, fjStates{io}{isf})
558 newrow = fjStates{io}{isf};
559 if length(newrow) < size(sn.space{isf},2)
560 newrow = [zeros(1,size(sn.space{isf},2)-length(newrow)), newrow];
562 hash_p = matchrow(sn.space{isf}, newrow);
567 new_state(isf) = hash_p;
573 ns = matchrow(stateSpaceHashed, new_state);
575 rate_val = fjrate(io) * fjprob(io);
576 Q(s,ns) = Q(s,ns) + rate_val;
577 Qimm(s,ns) = Qimm(s,ns) + rate_val;
578 Dfilt_fjsync{k}(s,ns) = Dfilt_fjsync{k}(s,ns) + rate_val;
585%% vanishing-row purge
586% Replace every vanishing row by its immediate-only counterpart so that the
587% embedded chain extracted by stochastic complementation below is the exact GSPN
588% one, rather than one perturbed at O(rate/GlobalConstants.Immediate). The same
589% masking is applied to the rate filters, so an event that the exact semantics
590% never let fire in a vanishing marking is not counted in arvRates/depRates
591% either. immPurged records that the predicate has already been evaluated on
592% this state space, so it is not recomputed below when no state is dropped.
594if options.config.hide_immediate
595 immPurged = ctmc_find_vanishing_states(sn, stateSpaceHashed, nclasses, nstateful, FJ);
596 % A state marked vanishing must have at least one immediate outgoing arc. A
597 % zero row in Qimm therefore means the vanishing predicate and the
598 % immediate-arc tagging have drifted apart; purging it would manufacture an
599 % absorbing state and make -Q22 singular. Report the gap and leave those
600 % rows alone rather than corrupting the chain. They still take part in the
601 % stochastic complementation below, exactly as before this fix.
604 immGap = immRows(full(sum(Qimm(immRows,:),2)) <= 0);
606 line_warning_always(mfilename, 'CTMC: %d vanishing state(s) have no immediate outgoing arc;
the vanishing predicate and
the immediate-arc tagging disagree, so those rows keep their timed arcs.
', numel(immGap));
607 immRows = setdiff(immRows, immGap);
611 Q(immRows,:) = Qimm(immRows,:);
614 Dfilt{a}(immRows,:) = 0;
617 if exist('Dfilt_gsync_comp
','var
')
618 for g=1:numel(Dfilt_gsync_comp)
620 Dfilt_gsync_comp{g}(immRows,:) = 0;
627Q = Q - diag(diag(Q));
628%SolverCTMC.printInfGen(Q,stateSpace)
630arvRates = zeros(size(stateSpaceHashed,1),nstateful,nclasses);
631depRates = zeros(size(stateSpaceHashed,1),nstateful,nclasses);
634 node_a = sync{a}.active{1}.node;
635 class_a = sync{a}.active{1}.class;
636 event_a = sync{a}.active{1}.event;
638 node_p = sync{a}.passive{1}.node;
639 class_p = sync{a}.passive{1}.class;
640 if event_a == EventType.DEP
641 node_a_sf = sn.nodeToStateful(node_a);
642 node_p_sf = sn.nodeToStateful(node_p);
643 for s=1:size(stateSpaceHashed,1)
644 depRates(s,node_a_sf,class_a) = depRates(s,node_a_sf,class_a) + sum(Dfilt{a}(s,:));
645 arvRates(s,node_p_sf,class_p) = arvRates(s,node_p_sf,class_p) + sum(Dfilt{a}(s,:));
650%% Compute arrival/departure rates for gsync FIRE completion events
651if isfield(sn, 'gsync
') && ~isempty(sn.gsync)
652 gsyncEvents = sn.gsync;
653 G = length(gsyncEvents);
655 if gsyncEvents{g}.active{1}.event == EventType.FIRE
656 for j = 1:length(gsyncEvents{g}.passive)
657 pev = gsyncEvents{g}.passive{j};
658 [pev_node, pev_class] = ind2sub([sn.nnodes, nclasses], pev.node);
659 if pev_node > sn.nnodes || ~sn.isstateful(pev_node)
662 pev_isf = sn.nodeToStateful(pev_node);
663 if pev.event == EventType.PRE
664 for s = 1:size(stateSpaceHashed, 1)
665 depRates(s, pev_isf, pev_class) = depRates(s, pev_isf, pev_class) + sum(Dfilt_gsync_comp{g}(s,:));
667 elseif pev.event == EventType.POST
668 for s = 1:size(stateSpaceHashed, 1)
669 arvRates(s, pev_isf, pev_class) = arvRates(s, pev_isf, pev_class) + sum(Dfilt_gsync_comp{g}(s,:));
677%% Compute arrival/departure rates for fork firing synchronizations
680 fjentry = sn.fjsync{k};
681 isf_fork = sn.nodeToStateful(fjentry.fork);
682 for s=1:size(stateSpaceHashed,1)
683 rowsum = sum(Dfilt_fjsync{k}(s,:));
685 depRates(s, isf_fork, fjentry.class) = depRates(s, isf_fork, fjentry.class) + rowsum;
686 for b=1:length(fjentry.branchheads)
687 isf_bh = sn.nodeToStateful(fjentry.branchheads(b));
688 arvRates(s, isf_bh, fjentry.auxclasses(b)) = arvRates(s, isf_bh, fjentry.auxclasses(b)) + rowsum;
695zero_row = find(sum(Q,2)==0);
696zero_col = find(sum(Q,1)==0);
699% in case the last column of Q represent a state for a transient class, it
700% is possible that no transitions go back to it, although it is valid for
701% the system to be initialized in that state. So we need to fill-in the
703Q(:,end+1:end+(size(Q,1)-size(Q,2)))=0;
704Q(zero_row,zero_row) = -eye(length(zero_row)); % can this be replaced by []?
705Q(zero_col,zero_col) = -eye(length(zero_col));
707 Dfilt{a}(:,end+1:end+(size(Dfilt{a},1)-size(Dfilt{a},2)))=0;
710if options.verbose == VerboseLevel.DEBUG || GlobalConstants.Verbose == VerboseLevel.DEBUG
711 SolverCTMC.printInfGen(Q,stateSpace);
713Q = ctmc_makeinfgen(Q);
715%% drop states unreachable from the initial state
716% The default state space generator enumerates the whole population lattice, so
717% for a model whose reachable set is constrained -- a Petri net with P-invariants
718% is the clearest case -- it also produces markings that cannot be reached. Some
719% of those enable nothing at all and are therefore absorbing, which leaves the
720% generator with several recurrent classes and no unique stationary distribution:
721% ctmc_solve then rejects it as malformed. Such states carry zero probability by
722% definition, and no reachable state has an arc into them, so restricting every
723% quantity to the reachable set is exact rather than an approximation.
724% This runs before the immediate-state removal below, since the initial state may
725% itself be vanishing and is then absent from the complemented chain.
726if ~isempty(sn.state) && all(~cellfun(@isempty, sn.state))
727 initState = matchrow(stateSpace, cell2mat(sn.state'));
729 adj = (Q - diag(diag(Q))) > 0;
730 reach =
false(size(Q,1),1);
731 reach(initState) =
true;
732 frontier = initState;
733 while ~isempty(frontier)
734 nxt = find(any(adj(frontier,:),1))
';
735 nxt = nxt(~reach(nxt));
740 line_debug('CTMC: %d of %d states unreachable from
the initial state, dropped
', ...
741 sum(~reach), numel(reach));
743 immPurged = []; % state indices shift, the predicate must be re-evaluated
745 Q = ctmc_makeinfgen(Q);
746 stateSpace = stateSpace(keep,:);
747 stateSpaceAggr = stateSpaceAggr(keep,:);
748 stateSpaceHashed = stateSpaceHashed(keep,:);
749 arvRates = arvRates(keep,:,:);
750 depRates = depRates(keep,:,:);
752 Dfilt{a} = Dfilt{a}(keep,keep);
755 Dfilt_fjsync{k} = Dfilt_fjsync{k}(keep,keep);
757 if exist('Dfilt_gsync_comp
','var
')
758 for g=1:numel(Dfilt_gsync_comp)
759 Dfilt_gsync_comp{g} = Dfilt_gsync_comp{g}(keep,keep);
766%% now remove immediate transitions
767% we first determine states in stateful nodes where there is an immediate
770if options.config.hide_immediate % if want to remove immediate transitions
771 % Design Y — Router-as-immediate-pass-through.
773 % Non-station stateful nodes whose presence-of-jobs signals an immediate
774 % transition are listed explicitly here (positive list, not "everything
775 % except Cache/Transition"), so a future node type added to the language
776 % defaults to NOT being silently eliminated by stochcomp — the developer
777 % must opt in. Currently only Router qualifies:
778 % - Router : pure zero-time pass-through. RROBIN/WRROBIN pointers are
779 % kept in the per-node state vector, but Router-occupied
780 % global states are folded into their downstream successors
781 % via stochastic complementation.
782 % - Cache : immediate read/write transitions kept visible so hit/miss
783 % rates can be computed (NOT in this list).
784 % - Transition (SPN): timed D1 firings + ENABLE-driven phase moves,
785 % handled by the gsync block below (NOT in this list).
786 % - Fork : (FJ-augmented structs only) holds the parent job for one
787 % vanishing state before the fork firing (sn.fjsync).
788 if isempty(immPurged)
789 imm = ctmc_find_vanishing_states(sn, stateSpaceHashed, nclasses, nstateful, FJ);
791 imm = immPurged; % already evaluated on this state space by the purge above
793 nonimm = setdiff(1:size(Q,1),imm);
794 stateSpace(imm,:) = [];
795 stateSpaceAggr(imm,:) = [];
797 [Q,~,Q12,~,Q22] = ctmc_stochcomp(Q, nonimm);
799 if FJ > 0 || ~isempty(imm)
800 % Actions that occur in vanishing states (fork firings, join
801 % departures, and the firing of immediate SPN modes) would be lost by
802 % simply dropping the vanishing rows: the place they take tokens from
803 % then reports zero throughput while still showing a nonzero arrival
804 % rate. Per-action rates are therefore recomputed here, before the
805 % Dfilt complement below overwrites the raw filters. The exact
806 % long-run rate of action a as seen from timed state s is the
807 % direct exit rate via a (to any destination, including vanishing
808 % ones) plus the expected number of a-firings along the vanishing
809 % chain entered from s: r_a = Dfilt{a}(nonimm,:)*1
810 % + Q12*(-Q22)^(-1)*(Dfilt{a}(imm,:)*1).
811 arvRates = zeros(length(nonimm),nstateful,nclasses);
812 depRates = zeros(length(nonimm),nstateful,nclasses);
815 node_a = sync{a}.active{1}.node;
816 class_a = sync{a}.active{1}.class;
817 event_a = sync{a}.active{1}.event;
819 node_p = sync{a}.passive{1}.node;
820 class_p = sync{a}.passive{1}.class;
821 if event_a == EventType.DEP
822 node_a_sf = sn.nodeToStateful(node_a);
823 node_p_sf = sn.nodeToStateful(node_p);
824 % A DEP is a TIMED service completion, which fires only from a
825 % tangible marking: in exact SPN/GSPN semantics a timed transition
826 % is disabled while an immediate transition is enabled, so it never
827 % fires during the zero-time vanishing sojourn. Complementing the
828 % vanishing rows (as the immediate SPN-FIRE and fork/join firings
829 % below legitimately do) would credit firings that physically cannot
830 % occur, inflating the departure rate at any station that shares a
831 % marking with an immediate node (e.g. an INF Delay downstream of an
832 % immediate Router in a class-switching cache model, whose RespT then
833 % drifts off the exact service time). Use the direct tangible rate.
834 r_a = full(sum(Dfilt{a}(nonimm,:),2));
835 depRates(:,node_a_sf,class_a) = depRates(:,node_a_sf,class_a) + r_a;
836 arvRates(:,node_p_sf,class_p) = arvRates(:,node_p_sf,class_p) + r_a;
839 % SPN firings: a mode declared IMMEDIATE fires only in vanishing
840 % states, so its PRE/POST token moves survive solely through the
842 if isfield(sn, 'gsync
') && ~isempty(sn.gsync) && exist('Dfilt_gsync_comp
','var
')
843 gsyncEvents_rc = sn.gsync;
844 for g = 1:length(gsyncEvents_rc)
845 if gsyncEvents_rc{g}.active{1}.event ~= EventType.FIRE
848 r_g = solver_ctmc_ratecomplement(Dfilt_gsync_comp{g}, nonimm, imm, Q12, Q22);
849 for j = 1:length(gsyncEvents_rc{g}.passive)
850 pev = gsyncEvents_rc{g}.passive{j};
851 [pev_node, pev_class] = ind2sub([sn.nnodes, nclasses], pev.node);
852 if pev_node > sn.nnodes || ~sn.isstateful(pev_node)
855 pev_isf = sn.nodeToStateful(pev_node);
856 if pev.event == EventType.PRE
857 depRates(:, pev_isf, pev_class) = depRates(:, pev_isf, pev_class) + r_g;
858 elseif pev.event == EventType.POST
859 arvRates(:, pev_isf, pev_class) = arvRates(:, pev_isf, pev_class) + r_g;
864 % fork firings: departure of the parent class at the Fork, one
865 % sibling arrival per branch head in the tag's auxiliary classes
867 fjentry = sn.fjsync{k};
868 isf_fork = sn.nodeToStateful(fjentry.fork);
869 r_k = solver_ctmc_ratecomplement(Dfilt_fjsync{k}, nonimm, imm, Q12, Q22);
870 depRates(:,isf_fork,fjentry.class) = depRates(:,isf_fork,fjentry.class) + r_k;
871 for b=1:length(fjentry.branchheads)
872 isf_bh = sn.nodeToStateful(fjentry.branchheads(b));
873 arvRates(:,isf_bh,fjentry.auxclasses(b)) = arvRates(:,isf_bh,fjentry.auxclasses(b)) + r_k;
878 % stochastic complement for action a
879 Q21a = Dfilt{a}(imm,nonimm);
882 Dfilt{a} = Dfilt{a}(nonimm,nonimm)+Ta;
884 % recompute arvRates and depRates
885 % arvRates = zeros(size(stateSpace,1),nstateful,nclasses);
886 % depRates = zeros(size(stateSpace,1),nstateful,nclasses);
889 % node_a = sync{a}.active{1}.node;
890 % class_a = sync{a}.active{1}.class;
891 % event_a = sync{a}.active{1}.event;
893 % node_p = sync{a}.passive{1}.node;
894 % class_p = sync{a}.passive{1}.class;
895 %
if event_a == EventType.DEP
896 % node_a_sf = sn.nodeToStateful(node_a);
897 % node_p_sf = sn.nodeToStateful(node_p);
898 %
for s=1:size(stateSpace,1)
899 % depRates(s,node_a_sf,class_a) = depRates(s,node_a_sf,class_a) + sum(Dfilt{a}(s,:));
900 % arvRates(s,node_p_sf,class_p) = arvRates(s,node_p_sf,class_p) + sum(Dfilt{a}(s,:));
905%SolverCTMC.printInfGen(Q,stateSpace)
908%
if ~isempty(Adj) && ~isempty(ST)
910%
for s=1:size(SSh, 1)
911% % check
for immediate transitions
912% enabled_t = find(Adj_t(s,:));
914%
for t=1:length(enabled_t)
915%
if sn.varsparam{Adj_t(s,enabled_t(t))}.timingstrategies(Adj_m(s,enabled_t(t)))
916% imm_t = [imm_t, enabled_t(t)];
919% % Immediate transitions exists,
the marking needs to be removed.
922% non_imm = setdiff(enabled_t,imm_t);
923% inM = find(Adj_t(:,s));
924%
for in=1:length(inM)
925% depRates(inM(in),:,:) = depRates(inM(in),:,:) + depRates(s,:,:);
926%
for nim=1:length(non_imm)
927% Q(inM(in), non_imm(nim)) = Q(inM(in), non_imm(nim)) + Q(inM(in), s) + Q(s, non_imm(nim));
928%
if Adj_t(inM(in), non_imm(nim))==0
929% Adj_t(inM(in), non_imm(nim)) = Adj_t(inM(in), s);
930% Adj_m(inM(in), non_imm(nim)) = Adj_m(inM(in), s);
932% arvRates(non_imm(nim),:,:) = arvRates(non_imm(nim),:,:) + arvRates(s,:,:);
935%
for im=1:length(imm_t)
936% weight = sn.varsparam{Adj_t(s,imm_t(im))}.firingweights(Adj_m(s,imm_t(im)));
937% totalWeight = totalWeight + weight;
938%
if Adj_t(inM(in), imm_t(im))==0
939% Adj_t(inM(in), imm_t(im)) = Adj_t(inM(in), s);
940% Adj_m(inM(in), imm_t(im)) = Adj_m(inM(in), s);
943%
for im=1:length(imm_t)
944% weight = sn.varsparam{Adj_t(s,imm_t(im))}.firingweights(Adj_m(s,imm_t(im)));
945% Q(inM(in), imm_t(im)) = Q(inM(in), imm_t(im)) + Q(inM(in), s) * (weight/totalWeight);
957% arvRates(imm,:,:) = [];
958% depRates(imm,:,:) = [];
962%Q = ctmc_makeinfgen(Q);
966function imm = ctmc_find_vanishing_states(sn, stateSpaceHashed, nclasses, nstateful, FJ)
967% Indices of
the vanishing (zero-sojourn) global states: Router/Fork
968% pass-through occupancy, firable Join sibling sets, SPN markings from
969% which an ENABLE
event moves
the Transition row, and markings enabling a
970% TimingStrategy.IMMEDIATE mode. Extracted so
the same predicate drives
971% both
the vanishing-row purge and
the stochastic complementation below.
972 isImmediatePassThrough = @(nt) (nt == NodeType.Router || nt == NodeType.Fork);
975 for ind = 1:sn.nnodes
976 if sn.isstateful(ind) && ~sn.isstation(ind) && isImmediatePassThrough(sn.nodetype(ind))
977 isf = sn.nodeToStateful(ind);
978 imm_st = find(sum(sn.space{isf}(:,1:nclasses),2)>0);
979 imm = [imm; find(arrayfun(@(a) any(a==imm_st),stateSpaceHashed(:,isf)))];
982 % Join-firable states (native fork-join): a Join holding a complete
983 % sibling set (or a plain job) fires immediately, so such global
984 % states are vanishing; incomplete sibling sets are NOT immediate
985 % (they are
the genuine synchronization-delay states)
987 for ind = 1:sn.nnodes
988 if sn.nodetype(ind) == NodeType.Join
989 isf = sn.nodeToStateful(ind);
991 origcl = sn.nodeparam{ind}.fj.origclasses;
992 for row=1:size(sn.space{isf},1)
994 [ospace_j] = State.afterEventJoin(sn, ind, sn.space{isf}(row,:), EventType.DEP, r,
false, [], NaN);
995 if ~isempty(ospace_j)
996 firable_rows(end+1) = row; %#ok<AGROW>
1001 if ~isempty(firable_rows)
1002 imm = [imm; find(arrayfun(@(a) any(a==firable_rows),stateSpaceHashed(:,isf)))]; %#ok<AGROW>
1007 % Transition immediate states: states where any ENABLE
event would change
the state
1008 if isfield(sn,
'gsync') && ~isempty(sn.gsync)
1009 gsyncEvents_sc = sn.gsync;
1010 for s = 1:size(stateSpaceHashed, 1)
1012 continue; % already marked
1014 state_sc = stateSpaceHashed(s, :);
1015 glspace_sc = cell(nstateful, 1);
1016 for isf = 1:nstateful
1017 glspace_sc{isf} = sn.space{isf}(state_sc(isf), :);
1019 for g = 1:length(gsyncEvents_sc)
1020 if gsyncEvents_sc{g}.active{1}.event == EventType.ENABLE
1021 gind_sc = gsyncEvents_sc{g}.active{1}.node;
1022 isf_t_sc = sn.nodeToStateful(gind_sc);
1023 orig_row_sc = glspace_sc{isf_t_sc};
1024 [outgl_sc, outrate_sc, ~] = State.afterGlobalEvent(sn, gind_sc, glspace_sc, gsyncEvents_sc{g},
false);
1025 % A state
is vanishing only
if the ENABLE
event actually
1026 % changes
the transition
's own row (mirrors the native-Python
1027 % detection). Marking on a non-empty outrate alone over-censors
1028 % multi-mode transitions: for a mode whose servers already
1029 % match the enabling degree the event returns a no-op outcome
1030 % equal to the current row, which must NOT be treated as
1031 % immediate (doing so collapsed the tangible state space).
1033 if ~isempty(outrate_sc)
1034 og_sc = outgl_sc{isf_t_sc};
1035 for io=1:size(og_sc,1)
1036 if outrate_sc(io) > 0 && ~isequal(og_sc(io,:), orig_row_sc)
1043 imm = [imm; s]; %#ok<AGROW>
1050 % Immediate-firing states (SPN): a marking that enables a mode declared
1051 % TimingStrategy.IMMEDIATE is vanishing, since that mode fires in zero time.
1052 % afterGlobalEvent emits such firings at GlobalConstants.Immediate scaled by
1053 % the firing weight, so the branching among competing immediate modes is
1054 % their weight ratio and any timed mode enabled in the same marking loses the
1055 % race. Complementing these states out below leaves the embedded chain over
1056 % the tangible markings, which is what a GSPN steady-state solution is.
1057 if isfield(sn, 'gsync
') && ~isempty(sn.gsync)
1058 gsyncEvents_im = sn.gsync;
1059 for g = 1:length(gsyncEvents_im)
1060 if gsyncEvents_im{g}.active{1}.event ~= EventType.FIRE
1063 gind_im = gsyncEvents_im{g}.active{1}.node;
1064 mode_im = gsyncEvents_im{g}.active{1}.mode;
1065 if ~isfield(sn.nodeparam{gind_im}, 'timing
') || isempty(sn.nodeparam{gind_im}.timing)
1068 if mode_im > numel(sn.nodeparam{gind_im}.timing) ...
1069 || sn.nodeparam{gind_im}.timing(mode_im) ~= TimingStrategy.IMMEDIATE
1072 for s = 1:size(stateSpaceHashed, 1)
1074 continue; % already marked
1076 glspace_im = cell(nstateful, 1);
1077 for isf = 1:nstateful
1078 glspace_im{isf} = sn.space{isf}(stateSpaceHashed(s, isf), :);
1080 [~, outrate_im, ~] = State.afterGlobalEvent(sn, gind_im, glspace_im, gsyncEvents_im{g}, false);
1081 if ~isempty(outrate_im) && any(outrate_im > 0)
1082 imm = [imm; s]; %#ok<AGROW>