1function [QN, UN, RN, TN, CN, XN, lG, sn] = solver_ssa_nrm(sn, options)
2% SOLVER_SSA_NRM Steady‑state analysis via the Next‑Reaction Method (SSA)
4% [QN, UN, RN, TN, CN, XN, LG, SN] = SOLVER_SSA_NRM(SN, OPTIONS)
5% runs a stochastic simulation of the queueing network described in SN
6%
for OPTIONS.samples reaction firings
using Gibson & Bruck
's
7% Next‑Reaction Method. During the run it:
8% • computes performance metrics directly during simulation;
9% • returns standard queueing performance measures.
12% QN – M×K matrix of mean queue lengths
13% UN – M×K matrix of utilizations
14% RN – M×K matrix of response times
15% TN – M×K matrix of throughputs
16% CN – 1×K vector of cycle times
17% XN – 1×K vector of system throughputs
18% LG – Logarithm of normalizing constant (not computed)
19% SN – (Possibly updated) network structure.
21% See also SOLVER_SSA_NRM_SPACE, NEXT_REACTION_METHOD_DIRECT.
23% ---------------------------------------------------------------------
24% Parameters & shorthands
25% ---------------------------------------------------------------------
26samples = options.samples;
33% ---------------------------------------------------------------------
34% Phase slot map --------------------------------------------------------
35% ---------------------------------------------------------------------
36% The state vector counts jobs per (node, class, PHASE) rather than per
37% (node, class), so that phase-type service is represented exactly instead of
38% being collapsed onto its mean rate. Phase counts differ per (station, class)
39% via sn.phasessz, hence the explicit offset map rather than arithmetic on R.
41% The layout is chosen so that a single-phase model is bit-for-bit the old one:
42% with nph == 1 everywhere, phOff(ind,r) = (ind-1)*R + (r-1) and therefore
43% slot(ind,r,1) = (ind-1)*R + r, exactly the flat class index the engine used
44% before. Every exponential model must reproduce its previous results, which is
45% the self-check for this generalization.
49 ist = sn.nodeToStation(ind);
51 nph(ind,r) = max(1, sn.phasessz(ist,r));
63isPhaseExpanded = NS > I*R; % false for a purely exponential model
64% Reverse map. Every consumer that used to decode a state index arithmetically
65% (floor((slot-1)/R)+1, mod(slot-1,R)+1) must go through this instead: with
66% unequal phase counts the flat arithmetic no longer identifies the node.
67smap = struct(); % layout descriptor threaded into every slot consumer
68slotNode = zeros(NS,1);
69slotClass = zeros(NS,1);
70slotPhase = zeros(NS,1);
74 slotNode(phOff(ind,r)+kk) = ind;
75 slotClass(phOff(ind,r)+kk) = r;
76 slotPhase(phOff(ind,r)+kk) = kk;
80smap.node = slotNode; smap.class = slotClass; smap.phase = slotPhase;
81smap.phOff = phOff; smap.nph = nph; smap.R = R;
83% Buffered phase-type service: see _kb/06-solver-catalog.md (SSA/NRM section)
84% for the svcph auxiliary-structure rationale.
85bufPHSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
86 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT];
87bufPHClass = false(I, R);
89 if sn.isstation(ind) && any(sn.sched(sn.nodeToStation(ind)) == bufPHSched)
92 bufPHClass(ind,r) = true;
97bufPHNode = any(bufPHClass, 2);
99smap.bufPHClass = bufPHClass; smap.bufPHNode = bufPHNode;
101% Cache nodes. A Cache is an immediate class-switch: a job arrives in a READ
102% class, reads an item drawn from pread, and leaves in the hit or miss class
103% depending on whether the item is currently cached, after which the replacement
104% policy updates the cache contents. The NRM models this as a state-dependent
105% class-switch reaction at the cache node (consume [cache,readClass], produce
106% [cache,hitClass] or [cache,missClass], chosen at firing by the cache access),
107% mirroring State.afterEventCache. The cache contents ride alongside the buffers
108% (buffers{cacheNode}) since no rate depends on them; the hit/miss draw and the
109% replacement update read and rewrite them at firing. A class r is a READ class
110% of cache ind iff its pread entry is a non-empty probability row.
111isCacheNode = false(I,1);
112isCacheReadClass = false(I,R);
114 if sn.nodetype(ind) == NodeType.Cache
115 isCacheNode(ind) = true;
116 np = sn.nodeparam{ind};
117 if isfield(np,'retrievalClassIndices
') && ~isempty(np.retrievalClassIndices)
118 rci = np.retrievalClassIndices(:)';
123 % A normal read
class has a hit class; a retrieval
class (created by
124 % setRetrievalSystem) reads its own one-hot item to COMPLETE a miss
125 % and has hitclass == 0 but a miss
class. Both take a cache-access
126 % reaction; the outcome
class is resolved at firing.
127 if r <= numel(np.pread) && ~isempty(np.pread{r}) && all(~isnan(np.pread{r}(:))) ...
128 && ((r <= numel(np.hitclass) && np.hitclass(r) > 0) || any(rci == r))
129 isCacheReadClass(ind,r) = true;
134smap.isCacheNode = isCacheNode;
136% Destination slot a BEGUN retrieval routes to (the fetch queue). When a miss
137% starts a retrieval the job must go to the retrieval queue, be served (the fetch
138% delay), and only THEN
return to the cache to complete the miss. If it were left
139% at [cache,retrievalClass] the cache-access reaction would fire again and
140% complete the miss instantly, collapsing the fetch. So a begin lands the job at
141% the retrieval
class's routed destination; only queue-returns occupy
142% [cache,retrievalClass] and trigger completion. Resolved once from rtnodes.
143cacheRetrDest = zeros(I, R);
146 np = sn.nodeparam{ind};
147 if isfield(np,'retrievalClassIndices
') && ~isempty(np.retrievalClassIndices)
148 for rc = np.retrievalClassIndices(:)'
149 row = sn.rtnodes((ind-1)*R + rc, :);
150 dslot = find(row > 0, 1,
'first');
152 jnd = floor((dslot-1)/R) + 1; s = mod(dslot-1,R) + 1;
153 cacheRetrDest(ind, rc) = phOff(jnd, s) + 1;
160% ---------------------------------------------------------------------
161% Stochastic Petri net path --------------------------------------------
162% ---------------------------------------------------------------------
163% A model with Transition
nodes is a stochastic Petri net, not a queueing
164% network: its dynamics are firings of transition modes over a place marking,
165% not job departures routed by the rt matrix. The stoichiometry matrix of the
166% reaction network IS the net
's incidence matrix, so the NRM is the natural
167% simulator, but the generic (node,class) departure grid below does not apply
168% (a firing produces to several places deterministically, never a routing
169% draw). Route Petri nets to the dedicated builder/runner, which shares the
170% Gibson & Bruck clocks but its own firing application and vanishing-marking
171% collapse for immediate transitions.
172if any(sn.nodetype == NodeType.Transition)
173 [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap);
178% ---------------------------------------------------------------------
179% Stoichiometry & reaction mapping (self‑loops included) ----------------
180% ---------------------------------------------------------------------
181S = zeros(0, NS); % will transpose at the end
186% this is currently M^2*R^2, it can be lowered to M*R decoupling the
188% Departure reactions, one per (node, class, PHASE). A departure is the
189% absorption of the phase-type service process, so it fires at mu(k)*phi(k) and
190% the job re-enters its destination in an entry phase drawn from pie: the
191% destination draw therefore carries the product of the routing probability and
192% the entry-phase probability. The existing weighted-destination sampler takes
193% that product unchanged.
195depPhase = []; % service phase each departure reaction consumes
196isBufSvcRx = false(0,1); % departure reaction of a buffered-PH class (reads svcph)
197isCacheRx = false(0,1); % cache-access reaction (read -> hit/miss at a cache node)
198cacheHitSlot = zeros(0,1); % nvec slot the hit-class job is produced into
199cacheMissSlot = zeros(0,1); % nvec slot the miss-class job is produced into
202 for kk = 1:nph(ind,r)
204 fromIR(k,:) = [ind, r];
206 isCacheRx(k,1) = false;
207 cacheHitSlot(k,1) = 0;
208 cacheMissSlot(k,1) = 0;
209 if isCacheReadClass(ind,r)
210 % Cache access: consume the read-class job at the cache; its
211 % production (hit or miss class, at the SAME cache node) and the
212 % contents update are resolved at firing by cacheAccess. No
213 % static routing: rtnodes has no out-edge for the read class.
214 fromIdx(k) = phOff(ind,r) + 1;
215 np = sn.nodeparam{ind};
217 Srow(fromIdx(k)) = -1;
221 isCacheRx(k,1) = true;
222 isBufSvcRx(k,1) = false;
223 % The outcome class (hit/miss/retrieval) is resolved at firing, so
224 % these slots are informational only; a retrieval class has
225 % hitclass 0, so guard the lookup.
226 if r <= numel(np.hitclass) && np.hitclass(r) > 0
227 cacheHitSlot(k,1) = phOff(ind, np.hitclass(r)) + 1;
229 if r <= numel(np.missclass) && np.missclass(r) > 0
230 cacheMissSlot(k,1) = phOff(ind, np.missclass(r)) + 1;
234 % At a buffered-PH source only the jobs in service carry a phase and
235 % the phase composition lives in svcph, not nvec; nvec holds the whole
236 % class population in its first phase slot. A departure therefore
237 % removes one job from that total slot regardless of which service
238 % phase completed -- the completing phase kk is carried in depPhase
239 % and consumed from svcph at firing.
241 fromIdx(k) = phOff(ind,r) + 1;
242 isBufSvcRx(k,1) = true;
244 fromIdx(k) = phOff(ind,r) + kk;
245 isBufSvcRx(k,1) = false;
249 Srow = zeros(1, NS); % build stoichiometry row
251 Srow(fromIdx(k)) = -Inf;
253 Srow(fromIdx(k)) = -1;
256 p = sn.rtnodes((ind-1)*R+r, (jnd-1)*R+s);
259 % A job arriving at a buffered-PH destination lands
260 % in the total-population slot; whether it enters
261 % service (and in which entry phase) or waits is
262 % decided at firing from the server occupancy and
263 % pie, not by the routing draw. So the destination
264 % collapses to the single total slot with weight p.
265 dslot = phOff(jnd,s) + 1;
266 toIdx{k}(end+1) = dslot;
267 probIR{k}(end+1) = p;
268 Srow(dslot) = Srow(dslot) + p;
270 pentry = entryProbs(sn, jnd, s, nph(jnd,s));
271 for ke = 1:nph(jnd,s)
275 dslot = phOff(jnd,s) + ke;
276 toIdx{k}(end+1) = dslot;
277 probIR{k}(end+1) = p * pentry(ke);
278 Srow(dslot) = Srow(dslot) + p * pentry(ke);
289nDepRx = k; % departure reactions occupy 1..nDepRx
290isBufSvcRx(end+1:k,1) = false;
292% Phase-transition reactions, one per (node, class, k -> k'). These move a job
293% between the phases of its own service process and so never leave the node;
294% D0
's off-diagonal carries their rates (State.afterEventStation, EventType.PHASE).
295isPhaseRx = false(k,1);
296phaseFrom = zeros(k,1);
298phaseRate = zeros(k,1);
300 if ~sn.isstation(ind)
303 ist = sn.nodeToStation(ind);
305 if nph(ind,r) <= 1 || isempty(sn.proc{ist}{r})
308 D0 = sn.proc{ist}{r}{1};
309 for ka = 1:nph(ind,r)
310 for kb = 1:nph(ind,r)
311 if ka == kb || D0(ka,kb) <= 0
315 fromIR(k,:) = [ind, r];
320 % A buffered-PH class keeps its in-service phase counts in
321 % svcph, not in nvec: a phase transition moves a job between
322 % phases of the SAME in-service composition, so it leaves nvec
323 % (the class total) unchanged. The stoichiometry column is
324 % therefore all zeros; the move is applied to svcph at firing
325 % and, like a retry/switchover, its dependency set must be
326 % supplied through a forced refresh (D cannot derive it from S).
327 fromIdx(k) = phOff(ind,r) + 1;
329 fromIdx(k) = phOff(ind,r) + ka;
330 Srow(phOff(ind,r) + ka) = -1;
331 Srow(phOff(ind,r) + kb) = 1;
334 isPhaseRx(k,1) = true;
337 phaseRate(k,1) = D0(ka,kb);
342isPhaseRx(end+1:k,1) = false;
343depPhase(end+1:k,1) = 0;
345% Reneging: each waiting (queued, not-in-service) class-r job abandons at the
346% memoryless rate sn.impatienceMu, so the aggregate rate out of the state is
347% (waiting count)*mu and the job leaves the system (the passive half of the
348% sync is LOCAL in refreshSync). This is a reaction the (node,class) departure
349% grid above cannot express -- it consumes a job without producing one -- so it
350% is appended as an extra column whose stoichiometry is a bare -1 at the source
351% slot. A renege is not a departure and must not count towards throughput; the
352% TN accumulator reads the first reaction with a given source slot, which is
353% always the departure, so the appended columns stay out of it.
354nDep = k; % departure reactions occupy 1..nDep
355isRenegeRx = false(nDep,1);
356renegeMu = zeros(nDep,1);
357if isfield(sn,'impatienceClass
') && ~isempty(sn.impatienceClass) ...
358 && any(sn.impatienceClass(:) == ImpatienceType.RENEGING)
360 ind = sn.stationToNode(ist);
362 if sn.impatienceClass(ist,r) == ImpatienceType.RENEGING && sn.impatienceMu(ist,r) > 0
364 fromIR(k,:) = [ind, r];
365 fromIdx(k) = (ind-1)*R + r;
368 Srow = zeros(1, I*R);
369 Srow((ind-1)*R + r) = -1; % job abandons and leaves the system
371 isRenegeRx(k,1) = true;
372 renegeMu(k,1) = sn.impatienceMu(ist,r);
377% Retrial: an orbiting class-r job retries entry at the memoryless rate
378% sn.retrialMu and succeeds only when a server is free; otherwise the event is
379% a no-op and is simply not generated (State.afterEventStation, EventType.RETRY).
380% The orbit needs no new state: orbiting jobs are already counted in the
381% station population and held in the buffer, so orbit_r is exactly the buffer
382% occupancy the FCFS-family rate law already reads. A retry moves a job from
383% the orbit into service WITHOUT changing any population, so its stoichiometry
384% column is all zeros -- which is why its dependency set has to be supplied by
385% hand below: D is derived from S, and an all-zero column would otherwise leave
386% every rate at the node stale after a retry fires.
387isRetryRx = false(k,1);
389retryNode = zeros(k,1);
390if isfield(sn,'retrialProc
') && ~isempty(sn.retrialProc)
392 if ~any(~cellfun(@isempty, sn.retrialProc(ist,:)))
395 ind = sn.stationToNode(ist);
397 if sn.retrialMu(ist,r) > 0
399 fromIR(k,:) = [ind, r];
400 fromIdx(k) = (ind-1)*R + r;
403 S(k,:) = zeros(1, I*R); % a retry moves no job between nodes
404 isRetryRx(k,1) = true;
405 retryMu(k,1) = sn.retrialMu(ist,r);
406 retryNode(k,1) = ind;
412% Polling switchover reactions. A polling server cycles through the buffers it
413% serves, carrying a controller [mode, pos, swphase, ctr] in the auxiliary
414% buffer of its node (mode 0 parked, 1 serving pos, 2 switching towards pos).
415% A service departure fires only while the controller serves that class (gated
416% in the propensity below); when a visit ends the server walks the cyclic order
417% (State.pollingNext folds every immediate switchover) and, on meeting a timed
418% switchover, dwells in mode 2. That dwell is a genuine timed event with no job
419% movement, so it is appended here as one reaction per polling node with a timed
420% switchover, exactly as a retry is: an all-zero stoichiometry column whose
421% propensity reads the controller and whose firing samples the switchover PH.
422% Only exponential service is expanded at a polling station (phaseNrmOK gates PH
423% service there); the switchover itself may be phase-type, its phases carried in
424% the controller rather than in nvec.
425poll = struct('on
', false);
426poll.isPoll = false(1, I);
427poll.pinfo = cell(I, 1);
428poll.swRx = zeros(1, I); % switchover reaction index of each polling node, 0 if none
430 if sn.isstation(ind) && sn.sched(sn.nodeToStation(ind)) == SchedStrategy.POLLING
431 poll.pinfo{ind} = State.pollingInfo(sn, ind);
432 poll.isPoll(ind) = true;
434 % The NRM tracks only the controller of a polling station, not the
435 % service phase of the single job in service, so phase-type service at a
436 % polling station is not expanded here. Reject it rather than spread the
437 % class over phases and gate each phase reaction on the same class (which
438 % would serve several fictitious phase-jobs at once). Switchover may be
439 % phase-type: its phase is carried in the controller.
442 line_error(mfilename, sprintf('NRM polling supports exponential service only; station %d
class %d has phase-type service. Use method=
''serial
''.
', sn.nodeToStation(ind), r));
447isPollSwRx = false(k, 1);
448pollSwNode = zeros(k, 1);
451 pinf = poll.pinfo{ind};
452 if isempty(pinf) || ~any(pinf.hasSw)
453 continue % no timed switchover: the server never dwells in a walk
456 fromIR(k,:) = [ind, 1]; % class field is a sentinel; never read as a class here
457 fromIdx(k) = (ind-1)*R + 1; % unused slot: a switchover consumes no job
460 S(k,:) = zeros(1, I*R); % a switchover moves no job between nodes
461 isPollSwRx(k,1) = true;
462 pollSwNode(k,1) = ind;
467% Pad every per-reaction marker to the final reaction count, so the reaction
468% loops below index them safely regardless of which extra-reaction families
469% (renege, retry, switchover) are present.
470isRenegeRx(end+1:k,1) = false;
471isRetryRx(end+1:k,1) = false;
472isPhaseRx(end+1:k,1) = false;
473depPhase(end+1:k,1) = 0;
474isPollSwRx(end+1:k,1) = false;
475pollSwNode(end+1:k,1) = 0;
476isBufSvcRx(end+1:k,1) = false;
477isCacheRx(end+1:k,1) = false;
478cacheHitSlot(end+1:k,1) = 0;
479cacheMissSlot(end+1:k,1) = 0;
480renegeMu(end+1:k,1) = 0;
481retryMu(end+1:k,1) = 0;
482retryNode(end+1:k,1) = 0;
484S = S.'; % states × reactions
486% ---------------------------------------------------------------------
487% Initial state vector --------------------------------------------------
488% ---------------------------------------------------------------------
489nvec0 = zeros(NS,1); % initial state (per node,
class and phase)
490% Non-preemptive policies that hold waiting jobs in a buffer. They share the
491% rate law (a
class-r completion fires at mu_r times the
class-r jobs actually
492% in service) and differ only in which waiting job
is promoted on a departure;
494bufferedSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
495 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
496 SchedStrategy.LCFSPR, SchedStrategy.PAS];
497% Preemptive policies: an arrival at a fully busy station takes a server and
498% pushes the incumbent it displaced back into the buffer, rather than queueing
499% itself (State.afterEventStation, the FCFSPR/LCFSPR arrival group). The rate
500% law
is unchanged -- it still counts the jobs actually in service -- so no
501% extra state
is needed: in-service
is population minus buffer occupancy, and
502% that automatically names the
new arrival as the one being served. With
503% exponential service preempt-resume needs no stored phase, because a resumed
504% job has the same memoryless residual as a fresh one. LCFSPI
is deliberately
505% absent: SolverSSA.getFeatureSet does not advertise it (nor does SolverCTMC),
506% so the NRM must not claim it either.
507preemptiveSched = SchedStrategy.LCFSPR;
508% Order-independent / pass-and-swap stations keep the FULL ordered job list,
509% not just the waiting jobs: there
is no server/buffer split at all, and the
510% rate
is a function mu(c) of the whole list (State.afterEventStationPAS). So
511% these carry a different buffer invariant -- numel(buf) == total, rather than
512% max(0, total - mi) -- and the list runs OLDEST-FIRST (c(1)
is the oldest),
513% the reverse of every other buffered policy here.
514% sn.sched carries PAS
for both PAS and OI stations: OI
is canonicalized to
515% pass-and-swap with an all-zero swap graph (see MNetwork.refreshLocalVars), so
516% reading the graph covers both and OI needs no separate
case.
517listSched = SchedStrategy.PAS;
518% Of those, the policies whose state keeps the buffer as per-
class counts
519% rather than as an ordered list of
class ids (State.fromMarginalAndRunning).
520countBufferedSched = [SchedStrategy.SIRO, SchedStrategy.SEPT, SchedStrategy.LEPT];
521buffers0 = cell(I,1); % per-node ordered buffer of waiting job classes (FCFS/LCFS)
525% A cache node has no queueing buffer; its buffers slot instead carries the cache
526% CONTENTS (the item held in each of the totalCacheCapacity slots, ordered by
527% list as State.afterEventCache lays them out). Any valid ordered placement
is a
528% correct warm start since the chain
is ergodic, so slot i starts holding item i.
531 np = sn.nodeparam{ind};
532 if isfield(np,
'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
533 tcc = np.totalCacheCapacity;
535 tcc = sum(np.itemcap);
537 % With a retrieval system the contents are followed by a per-item
538 % occupancy bitmap (State.spaceCache):
column tcc+i
is 1 iff item i
is
539 % currently being retrieved. Start with an empty bitmap.
540 if isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
541 && any(np.retrievalSystemCapacity > 0)
542 buffers0{ind} = [1:tcc, zeros(1, np.nitems)];
548% In-service phase multiset of each buffered-PH node: svcph0{ind}(r,k) counts the
549%
class-r jobs in service in phase k. Empty
for every other node. Populated below
550% once the waiting buffer of each buffered-PH node
is known (in-service =
class
551% total minus waiting), so it
is filled after the buffer loop.
555 svcph0{ind} = zeros(R, maxnph);
561 if sn.isstateful(ind)
562 state_i = state{sn.nodeToStateful(ind)};
563 [~,nir] = State.toMarginalAggr(sn, ind, state_i);
566 if sn.nodetype(ind) == NodeType.Source
569 line_error(mfilename,
'Infinite population error.');
572 % Spread the
class population across its phases. The marginal the
573 % initial state carries
is per
class, not per phase, so the entry
574 % distribution pie
is the natural allocation: it
is the phase a job
575 % starts service in. For a single-phase
class this puts everything
576 % in slot 1, reproducing the old flat layout exactly. A buffered-PH
577 %
class keeps its whole population in slot 1 too -- nvec
is the class
578 % total there and the in-service phase composition lives in svcph0
579 % (built below), so the phase slots 2..nph stay empty in nvec.
580 if nph(ind,r) <= 1 || bufPHClass(ind,r)
581 nvec0(phOff(ind,r) + 1,1) = nir(r);
583 pe = entryProbs(sn, ind, r, nph(ind,r));
585 for ke = 1:nph(ind,r)
589 take = min(left, round(nir(r) * pe(ke)));
591 nvec0(phOff(ind,r) + ke,1) = take;
597 % Populate buffers
for buffered
nodes from the raw state vector
598 % (only stations have buffered scheduling; skip non-station
599 % stateful
nodes such as RROBIN dispatchers/Routers and Caches)
600 ist = sn.nodeToStation(ind);
601 if ist >= 1 && any(sn.sched(ist) == bufferedSched)
602 sumK = sum(sn.phasessz(ist,:));
603 sumNvars = sum(sn.nvars(ind,:));
604 bufCols = size(state_i,2) - sumK - sumNvars;
605 if any(sn.sched(ist) == listSched)
606 % PAS/OI stores the full ordered list left-aligned in the first
607 % cap(ist) columns, c(1) oldest, zero-padded on the right --
608 % already the order the NRM needs, so it
is copied verbatim
609 % rather than reversed.
610 % Its width
is nCols - nvars, NOT the shared bufCols: a PAS
611 % station has no server/phase block at all (there
is no
612 % server/buffer split), yet phasessz still floors to 1 per class
613 % as for any other station, so subtracting sumK here would drop
614 % the last sum(phasessz) entries of the list. Both PAS
615 % authorities, State.afterEventStationPAS and the PAS branch of
616 % State.toMarginal, read W = size(inspace,2) - V.
617 pasCols = size(state_i,2) - sumNvars;
619 classId = state_i(1,pos);
620 if classId >= 1 && classId <= R
624 elseif any(sn.sched(ist) == countBufferedSched)
625 % SIRO/SEPT/LEPT keep an UN-ordered buffer: the first R columns
626 % hold the per-
class counts of waiting jobs, not class ids (see
627 % State.fromMarginalAndRunning). Expand them into the NRM
's
628 % ordered list; the order within it is immaterial for these
629 % disciplines, which select by class and never by position.
630 for r = 1:min(R, bufCols)
631 buffers0{ind}(end+1:end+state_i(1,r)) = r;
634 % FCFS/HOL/LCFS keep an ordered list of class ids
636 classId = state_i(1,pos);
637 if classId >= 1 && classId <= R
638 buffers0{ind}(end+1) = classId; % addLast
640 % classId == 0 means empty position, skip
645 % Seed the in-service phase multiset of a buffered-PH node. The jobs in
646 % service are the class total minus the ones waiting in the buffer just
647 % built; their starting phases are drawn from the entry distribution pie,
648 % the same allocation the INF/PS init uses. Only in-service jobs get a
649 % phase -- waiting jobs have not started service and carry none.
652 waiting_r = sum(buffers0{ind} == r);
653 insvc_r = max(0, nir(r) - waiting_r);
655 svcph0{ind}(r,1) = insvc_r;
657 pe = entryProbs(sn, ind, r, nph(ind,r));
659 for ke = 1:nph(ind,r)
663 take = min(left, round(insvc_r * pe(ke)));
665 svcph0{ind}(r,ke) = take;
679 ist = sn.nodeToStation(ind);
680 muir = sn.rates(ist,r);
684 mi(ind,1) = sn.nservers(ist);
688 rates(ind,r) = GlobalConstants.Immediate;
689 mi(ind,1) = GlobalConstants.MaxInt;
692 mi(isinf(mi)) = GlobalConstants.MaxInt;
695% Limited load-dependent scaling lld(ist, ntot): a work-conserving factor that
696% multiplies the aggregate service rate at total station population ntot (as in
697% State.afterEventStation). Default (all ones) for stations without load
698% dependence, so it is inert for plain single-/multi-server queues.
699if isempty(sn.lldscaling)
700 lldMat = []; lldlimit = 0;
702 lldMat = sn.lldscaling; lldlimit = size(lldMat,2);
705% Class-dependent scaling cdscaling{ist}: a handle mapping the per-class
706% station population vector n to the 1xR vector of rate scalings beta_r(n)
707% (as in State.afterEventStation, evaluated per firing on the current state).
708% Joint-dependence handles eta_i(n) (sn.jdscaling, non-product-form) enter the
709% sample-path rates the same way, so fold them into the effective per-station
710% handle eta_i(n).*beta_{i,r}(n), exactly as State.afterEventInit does.
711if isempty(sn.cdscaling)
714 cdCell = sn.cdscaling;
716if ~isempty(sn.jdscaling)
722 if ist <= numel(sn.jdscaling) && ~isempty(sn.jdscaling{ist})
723 jdh = sn.jdscaling{ist};
724 if ist <= numel(cdCell) && ~isempty(cdCell{ist})
726 cdCell{ist} = @(ni) cdh(ni) .* jdh(ni);
734% Scheduling policies whose rate law reads per-class weights from
735% sn.schedparam. These are single-server only, as in State.afterEventStation.
736weightedSched = [SchedStrategy.DPS, SchedStrategy.GPS, ...
737 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO];
739% Finite capacity regions (DROP rule). A region constrains an aggregate of the
740% per-class populations of its member stations, which is a linear function of
741% the NRM state vector, so admission is a multiplicative 0/1 gate on the
742% routing draw. The DROP rule censors the refused transition, and censoring an
743% exponential transition is exactly what zeroing its share of the propensity
744% does. WAITQ instead parks refused jobs in a per-region FIFO, which is extra
745% state the reaction network does not carry, so those models are routed to the
746% serial engine by SOLVER_SSA_ANALYZER and never reach here.
747fcr = fcrPrecompute(sn);
749% Balking. An arrival that balks is lost: it has left its source but never
750% joins the destination, so the departure rate is unchanged and only the
751% arrival outcome differs (State.afterEventStation scales the admitted
752% branches by 1-balkProb and adds a balked branch of probability balkProb that
753% leaves the destination state untouched). Only the QUEUE_LENGTH strategy is a
754% pure function of the state vector; EXPECTED_WAIT / COMBINED depend on the
755% mean wait and are rejected by the analyzers.
756balk = balkPrecompute(sn);
758% G-network signals. A signal class never joins the station it reaches: it acts
759% on the jobs already there and is annihilated (State.afterEventStationSignal).
760% That makes it an arrival-side effect exactly like balking, so the departure
761% rate is unchanged and only the arrival outcome differs. The reference
762% enumerates every victim subset with its probability because it builds a
763% generator; a simulator instead draws the batch size and the victims, which is
764% equivalent and avoids the enumeration.
765sig = signalPrecompute(sn);
767% Round-robin routing. The pointer that RROBIN/WRROBIN walk is a per-(node,
768% class) local variable, not a population, and no rate depends on it: it only
769% decides where a departure goes. In a generator that makes it a genuine extra
770% state dimension, but a simulator can carry it as auxiliary state alongside
771% the buffers, which is what happens here. State.afterEventRouter advances the
772% pointer on the departure and the routing closure then reads state_AFTER, so
773% the destination used is the one the pointer lands on -- advance first, then
775rr = rrPrecompute(sn, state);
777% Propensity function ---------------------------------------------------
778epstol = GlobalConstants.Zero;
780classprio = sn.classprio(:)'; % lower value = higher priority in LINE
781% Rate of the service-process
event each reaction carries: the absorption
782% mu(k)*phi(k)
for a departure, the off-diagonal D0(k,k
') for a phase change.
783% For a single-phase class this is just the exponential rate, so an exponential
784% model sees exactly the rates it saw before.
785rateOf = zeros(length(fromIdx),1);
786for j = 1:length(fromIdx)
787 ind = fromIR(j,1); r = fromIR(j,2);
789 rateOf(j) = phaseRate(j);
790 elseif sn.isstation(ind)
791 ist = sn.nodeToStation(ind);
793 if nph(ind,r) > 1 && ~isempty(sn.proc{ist}{r})
794 rateOf(j) = sn.mu{ist}{r}(kk) * sn.phi{ist}{r}(kk);
796 rateOf(j) = rates(ind, r);
799 rateOf(j) = rates(ind, r);
803for j=1:length(fromIdx)
805 base = (ind-1)*R + 1; % first per-class state slot of this node
806 ldrow = []; % load-dependent scaling row of the station
807 cdbeta = []; % class-dependence handle of the station
808 wrow = []; % normalized DPS/GPS scheduling weights
810 istj = sn.nodeToStation(ind);
811 if istj >= 1 && ~isempty(lldMat), ldrow = lldMat(istj, :); end
812 if istj >= 1 && istj <= numel(cdCell) && ~isempty(cdCell{istj})
813 cdbeta = cdCell{istj};
815 if istj >= 1 && any(sn.sched(istj) == weightedSched)
816 wrow = sn.schedparam(istj, 1:R);
818 line_error(mfilename, sprintf('Station %d has %s scheduling with non-positive total weight.
', istj, SchedStrategy.toText(sn.sched(istj))));
820 wrow = wrow / sum(wrow);
821 % State.afterEventStation rejects multi-server DPS/GPS, so the
822 % rate law below is only defined for a single server. Fail here
823 % rather than silently simulate a different station.
825 line_error(mfilename, sprintf('Multi-server %s stations are not supported yet.
', SchedStrategy.toText(sn.sched(istj))));
829 % Buffered phase-type service. Only the jobs in service carry a phase, and
830 % their per-phase counts live in svc{ind}(r,k), not in nvec. Both the
831 % departure (absorption of phase kk) and the internal phase transition
832 % (kk -> kb) therefore fire at rate rateOf(j) times the number of class-r
833 % jobs currently in service in the source phase kk -- exactly the INF-family
834 % law rateOf*kir, but with kir read from the in-service multiset svc rather
835 % than from nvec (whose class total also counts the waiting jobs). The
836 % load-/class-dependent factors still read the total population, as for the
837 % exponential buffered law. A single-phase (exponential) buffered class is
838 % NOT bufPHClass and keeps its original rate law below.
839 if sn.isstation(ind) && bufPHClass(ind, fromIR(j,2))
842 kk_ph = phaseFrom(j);
846 a{j} = @(X, bufs, svc) rateOf(j) * svc{ind}(rr_ph, kk_ph) ...
847 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
848 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
852 switch sn.sched(sn.nodeToStation(ind))
853 case SchedStrategy.EXT
854 % A Source fires at a constant arrival rate. It has no service
855 % phases (nph == 1 there, enforced by phaseNrmOK), so the
856 % kir/nir share must NOT be applied: the Source's fictitious
857 % token would drive kirFrac to 0 and silence the Source, which
858 % deadlocks every open model. rateOf(j)
is that constant rate.
859 a{j} = @(X,
bufs, svc) rateOf(j);
860 case SchedStrategy.INF
861 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * classPop(X, phOff, nph, ind, fromIR(j,2)) ...
862 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
863 case {SchedStrategy.PS, SchedStrategy.LPS}
864 % LPS shares the PS rate law in State.afterEventStation: the
865 % sharing limit
is the server count, so min(ni,c) covers both.
866 if R == 1 % single
class
867 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * min( mi(fromIR(j,1)), classPop(X, phOff, nph, ind, fromIR(j,2))) ...
868 * lldfac(ldrow, classPop(X, phOff, nph, ind, fromIR(j,2)), lldlimit) ...
869 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
871 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * ( classPop(X, phOff, nph, ind, fromIR(j,2)) ./ ...
872 (epstol+sum( classCounts(X, phOff, nph, ind, R) ) )) * ...
873 min( mi(fromIR(j,1)), (epstol+sum( classCounts(X, phOff, nph, ind, R) )) ) ...
874 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
875 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
877 case SchedStrategy.DPS
878 % Discriminatory PS:
class r receives a share w_r*n_r/(w.n) of
879 % the single server (State.afterEventStation,
case DPS).
880 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
881 * dpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
882 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
883 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
884 case SchedStrategy.GPS
885 % Generalized PS: share w_r/(w.c) where c_s = 1{n_s>0}, i.e.
886 % weights are split across the *active* classes only.
887 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
888 * gpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
889 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
890 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
891 case SchedStrategy.PSPRIO
892 % Below capacity every job
is served, so priority
is inert;
893 % above it, only the most urgent non-empty group shares the
894 % servers. lld uses the priority-group population, cd the full
895 % one, mirroring State.afterEventStation exactly.
896 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
897 * psprioshare(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
898 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
899 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
900 case SchedStrategy.DPSPRIO
901 % As DPS, but above capacity restricted to the most urgent
902 % non-empty group; cd
is evaluated on the priority-restricted
903 % population (State.afterEventStation,
case DPSPRIO).
904 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
905 * dpsprioshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
906 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
907 * cdfac(cdbeta, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
908 case SchedStrategy.GPSPRIO
909 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
910 * gpsprioshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
911 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
912 * cdfac(cdbeta, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
913 case SchedStrategy.PAS
914 % Position p of the ordered list
is served at
915 % Delta_mu(c1..cp) = mu(c1..cp) - mu(c1..c_{p-1}), and
916 % pass-and-swap decides which
class that completion ejects. The
917 %
class-r departure rate
is therefore the total Delta_mu over
918 % the positions whose pass-and-swap ejects a
class-r job, which
919 %
is exactly what afterEventStationPAS enumerates.
920 muFun = sn.nodeparam{ind}.svcRateFun;
922 line_error(mfilename,
'PAS/OI station has no service rate function mu(c); set it via setService(@(c) ...).');
924 swapG = sn.nodeparam{ind}.swapGraph;
925 a{j} = @(X,
bufs, svc) oirate(muFun, swapG,
bufs{ind}, fromIR(j,2));
926 case SchedStrategy.POLLING
927 % A polling station has a single server that serves exactly one
928 % job, of the
class its controller currently attends. The
929 % departure of
class r therefore fires only while the controller
930 %
is SERVING
class r, at the plain service rate of the one job in
931 % service -- never scaled by the
class population, since the other
932 %
class-r jobs wait in the buffer
for the server to come back to
933 % them. The controller rides in
bufs{ind} = [mode, pos, swk, ctr];
934 % pollServeGate returns 1 exactly when mode==SERVING and pos==r.
935 a{j} = @(X,
bufs, svc) rateOf(j) * pollServeGate(
bufs{fromIR(j,1)}, fromIR(j,2)) ...
936 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
937 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
938 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
939 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
940 SchedStrategy.LCFSPR}
941 % Invariant: numel(
bufs{ind}) == max(0, total - mi(ind)).
942 % Rate
is proportional to the jobs actually being served, i.e.
943 % the
class-r population minus the
class-r jobs waiting in buffer,
944 % scaled by the load-dependent
factor at the total population.
945 % Every non-preemptive buffered policy shares
this law: with
946 % exponential service the departure rate depends only on the
947 % in-service composition, never on the buffer order, which enters
948 % solely through which job
is promoted next (pickFromBuffer).
949 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * ...
950 max(0, classPop(X, phOff, nph, ind, fromIR(j,2)) - sum(
bufs{fromIR(j,1)} == fromIR(j,2))) ...
951 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
952 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
955 a{j} = @(X,
bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * min(1, classPop(X, phOff, nph, ind, fromIR(j,2)));
959% Reneging propensities ------------------------------------------------
960% Only the jobs actually waiting can abandon, so the rate
is the
class-r
961% population minus the
class-r jobs in service, exactly the buffer occupancy
962% the FCFS-family rate law already relies on.
963for j = (nDep+1):length(fromIdx)
965 a{j} = @(X,
bufs, svc) renegeMu(j) * sum(
bufs{ind} == fromIR(j,2));
968% Retrial propensities --------------------------------------------------
969% Only jobs actually in orbit retry, and only a free server admits them.
970for j = 1:length(fromIdx)
975 a{j} = @(X,
bufs, svc) retryMu(j) * sum(
bufs{ind} == fromIR(j,2)) ...
976 *
double(sum(X(((ind-1)*R+1):(ind*R))) - numel(
bufs{ind}) < mi(ind));
979% Polling switchover propensities ---------------------------------------
980% The reneging loop above overwrote these appended columns with a zero-rate
981% renege closure; restore the switchover law here. A switchover fires only
982%
while the controller
is walking (mode SWITCHING,
bufs{ind}(1)==2), at the
983% total leaving rate -D0(swk,swk) of the current phase swk of the switchover
984% PH into buffer pos. The competition between advancing to another phase and
985% absorbing (arriving at pos)
is resolved at firing time, exactly as a routed
986% departure resolves its destination after it fires.
987for j = 1:length(fromIdx)
992 pinf = poll.pinfo{ind};
993 a{j} = @(X,
bufs, svc) pollSwRate(
bufs{ind}, pinf);
996% Finite capacity regions
do NOT gate the propensities ------------------
997% Under the DROP rule the refused job
is DESTROYED, not held back: the
998% departure fires at its full rate and the job simply never reaches the
999% destination. Scaling the propensity by the admitted share instead censors
1000% the transition, which keeps the job at its SOURCE -- a different model, and
1001% one that diverges as soon as the source
is a real queue rather than a Source
1002% node (an interior region makes the upstream queue grow without bound
while
1003% nothing
is ever lost). The two coincide only at a Source, whose population
is
1004% fictitious, which
is why every FCR fixture placed a region on a Source-fed
1005% station and never saw the difference. Refusal
is applied at firing time
1006% instead, on the drawn destination, exactly as a balk
is (see balkDraw below):
1007% the source releases the job and the destination never receives it. This
1008% matches SOLVER_SSA (which marks the refusal and suppresses only the passive
1009% application) and the exact CTMC.
1011% Propensity functions dependencies -----------------------------------
1012D = cell(1,size(S,2));
1014 J = find(S(:,k))'; % set of state variables affected by reaction k
1017 % Decode through the slot
map, never arithmetically: with phase
1018 % expansion a state index
is a (node,class,PHASE) slot, so
1019 % mod(pos-1,R)+1 names the wrong node as soon as any class has more
1020 % than one phase. Collect EVERY slot of each affected node, because a
1021 % rate law reads its node's whole class-count vector (classCounts sums
1022 % each class over its phases) and the per-phase share reads the sibling
1023 % phases of its own class.
1024 ind = slotNode(J(j));
1025 % NB: not `rr` -- that name holds the round-robin controller in this
1026 % scope, and shadowing it here would pass an integer to the run loop.
1028 vecd(end+1:end+nph(ind,rcls)) = (phOff(ind,rcls)+1):(phOff(ind,rcls)+nph(ind,rcls));
1031 % vecd now contains all state variables affected by the firing of
1032 % reaction k. We now find the propensity functions that depend
1033 % on those variables
1035 % A retry has an all-zero stoichiometry
column, so the generic
1036 % derivation below would return an empty dependency set and leave every
1037 % rate at the node stale. A retry does change the in-service
1038 % composition, hence every reaction whose source
is this node.
1039 base_k = (fromIR(k,1)-1)*R;
1040 D{k} = find(ismember(fromIdx, (base_k+1):(base_k+R)));
1044 vecd = unique(vecd);
1046 for j=1:length(vecd)
1047 % No `fcr.on` widening here: regions no longer gate the
1048 % propensities (see the FCR note above), so a departure
's rate
1049 % depends only on its own station's populations, as in the
1050 % unregulated
case. Admission
is resolved at firing time on the
1051 % drawn destination and changes no rate.
1052 vecs = [vecs,find(S(vecd(j),:)<0)];
1054 D{k} = unique(vecs);
1060% A retry has an all-zero stoichiometry
column, so the derivation above -- which
1061% collects reactions by the sign of their S entries -- can never place it in any
1062% OTHER reaction
's dependency set. It still has to be refreshed whenever the
1063% node it serves changes, because its rate reads both the orbit occupancy and
1064% whether a server is free: without this, a retry blocked at a busy server keeps
1065% its zero rate after the server frees, the orbit never drains and the station
1066% grows without bound.
1067for j = find(isRetryRx(:)')
1069 slots = ((indj-1)*R + 1):(indj*R);
1071 if any(S(slots, k) ~= 0) || fromIR(k,1) == indj
1072 if ~ismember(j, D{k})
1079% Having accounted
for them in D, we can now remove self-loops markings
1082% ---------------------------------------------------------------------
1083% Initialize performance metric matrices
1084% ---------------------------------------------------------------------
1085lG = 0; % Not computed in SSA
1087% ---------------------------------------------------------------------
1088% Run SSA/NRM with direct metric computation
1089% ---------------------------------------------------------------------
1090[QN, UN, RN, TN, CN, XN, cacheProd] = next_reaction_method_direct(S, D, a, nvec0,
buffers0, samples, options, sn, fromIdx, fromIR, mi, fcr, balk, isRenegeRx, sig, rr, isRetryRx, phOff, nph, nDepRx, isPhaseRx, smap, poll, isPollSwRx, pollSwNode, svcph0, isBufSvcRx, bufPHClass, bufPHNode, depPhase, phaseFrom, phaseTo, isCacheRx, cacheHitSlot, cacheMissSlot, isCacheNode, cacheRetrDest);
1091% Write the measured hit/miss probabilities back into sn so the analyzer can set
1092% them on each Cache node (State.afterEventCache convention: actualhitprob(r) =
1093% hit throughput / (hit+miss) throughput at the cache, per read class r).
1094% The cache hit/miss probability of a read class
is the throughput of its hit
1095% class over hit+miss at the cache -- exactly what cacheProd counts per produced
1096% class. A retrieval completion produces the miss class, so retrieval misses are
1097% counted here too; a delayed hit produces nothing and
is excluded from the
1098% ratio (matching the serial engine, which folds delayed hits away). Retrieval
1099% classes (hitclass == 0) are internal and get no hit/miss probability of their
1103 np = sn.nodeparam{ind};
1104 % Size to nclasses with NaN defaults, exactly as the serial analyzer does:
1105 % the arrival-rate reconstruction (sn_get_arvr_from_tput) indexes
1106 % actual{hit,miss}prob at every origClass whose missclass
is set, which
1107 % includes the internal retrieval classes.
1108 np.actualhitprob = NaN(1, R);
1109 np.actualmissprob = NaN(1, R);
1111 if isCacheReadClass(ind,r) && r <= numel(np.hitclass) && np.hitclass(r) > 0
1112 hc = np.hitclass(r); mc = np.missclass(r);
1113 hcount = cacheProd(ind, hc);
1114 mcount = cacheProd(ind, mc);
1115 tot = hcount + mcount;
1117 np.actualhitprob(r) = hcount / tot;
1118 np.actualmissprob(r) = mcount / tot;
1122 sn.nodeparam{ind} = np;
1128% ======================================================================
1129% Next-Reaction Method with direct metric computation
1130% ======================================================================
1131function [QN, UN, RN, TN, CN, XN, cacheProd] = next_reaction_method_direct(S, D, a, nvec0,
buffers0, samples, options, sn, fromIdx, fromIR, mi, fcr, balk, isRenegeRx, sig, rr, isRetryRx, phOff, nph, nDepRx, isPhaseRx, smap, poll, isPollSwRx, pollSwNode, svcph0, isBufSvcRx, bufPHClass, bufPHNode, depPhase, phaseFrom, phaseTo, isCacheRx, cacheHitSlot, cacheMissSlot, isCacheNode, cacheRetrDest)
1133numReactions = size(S,2);
1145% when a reaction fires,
this matrix helps selecting the probability that a
1146% particular routing or phase
is selected as a result ------------------
1147P = S;
P(
P<0)=
P(
P<0)+1';
1148fromIdxCell = cell(numReactions,1);
1149toIdxCell = cell(numReactions,1);
1150cdfVec = cell(numReactions,1);
1152 nnzP(r) = nnz(
P(:,r));
1154 fromIdxCell{r} = find(S(:,r)<0);
1155 toIdxCell{r} = find(
P(:,r));
1156 cdfVec{r} = cumsum(
P(toIdxCell{r},r));
1160% JSQ routing: reactions whose source
class routes with JSQ select the
1161% destination node holding the smallest total population at firing time
1162% (each candidate evaluated on its own queue, never the routing node
's;
1163% ties split uniformly)
1164isJSQ = false(numReactions,1);
1165% SQ(d) (shortest queue of d): sample d candidates uniformly WITH
1166% replacement, join the one holding the smallest total population, ties broken
1167% by first occurrence in the sampled tuple. This is the sampled form of the
1168% marginal enumerated by sub_sq in MNetwork.refreshRoutingMatrix and of
1169% LDES's selectSQDestination; drawing directly
is equivalent
for
1170% a simulator and avoids enumerating the ndest^d tuples. Dispatcher memory
is
1171% not supported, so the draw
is a pure function of the current populations.
1172isKCH =
false(numReactions,1);
1173kchK = zeros(numReactions,1);
1175 if nnzP(r)>1 && fromIdx(r) > 0
1176 srcNode = smap.node(fromIdx(r));
1177 srcClass = smap.class(fromIdx(r));
1178 if sn.routing(srcNode, srcClass) == RoutingStrategy.JSQ
1180 elseif sn.routing(srcNode, srcClass) == RoutingStrategy.SQ
1182 kk = 2; % sub_sq
default when nodeparam carries no d
1183 if iscell(sn.nodeparam) && srcNode <= numel(sn.nodeparam) ...
1184 && iscell(sn.nodeparam{srcNode}) && srcClass <= numel(sn.nodeparam{srcNode})
1185 np = sn.nodeparam{srcNode}{srcClass};
1186 if ~isempty(np) && isfield(np,
'd') && ~isempty(np.d)
1190 kchK(r) = max(1, min(kk, numel(toIdxCell{r})));
1195% initialise Gillespie clocks ------------------------------------------
1197buffers =
buffers0; % working copy of the per-node ordered buffers
1198svcph = svcph0; % working copy of the in-service phase multiset (buffered-PH)
1199cacheProd = zeros(numel(
buffers0), sn.nclasses); % per (cache node, PRODUCED
class) count
1200% Seed each polling controller into the auxiliary buffer of its node. The seed
1201%
is a member of the reachable controller space (State.pollingInit
's rule): the
1202% server walks from a canonical position and settles on the first tangible
1203% state -- a visit on a class with work, a switchover, or a park -- so the
1204% initial state carries no controller configuration the dynamics cannot reach.
1206 for ind = 1:sn.nnodes
1207 if ~poll.isPoll(ind)
1210 pinf = poll.pinfo{ind};
1211 nbuf = classCounts(nvec0, phOff, nph, ind, R)'; % 1xR per-
class populations
1212 [q0, mode0, budget0] = State.pollingNext(pinf, 1, nbuf, R,
true);
1213 buffers{ind} = pollLandCtrl(pinf, q0, mode0, budget0);
1216% Per-region WAITQ FIFO of parked (dstNode, dstClass) tokens, encoded as
1217% (dstNode-1)*R + dstClass. Empty and untouched unless a region uses WAITQ.
1220 fcrBuf = repmat({zeros(1,0)}, numel(fcr.classCap), 1);
1223 Ak(k) = a{k}(nvec0, buffers, svcph);
1226Pk = -log(rand(1,numReactions));
1227Tk = zeros(1,numReactions);
1229tau = (Pk - Tk) ./ Ak;
1231% Performance tracking variables
1233NK = sn.njobs
'; % Jobs per class
1234servers = sn.nservers;
1235PH = sn.proc; % service-process MAPs/PHs
1237% Normalized DPS/GPS weights and class priorities, mirroring the propensity
1238% construction so the utilization accumulators use identical sharing factors.
1239classprio = sn.classprio(:)';
1242 if any(sn.sched(ist) == [SchedStrategy.DPS, SchedStrategy.GPS, ...
1243 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO])
1244 wnorm(ist, :) = sn.schedparam(ist, 1:R) / sum(sn.schedparam(ist, 1:R));
1250 [dt, kfire] = min(tau);
1251 if isinf(dt), line_error(mfilename,'Deadlock. Quitting nrm method.'); end
1253 totalTime = totalTime + dt;
1255 % Accumulate state-dependent metrics during this time interval
1257 ind = sn.stationToNode(ist);
1259 % nvec counts jobs per phase now, so the class population
is the
1260 % sum over that class's phases.
1261 currentPop = classPop(nvec, phOff, nph, ind, k);
1263 % Accumulate queue length (QN)
1264 QN(ist, k) = QN(ist, k) + currentPop * dt;
1266 % Compute throughput contribution from departures
1267 % Throughput
is the total absorption rate of the class: with phase
1268 % expansion each phase owns its own departure reaction, so they are
1269 % summed. Phase-change reactions move no job and are excluded, as
1270 % are the appended renege/retry columns.
1273 if fromIR(jd,1) == ind && fromIR(jd,2) == k && ~isPhaseRx(jd)
1274 depRate = depRate + Ak(jd);
1277 TN(ist, k) = TN(ist, k) + depRate * dt;
1279 % Compute utilization based on scheduling policy. For the whole PS
1280 % family the class-k utilization
is the share of service capacity
1281 % it receives divided by the server count, so the same sharing
1282 % factors that define the propensities are reused here (without the
1283 % lld/cd rate scalings, which rescale work but not occupancy).
1284 switch sn.sched(ist)
1285 case {SchedStrategy.INF, SchedStrategy.EXT}
1286 UN(ist, k) = UN(ist, k) + currentPop * dt;
1287 case {SchedStrategy.PS, SchedStrategy.LPS}
1288 totalPop = sum(classCounts(nvec, phOff, nph, ind, R));
1290 utilization = (currentPop / totalPop) * min(servers(ist), totalPop) / servers(ist);
1294 UN(ist, k) = UN(ist, k) + utilization * dt;
1295 case SchedStrategy.DPS
1296 npop = classCounts(nvec, phOff, nph, ind, R);
1297 UN(ist, k) = UN(ist, k) + dpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1298 case SchedStrategy.GPS
1299 npop = classCounts(nvec, phOff, nph, ind, R);
1300 UN(ist, k) = UN(ist, k) + gpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1301 case SchedStrategy.PSPRIO
1302 npop = classCounts(nvec, phOff, nph, ind, R);
1303 UN(ist, k) = UN(ist, k) + psprioshare(npop, k, servers(ist), classprio) / servers(ist) * dt;
1304 case SchedStrategy.DPSPRIO
1305 npop = classCounts(nvec, phOff, nph, ind, R);
1306 UN(ist, k) = UN(ist, k) + dpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1307 case SchedStrategy.GPSPRIO
1308 npop = classCounts(nvec, phOff, nph, ind, R);
1309 UN(ist, k) = UN(ist, k) + gpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1310 case SchedStrategy.PAS
1311 % Pass-and-swap / order-independent: utilization
is the
1312 % time-average number of in-service jobs per
class over the
1313 % servers, where
"in service" means the positions whose
1314 % marginal rate increment Delta_mu
is positive -- so a job
1315 % served by several server types still counts once, not
1316 % 1/rate (solver_ctmc_analyzer,
case PAS).
1317 UN(ist, k) = UN(ist, k) + pasInSvc(sn, ind, buffers{ind}, k) / servers(ist) * dt;
1318 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
1319 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
1320 SchedStrategy.LCFSPR}
1321 if ~isempty(PH{ist}{k})
1322 waiting = sum(buffers{ind} == k);
1323 inService = currentPop - waiting;
1324 UN(ist, k) = UN(ist, k) + (inService / servers(ist)) * dt;
1326 case SchedStrategy.POLLING
1327 % The single server
is busy on exactly one
class-k job
while
1328 % the controller serves
class k, and idle (switching or
1329 % parked) otherwise; so
class-k utilization
is the fraction
1330 % of time the controller
is SERVING
class k.
1331 ctrl = buffers{ind};
1332 if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == k
1333 UN(ist, k) = UN(ist, k) + dt / servers(ist);
1341 % update aggregate state
1343 cacheChanged =
false;
1345 % Cache access. The read-
class job at the cache reads an item drawn from
1346 % pread, and the cache contents (carried in buffers{cacheNode}) decide a
1347 % hit or a miss; the replacement policy then rewrites the contents.
1348 % Mirrors State.afterEventCache (READ, isSimulation). The job leaves in
1349 % the hit or miss
class at the SAME cache node, and the existing
1350 % immediate forwarding routes it downstream from there.
1351 cn = fromIR(kfire,1); rdc = fromIR(kfire,2);
1352 [outClass, newContents, cacheCat] = cacheAccess(sn, cn, rdc, buffers{cn});
1353 buffers{cn} = newContents;
1354 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1; % consume the read-
class job
1357 % BEGIN retrieval: the job must travel to the fetch queue and
1358 %
return before the miss completes, so it
is placed at the
1359 % retrieval
class's routed destination (the queue), NOT left at
1360 % the cache where the cache-access reaction would fire again.
1361 destPos = cacheRetrDest(cn, outClass);
1363 % Hit or miss/completion: the job leaves in the hit or miss class
1364 % at the SAME cache node; the existing immediate forwarding routes
1365 % it downstream. Count the production per produced class so the
1366 % hit/miss probabilities are the hit/miss-class throughput at the
1367 % cache (State.afterEventCache convention).
1368 destPos = phOff(cn, outClass) + 1;
1369 cacheProd(cn, outClass) = cacheProd(cn, outClass) + 1;
1371 nvec(destPos) = nvec(destPos) + 1;
1373 % OUTCLASS == 0 is a delayed hit: the request is absorbed (produces
1374 % nothing), coalescing onto the in-flight retrieval.
1375 cacheChanged = true;
1376 elseif nnzP(kfire)>1
1377 cand = toIdxCell{kfire};
1378 % A finite capacity region does NOT filter the routing draw. Routing
1379 % picks the destination first and the region decides admission at the
1380 % destination's entry afterwards, dropping the job on refusal; a
1381 % routing strategy that steered around full regions would be a
1382 % different (and better-behaved) model than the one SOLVER_SSA and the
1383 % CTMC implement. The refusal check
is applied to the drawn destination
1386 % JSQ: join the destination node with the smallest total population
1387 % (ties split uniformly)
1388 npop = inf(numel(cand),1);
1390 jnd = smap.node(cand(x));
1391 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1393 amins = find(npop == min(npop));
1394 r = amins(1 + floor(rand*length(amins)));
1395 elseif rr.on && rr.isrr(fromIR(kfire,1), fromIR(kfire,2))
1396 % Round-robin: advance the pointer, then take the destination it
1397 % lands on (State.afterEventRouter advances on DEP and the routing
1398 % closure reads state_after).
1399 [rr, jnd] = rrNext(rr, fromIR(kfire,1), fromIR(kfire,2));
1400 % A phase-type destination contributes ONE candidate per entry phase,
1401 % each weighted by pentry in the routing matrix. The pointer fixes the
1402 % NODE; the entry PHASE must still be drawn from pentry among that
1403 % node
's candidates. Taking the first match (phase 0) biases the
1404 % service time -- the RROBIN + phase-type residence bug (RUN-10). Use
1405 % smap.node (not the flat floor((cand-1)/R) formula, which is wrong
1406 % once phases expand the state) to find the node's candidates, then
1407 % sample among them in proportion to their routing weights.
1409 for x = 1:numel(cand)
1410 if smap.node(cand(x)) == jnd
1411 matches(end+1) = x; %#ok<AGROW>
1415 line_error(mfilename, sprintf(
'Round-robin selected node %d, which is not a routing destination of node %d.', jnd, fromIR(kfire,1)));
1417 if numel(matches) == 1
1421 w = zeros(numel(matches),1);
1422 for ii = 1:numel(matches)
1425 w(ii) = cd(x) - cd(x-1);
1434 u = rand * wsum; acc = 0; r = matches(end);
1435 for ii = 1:numel(matches)
1445 npop = zeros(numel(cand),1);
1447 jnd = smap.node(cand(x));
1448 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1450 % SQ(d): the d candidates are drawn uniformly with replacement
1451 % and the least loaded wins; the strict comparison retains the
1452 % first occurrence, which
is the tie rule of sub_sq.
1453 draws = min(kchK(kfire), numel(cand));
1457 x = 1 + floor(rand*numel(cand));
1458 if npop(x) < bestpop
1464 % Inverse-CDF sampling: smallest r such that cdfVec(r) > rand. The
1465 % previous formulation `1+find(rand>=cdfVec,1)` was a misuse of
1466 % find(...,1) that always returned 2 once rand exceeded cdfVec(1),
1467 % leaving destinations beyond the second one unreachable (e.g. all
1468 % traffic skipping Station3 in a 3-way RAND split).
1469 r = find(cdfVec{kfire} > rand, 1);
1471 r = length(cdfVec{kfire});
1474 % Balking
is decided on the pre-arrival population, so it
is drawn
1475 % before the state
is updated. A balked job
is lost: the source still
1476 % releases it, the destination never receives it.
1479 balked = balkDraw(balk, nvec, toIdxCell{kfire}(r), R, smap);
1481 % An open arrival at a full physically-capped destination
is lost,
1482 % exactly as a balked one
is: the source releases it, the destination
1483 % never receives it. Mirrors State.afterEventStation.
1484 if ~balked && capacityLoss(sn, nvec, toIdxCell{kfire}(r), R, smap)
1487 % A region refuses the drawn destination on the same pre-arrival
1488 % population. Under DROP the refused job
is lost, exactly as a balked
1489 % one
is; under WAITQ it
is parked in the refusing region
's FIFO and
1490 % admitted later, head-of-line. Either way it does not enter the
1491 % destination now, so the source still departs and destPos is cleared.
1492 parkF = 0; parkTok = 0;
1493 if ~balked && fcr.on
1494 dstN = smap.node(toIdxCell{kfire}(r));
1495 dstC = smap.class(toIdxCell{kfire}(r));
1496 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1499 if fcr.waitq(fref, dstC)
1500 parkF = fref; parkTok = (dstN-1)*R + dstC;
1504 nvec(fromIdxCell{kfire}) = nvec(fromIdxCell{kfire}) - 1;
1508 fcrBuf{parkF}(end+1) = parkTok;
1510 elseif sig.on && sigIsSignalArrival(sig, toIdxCell{kfire}(r), R, smap)
1511 % the signal is annihilated on arrival: it never joins the station
1512 [nvec, buffers] = sigApply(sig, nvec, buffers, toIdxCell{kfire}(r), R, mi, smap);
1515 nvec(toIdxCell{kfire}(r)) = nvec(toIdxCell{kfire}(r)) + 1;
1516 destPos = toIdxCell{kfire}(r);
1519 dpos = find(S(:,kfire) > 0); % deterministic destination (single move)
1521 if balk.on && ~isempty(dpos)
1522 balked = balkDraw(balk, nvec, dpos(1), R, smap);
1524 if ~balked && ~isempty(dpos) ...
1525 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1526 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire)) ...
1527 && capacityLoss(sn, nvec, dpos(1), R, smap)
1530 % Single-destination departures cross region boundaries too, so the
1531 % region gate applies here exactly as it does to a drawn destination.
1532 % Renege and retry columns carry no destination and are never gated.
1533 parkF = 0; parkTok = 0;
1534 if ~balked && fcr.on && ~isempty(dpos) ...
1535 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1536 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire))
1537 dstN = smap.node(dpos(1));
1538 dstC = smap.class(dpos(1));
1539 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1542 if fcr.waitq(fref, dstC)
1543 parkF = fref; parkTok = (dstN-1)*R + dstC;
1548 % lost or parked on arrival: apply the source departure only
1549 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1553 fcrBuf{parkF}(end+1) = parkTok;
1555 elseif sig.on && ~isempty(dpos) && sigIsSignalArrival(sig, dpos(1), R, smap)
1556 % the signal is annihilated on arrival: it never joins the station
1557 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1558 [nvec, buffers] = sigApply(sig, nvec, buffers, dpos(1), R, mi, smap);
1562 nvec = nvec + S(:,kfire); % zero change for self-loops
1566 elseif ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1567 && ~(kfire <= numel(isPollSwRx) && isPollSwRx(kfire)) && sn.isslc(fromIR(kfire,2))
1568 % Self-looping class: the completed job re-enters the same node and
1569 % class (its stoichiometry is a no-op). At a buffered (FCFS/LCFS)
1570 % station it must rejoin the buffer so the ordering rotates; point
1571 % destPos at the source slot so updateBuffers applies the arrival.
1572 destPos = fromIdx(kfire);
1576 % maintain the buffers given the source/destination of this firing
1578 if kfire <= numel(isRetryRx) && isRetryRx(kfire)
1579 % A successful retry moves one orbiting job into the free server. The
1580 % population is unchanged (it was already counted at the station), so
1581 % only the orbit shrinks; in-service is read back as population minus
1583 ind = fromIR(kfire,1);
1584 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first
');
1586 buffers{ind}(slot) = [];
1588 elseif kfire <= numel(isRenegeRx) && isRenegeRx(kfire)
1589 % Reneging removes a job that was WAITING, so no server is freed and no
1590 % queued job is promoted; the abandoning job simply leaves the buffer.
1591 % State.afterEventStation drops the newest waiting job of the class and
1592 % notes that for memoryless patience all waiting jobs are exchangeable,
1593 % so the choice cannot affect the marginal distribution.
1594 ind = fromIR(kfire,1);
1595 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first
');
1597 buffers{ind}(slot) = [];
1599 elseif kfire <= numel(isPhaseRx) && isPhaseRx(kfire) && bufPHNode(fromIR(kfire,1))
1600 % A buffered-PH phase transition moves one in-service job between phases
1601 % of its own service process. It frees no server and adds no arrival, so
1602 % the buffer is untouched and only svcph changes (INF/PS phase moves are
1603 % already applied to nvec via the stoichiometry and fall to updateBuffers
1604 % below as a no-op, as before).
1605 ind = fromIR(kfire,1); r = fromIR(kfire,2);
1606 svcph{ind}(r, phaseFrom(kfire)) = svcph{ind}(r, phaseFrom(kfire)) - 1;
1607 svcph{ind}(r, phaseTo(kfire)) = svcph{ind}(r, phaseTo(kfire)) + 1;
1609 elseif isCacheRx(kfire)
1610 % The cache access already updated the cache contents (buffers{cacheNode})
1611 % and moved the job to the hit/miss class in the firing block above; there
1612 % is no job buffer to maintain at a cache node.
1614 [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase);
1617 % Polling controller advance. The controller of each polling node lives in
1618 % its auxiliary buffer as [mode, pos, swk, ctr]; a firing can move it in
1619 % three ways, mirroring State.afterEventStation exactly (EventType.DEP under
1620 % SchedStrategy.POLLING, EventType.SWITCH, and the parked-server arrival):
1621 % * a service completion at the node ends the visit unless the discipline
1622 % still admits another job of the served class, and on ending walks the
1623 % cyclic order to the next tangible controller state;
1624 % * a switchover reaction advances the switchover PH one phase, or on
1625 % absorption arrives at the target buffer and opens a visit or walks on;
1626 % * an arrival to a parked server wakes it, and the walk resolves at once
1627 % to a visit on the newly present work.
1628 % Any of these changes a service gate or the switchover rate, so a change is
1629 % flagged to force a full propensity refresh below (like a WAITQ release).
1630 pollChanged = false;
1632 srcNode = fromIR(kfire,1);
1633 if kfire <= numel(isPollSwRx) && isPollSwRx(kfire)
1634 pind = pollSwNode(kfire);
1635 pinf = poll.pinfo{pind};
1636 ctrl = buffers{pind};
1637 posS = ctrl(2); swkS = ctrl(3);
1638 D0S = pinf.swD0{posS};
1639 KswS = pinf.Ksw(posS);
1640 w = zeros(1, KswS + 1);
1642 if kd ~= swkS && D0S(swkS,kd) > 0
1643 w(kd) = D0S(swkS,kd);
1646 w(KswS + 1) = max(0, -sum(D0S(swkS,:))); % absorption (D1 row sum)
1647 pick = drawFromDist(w);
1648 if pick <= KswS && pick ~= swkS
1649 ctrl(3) = pick; % internal phase advance
1650 buffers{pind} = ctrl;
1652 nbufS = classCounts(nvec, phOff, nph, pind, R)';
1653 [qS, mdS, bgS] = State.pollingNext(pinf, posS, nbufS, R,
true);
1654 buffers{pind} = pollLandCtrl(pinf, qS, mdS, bgS);
1657 elseif poll.isPoll(srcNode) && kfire <= nDepRx && ~isPhaseRx(kfire)
1658 pinf = poll.pinfo{srcNode};
1659 ctrl = buffers{srcNode};
1660 posD = ctrl(2); ctrD = ctrl(4);
1661 nbufD = classCounts(nvec, phOff, nph, srcNode, R)
'; % after the departure
1663 case PollingType.EXHAUSTIVE
1664 ctrnextD = 0; goonD = nbufD(posD) > 0;
1665 case PollingType.GATED
1666 ctrnextD = ctrD - 1; goonD = ctrnextD > 0;
1667 case PollingType.KLIMITED
1668 ctrnextD = ctrD - 1; goonD = ctrnextD > 0 && nbufD(posD) > 0;
1669 case PollingType.DECREMENTING
1670 ctrnextD = ctrD; goonD = nbufD(posD) > ctrD;
1673 buffers{srcNode} = [1, posD, 0, ctrnextD];
1675 [qD, mdD, bgD] = State.pollingNext(pinf, posD, nbufD, R, false);
1676 buffers{srcNode} = pollLandCtrl(pinf, qD, mdD, bgD);
1680 if ~isempty(destPos) && destPos > 0
1681 jnd = smap.node(destPos);
1683 ctrlA = buffers{jnd};
1684 if ~isempty(ctrlA) && ctrlA(1) == 0
1685 pinfA = poll.pinfo{jnd};
1686 nbufA = classCounts(nvec, phOff, nph, jnd, R)'; % includes the arrival
1687 [qA, mdA, bgA] = State.pollingNext(pinfA, ctrlA(2), nbufA, R,
true);
1688 buffers{jnd} = pollLandCtrl(pinfA, qA, mdA, bgA);
1695 % WAITQ: admit parked jobs whose regions
this firing may have relieved.
1696 % A release changes populations at arbitrary destination
nodes, so when
1697 % anything
is admitted every reaction
is refreshed rather than only the
1698 % dependency set of the fired reaction.
1700 if fcr.on && fcr.anyWaitq
1701 [nvec, buffers, fcrBuf, nReleased, svcph, relChanged] = ...
1702 fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode);
1703 svcChanged = svcChanged || relChanged;
1708 % update rates
for all reactions dependent on the last fired reaction. A
1709 % polling controller move or a WAITQ release can change rates outside the
1710 %
static dependency set of the fired reaction (a switchover reaction has an
1711 % all-zero stoichiometry
column, and a controller move flips service gates),
1712 % so either forces a full refresh.
1713 if nReleased > 0 || pollChanged || svcChanged || cacheChanged
1714 for k=1:numReactions
1715 Ak(k) = a{k}(nvec, buffers, svcph);
1719 Ak(k) = a{k}(nvec, buffers, svcph);
1724 Pk(kfire) = Pk(kfire) - log(rand);
1725 tau = (Pk - Tk) ./ Ak;
1728 %
do not count immediate events
1730 print_progress(options, n);
1732% Print newline after progress counter
1733if isfield(options,
'verbose') && options.verbose
1737% Normalize metrics by total time
1741 QN(ist, k) = QN(ist, k) / totalTime;
1742 UN(ist, k) = UN(ist, k) / totalTime;
1743 TN(ist, k) = TN(ist, k) / totalTime;
1748% Class-dependent stations report utilization as T*S/peak, where peak
is the
1749% declared per-
class peak rate scaling (sn.cdscalingpeak). This matches the
1750% T*S/c convention of the analytic solvers and serial SSA; the accumulated
1751% in-service fraction above divides by the server count (1
for a cd station),
1752% which
is not the same quantity. Override those stations here.
1753if ~isempty(sn.cdscaling) || ~isempty(sn.jdscaling)
1755 isCd = ~isempty(sn.cdscaling) && ist <= numel(sn.cdscaling) && ~isempty(sn.cdscaling{ist});
1756 isJd = ~isempty(sn.jdscaling) && ist <= numel(sn.jdscaling) && ~isempty(sn.jdscaling{ist});
1759 % Effective peak = product of the declared cd and jd peaks.
1761 if isCd, peak = peak * sn.cdscalingpeak(ist, k); end
1762 if isJd, peak = peak * sn.jdscalingpeak(ist, k); end
1763 if isfinite(sn.rates(ist, k)) && sn.rates(ist, k) > 0 && peak > 0
1764 UN(ist, k) = TN(ist, k) / sn.rates(ist, k) / peak;
1773% Compute derived metrics
1775 % System throughput at reference station
1776 XN(1, k) = TN(sn.refstat(k), k);
1781 RN(ist, k) = QN(ist, k) / TN(ist, k);
1789 CN(1, k) = NK(k) / XN(1, k);
1801 function print_progress(opt, samples_collected)
1802 if ~isfield(opt,
'verbose') || ~opt.verbose || batchStartupOptionUsed,
return; end
1803 if samples_collected == 1e3
1804 line_printf(
'\nSSA samples: %8d', samples_collected);
1805 elseif opt.verbose == 2
1806 if samples_collected == 0
1807 line_printf(
'\nSSA samples: %9d', samples_collected);
1809 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1811 elseif mod(samples_collected,1e3)==0 || opt.verbose == 2
1812 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1815end % next_reaction_method_direct
1817% ======================================================================
1818% Buffer maintenance
for FCFS/LCFS
nodes
1819% ======================================================================
1820function [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase)
1821% Maintain the ordered per-node buffers when reaction KFIRE fires. A departure
1822% frees a server, so the buffered job selected by the station
's discipline is
1823% promoted into service and leaves the buffer; an arrival at a buffered
1824% destination whose servers are all busy joins the buffer head. At a buffered-PH
1825% node the same events also move jobs in and out of the in-service phase multiset
1826% svcph, and SVCCHANGED flags that so the caller forces a full propensity refresh
1827% (svcph is not part of the stoichiometry, so the static dependency set misses it).
1828ind = fromIR(kfire,1); % source node of the firing
1831% Buffered-PH departure: the completing job leaves service, so drop it from the
1832% in-service phase it occupied (carried in depPhase). The promotion below refills
1833% the freed server from the buffer at a fresh entry phase.
1834if bufPHNode(ind) && isBufSvcRx(kfire)
1835 r = fromIR(kfire,2);
1836 svcph{ind}(r, depPhase(kfire)) = svcph{ind}(r, depPhase(kfire)) - 1;
1840% An order-independent station keeps the full ordered list, so a departure is
1841% not a promotion but a pass-and-swap rewrite: the completing position's chain
1842% shifts classes along and removes one slot. Which position completed
is
1843% redrawn here in proportion to the Delta_mu of the positions that eject
this
1844%
class, which
is the same split afterEventStationPAS enumerates.
1845if isListSched(ind, sn) && ~isempty(buffers{ind})
1846 buffers{ind} = oiDepart(sn, ind, buffers{ind}, fromIR(kfire,2));
1850% Handle departure from a buffered source node: promote one waiting job. A
1851% retrial station
is the exception -- the freed server
is NOT filled from the
1852% orbit, orbiting jobs re-enter only through RETRY events at the memoryless
1853% retrial rate (State.afterEventStation suppresses promotion likewise).
1854if isBuffered(ind, sn) && ~isempty(buffers{ind}) && ~isRetrialStation(ind, sn) ...
1855 && ~isListSched(ind, sn)
1856 pos = pickFromBuffer(buffers{ind}, sn, sn.nodeToStation(ind));
1857 promoted = buffers{ind}(pos);
1858 buffers{ind}(pos) = [];
1860 % The promoted waiting job starts service now, entering a phase drawn
1861 % from its entry distribution pie (the same allocation the init uses).
1862 ke = drawEntryPhase(sn, ind, promoted, smap.nph(ind, promoted));
1863 svcph{ind}(promoted, ke) = svcph{ind}(promoted, ke) + 1;
1868% Handle arrival at a buffered destination node
1869if ~isempty(destPos) && destPos > 0
1870 [buffers, svcph, arrChanged] = applyArrivalBuffer(smap.node(destPos), smap.class(destPos), ...
1871 nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
1872 svcChanged = svcChanged || arrChanged;
1876function [buffers, svcph, svcChanged] = applyArrivalBuffer(jnd, s, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode)
1877% Join a just-arrived
class-S job to the ordered buffer of destination node
1878% JND,
if that node
is buffered. NVEC already includes the arrival. Shared by
1879% updateBuffers (routed arrivals) and fcrReleaseCascade (WAITQ releases), so
1880% the two paths cannot drift. At a buffered-PH destination a job that enters
1881% service (rather than waiting)
is added to the in-service phase multiset svcph
1882% at a pie-drawn entry phase; SVCCHANGED flags that
for a propensity refresh.
1884 if isListSched(jnd, sn)
1885 % PAS/OI: the arrival simply joins the back of the ordered list; there
1886 %
is no server/buffer split, so no capacity test against mi. Capacity
1887 %
is the station
's own cap, and an arrival past it is lost.
1888 if numel(buffers{jnd}) < sn.cap(sn.nodeToStation(jnd))
1889 buffers{jnd}(end+1) = s; % append at the back (newest last)
1891 elseif isBuffered(jnd, sn)
1892 totalAtDest = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R));
1893 enteredService = false;
1894 if isRetrialStation(jnd, sn)
1895 % A retrial station breaks the buffer invariant the other policies
1896 % share: because a departure does not promote, the orbit can be
1897 % occupied while servers sit idle, so "total > mi" no longer means
1898 % "the servers are busy". An arrival must consult the servers
1899 % directly and only join the orbit when none is free.
1900 inSvc = (totalAtDest - 1) - numel(buffers{jnd});
1902 buffers{jnd} = [s, buffers{jnd}];
1904 enteredService = true;
1906 elseif totalAtDest > mi(jnd)
1907 if isPreemptive(jnd, sn)
1908 % Preempt-resume: the arrival seizes a server and the incumbent
1909 % it displaces is the one that joins the buffer. The victim is
1910 % drawn in proportion to the class occupancies of the servers,
1911 % as State.afterEventStation weights its preemption branches by
1912 % si_preempt/sum(space_srv). Buffering the incumbent rather than
1913 % the arrival is what leaves the new job in service, since
1914 % in-service is read back as population minus buffer occupancy.
1915 c = pickPreempted(nvec, buffers{jnd}, jnd, s, R);
1917 buffers{jnd} = [c, buffers{jnd}]; % addFirst
1919 enteredService = true;
1921 % All servers busy - arriving job joins back of buffer
1922 buffers{jnd} = [s, buffers{jnd}]; % addFirst
1925 % A server is free: the job goes straight into service.
1926 enteredService = true;
1928 if enteredService && bufPHNode(jnd)
1929 ke = drawEntryPhase(sn, jnd, s, smap.nph(jnd, s));
1930 svcph{jnd}(s, ke) = svcph{jnd}(s, ke) + 1;
1936function ke = drawEntryPhase(sn, jnd, s, nphjs)
1937% Sample the service phase a class-S job starts in at node JND from its entry
1938% distribution pie. A single-phase class always enters phase 1.
1943pe = entryProbs(sn, jnd, s, nphjs);
1944ke = drawFromDist(pe);
1947function pos = pickFromBuffer(buf, sn, ist)
1948% Index of the waiting job that the discipline at station IST promotes into
1949% service. BUF is ordered newest-first / oldest-last, matching the convention
1950% of State.afterEventStation's space_buf (which inserts arrivals at
column 1
1951% and,
for HOL, promotes the rightmost job of the urgent priority group).
1953 case SchedStrategy.FCFS
1954 pos = numel(buf); % oldest
1955 case {SchedStrategy.LCFS, SchedStrategy.LCFSPR}
1956 pos = 1; % newest / most recently preempted
1957 case SchedStrategy.SIRO
1958 % Uniform over the waiting jobs. State.afterEventStation promotes a
1959 %
class-r job with probability (nir(r)-sir(r))/(ni-sum(sir)), i.e. the
1960 % waiting
class-r fraction, which
is exactly a uniform draw over buf.
1961 pos = 1 + floor(rand * numel(buf));
1962 case SchedStrategy.HOL
1963 % Highest priority (lowest classprio value); FCFS within the group, so
1964 % the oldest = the last matching position.
1965 prio = sn.classprio(buf);
1966 pos = find(prio == min(prio), 1,
'last');
1967 case {SchedStrategy.SEPT, SchedStrategy.LEPT}
1968 % sn.schedparam(ist,r)
is the rank of
class r's mean service time
1969 % (ascending
for SEPT, descending
for LEPT), so the promoted
class is
1970 % the waiting one of least rank. Oldest first within a
class.
1971 ranks = sn.schedparam(ist, buf);
1972 pos = find(ranks == min(ranks), 1,
'last');
1974 line_error(mfilename, sprintf(
'pickFromBuffer: unsupported buffered policy %s.', ...
1975 SchedStrategy.toText(sn.sched(ist))));
1979function n = pasInSvc(sn, ind, c, r)
1980% Number of
class-r jobs in service at a PAS/OI station holding the ordered
1981% list C: the positions whose marginal rate increment Delta_mu
is positive.
1982% Mirrors the sir the PAS branch of State.toMarginal reports, which
is what
1983% solver_ctmc_analyzer divides by the server count.
1988muFun = sn.nodeparam{ind}.svcRateFun;
1991 muCur = muFun(c(1:p));
1992 if muCur - muPrev > 0 && c(p) == r
1999function buf = oiDepart(sn, ind, buf, r)
2000% Apply the pass-and-swap rewrite
for a
class-r departure at OI station IND.
2001% The completing position
is drawn among those whose pass-and-swap ejects
class
2002% r, weighted by that position
's own service rate Delta_mu.
2003muFun = sn.nodeparam{ind}.svcRateFun;
2004G = sn.nodeparam{ind}.swapGraph;
2011 muCur = muFun(c(1:p));
2012 ratep = muCur - muPrev;
2017 [~, depClass] = State.passAndSwap(c, p, G);
2019 pos(end+1) = p; %#ok<AGROW>
2020 w(end+1) = ratep; %#ok<AGROW>
2024 return % this class cannot depart from the current list
2036buf = State.passAndSwap(c, pick, G);
2039function rt = oirate(muFun, G, c, r)
2040% Aggregate class-r departure rate of an order-independent / pass-and-swap
2041% station holding the ordered list C (oldest first). Mirrors the DEP branch of
2042% State.afterEventStationPAS: every position contributes its own service token
2043% at Delta_mu, and pass-and-swap decides which class actually leaves.
2049muPrev = 0; % mu of the empty prefix is 0
2051 muCur = muFun(c(1:p));
2052 ratep = muCur - muPrev;
2055 continue % position p receives no service
2057 [~, depClass] = State.passAndSwap(c, p, G);
2064function tf = isListSched(ind, sn)
2065% True for stations whose buffer holds the FULL ordered job list rather than
2066% only the waiting jobs.
2069 tf = (sn.sched(sn.nodeToStation(ind)) == SchedStrategy.PAS);
2073function tf = isRetrialStation(ind, sn)
2074% True for stations with a retrial orbit: their freed servers are not filled by
2075% promotion, only by a successful RETRY.
2077if sn.isstation(ind) && isfield(sn,'retrialProc
') && ~isempty(sn.retrialProc)
2078 ist = sn.nodeToStation(ind);
2079 tf = ist > 0 && any(~cellfun(@isempty, sn.retrialProc(ist,:)));
2083function tf = isPreemptive(ind, sn)
2084% True for the preempt-resume / preempt-independent policies, whose arrivals
2085% displace an incumbent instead of queueing behind it.
2088 ist = sn.nodeToStation(ind);
2089 tf = any(sn.sched(ist) == [SchedStrategy.LCFSPR]);
2093function c = pickPreempted(nvec, buf, jnd, arrClass, R)
2094% Class of the incumbent displaced by an arrival of class ARRCLASS at node JND,
2095% drawn in proportion to the servers' class occupancies. NVEC already counts
2096% the arrival, so it
is discounted here to recover the pre-arrival in-service
2097% composition (in-service = population minus buffer occupancy).
2101 insvc(r) = nvec(base + r) - sum(buf == r);
2103 insvc(r) = insvc(r) - 1; % discount the job that just arrived
2106insvc(insvc < 0) = 0;
2114c = find(insvc > 0, 1,
'last');
2116 acc = acc + insvc(r);
2117 if insvc(r) > 0 && u < acc
2124function npop = classCounts(X, phOff, nph, ind, R)
2125% Per-
class populations at node IND, summing each class over its phases. The
2126% scheduling rate laws are
class-level: they are unchanged by phase expansion,
2127% and only the per-phase share (see kirFrac)
is layered on top.
2130 npop(r) = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2134function n = classPop(X, phOff, nph, ind, r)
2135% Population of
class R at node IND, summed over its phases.
2136n = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2139function f = kirFrac(X, slot, phOff, nph, ind, r)
2140% Share of its
class that the job population in one phase represents: kir/nir.
2141% The class-level rate law
is split across the class's phases in this ratio,
2142% which
is exactly how State.afterEventStation writes every phase-aware case
2143% (e.g. DPS uses (kir/nir) * [class share]). For a single-phase class this
is
2144% 1 whenever the class
is present, so an exponential model
is unaffected.
2145nir = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2153function pentry = entryProbs(sn, jnd, s, nphjs)
2154% Entry-phase distribution of a class-s job arriving at node JND: pie of its
2155% service process there. A non-station node, or a station whose process is
2156% absent (a disabled class), has a single phase entered with probability 1.
2157pentry = zeros(1, nphjs);
2162ist = sn.nodeToStation(jnd);
2165if isempty(p) || all(isnan(p)) || sum(p) <= 0
2166 % no entry distribution declared: enter the first phase
2170pentry(1:min(nphjs,numel(p))) = p(1:min(nphjs,numel(p)));
2171pentry = pentry / sum(pentry);
2174function tf = isBuffered(ind, sn)
2175% True for stations whose waiting jobs are held in an ordered buffer.
2178 ist = sn.nodeToStation(ind);
2179 tf = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.LCFS, ...
2180 SchedStrategy.SIRO, SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
2181 SchedStrategy.LCFSPR]);
2185function f = lldfac(ldrow, ntot, lldlimit)
2186% Limited load-dependent scaling factor at total station population NTOT. Returns
2187% 1 when the station has no load dependence or is empty; otherwise the tabulated
2188% factor, clamped to the last entry beyond the tabulated limit.
2189if isempty(ldrow) || ntot < 1
2192 f = ldrow(min(round(ntot), lldlimit));
2196% ======================================================================
2197% Round-robin routing pointers
2198% ======================================================================
2200function rr = rrPrecompute(sn, state)
2201% Per-(node,class) round-robin pointers, seeded from the initial state. RROBIN
2202% stores the destination node index in its slot, WRROBIN a POSITION in the
2203% weighted cycle (each outlink replicated by its weight), matching
2204% State.fromMarginal and State.afterEventRouter.
2205rr = struct('on', false);
2206if ~isfield(sn,'routing') || isempty(sn.routing)
2209if ~any(sn.routing(:) == RoutingStrategy.RROBIN | sn.routing(:) == RoutingStrategy.WRROBIN)
2214rr.isrr = false(sn.nnodes, R);
2215rr.iswrr = false(sn.nnodes, R);
2216rr.cycle = cell(sn.nnodes, R); % ordered destination list walked per dispatch
2217rr.pos = ones(sn.nnodes, R); % current position in that list
2218for ind = 1:sn.nnodes
2220 isRR = sn.routing(ind,r) == RoutingStrategy.RROBIN;
2221 isWRR = sn.routing(ind,r) == RoutingStrategy.WRROBIN;
2225 np = sn.nodeparam{ind}{r};
2226 if isWRR && isfield(np,'weighted_outlinks') && ~isempty(np.weighted_outlinks)
2227 cyc = np.weighted_outlinks;
2231 rr.isrr(ind,r) = true;
2232 rr.iswrr(ind,r) = isWRR;
2233 rr.cycle{ind,r} = cyc(:)';
2234 % seed the pointer from the initial state slot so a warm start is honored
2236 if sn.isstateful(ind)
2237 st = state{sn.nodeToStateful(ind)};
2238 slot = sum(sn.nvars(ind, 1:(R + r)));
2239 if ~isempty(st) && slot >= 1 && slot <= numel(st)
2242 if v >= 1 && v <= numel(cyc), p0 = v; end
2244 j = find(cyc == v, 1);
2245 if ~isempty(j), p0 = j; end
2254function [rr, jnd] = rrNext(rr, ind, r)
2255% Advance the pointer cyclically and return the destination it lands on.
2256cyc = rr.cycle{ind,r};
2267% ======================================================================
2269% ======================================================================
2271function sig = signalPrecompute(sn)
2272% Signal classes and their removal parameters. A signal never joins a station:
2273% it removes jobs there and is annihilated (State.afterEventStationSignal).
2274sig = struct('on', false);
2275if ~isfield(sn,'issignal') || isempty(sn.issignal) || ~any(sn.issignal)
2279sig.sn = sn; % signalBatchPMF and isCatastropheSignal need it
2280sig.issignal = logical(sn.issignal(:)');
2281sig.nonsignal = find(~sig.issignal);
2284function tf = sigIsSignalArrival(sig, destPos, R, smap)
2285% True when the state slot DESTPOS is a signal class at a station.
2286s = smap.class(destPos);
2287jnd = smap.node(destPos);
2288tf = sig.issignal(s) && sig.sn.isstation(jnd);
2291function [nvec, buffers] = sigApply(sig, nvec, buffers, destPos, R, mi, smap)
2292% Apply the arrival of a signal class at a station: pick the victims and remove
2293% them. Mirrors State.afterEventStationSignal, sampled instead of enumerated.
2295jnd = smap.node(destPos);
2296cls = smap.class(destPos);
2297base = smap.phOff(jnd,1); % first slot of this node
2298ist = sn.nodeToStation(jnd);
2300% CATASTROPHE empties the station of every job, ignoring the batch-size
2301% distribution: a catastrophe removes all jobs by definition.
2302if State.isCatastropheSignal(sn, cls)
2303 nvec((base+1):(base+R)) = 0;
2308% Eligible victim classes. A signal that declares a target (forJobClass,
2309% sn.signaltarget >= 1) only removes that class; otherwise every non-signal
2310% class is eligible, which is the classic Gelenbe negative customer and is what
2311% SolverMAM and SolverLDES both do.
2313if isfield(sn,'signaltarget') && ~isempty(sn.signaltarget) && numel(sn.signaltarget) >= cls
2314 tgt = sn.signaltarget(cls);
2319 tgtclasses = sig.nonsignal;
2321tgtclasses = tgtclasses(nvec(base + tgtclasses) > 0);
2322ntot = sum(nvec(base + tgtclasses));
2323if isempty(tgtclasses) || ntot <= 0
2324 return % no victim: the signal simply vanishes
2327% Batch size, drawn from the pmf the reference enumerates. It is already
2328% clipped at the eligible population, so an oversized batch empties it rather
2329% than driving the queue negative.
2330[kvals, kprobs] = State.signalBatchPMF(sn, cls, ntot);
2331k = kvals(find(cumsum(kprobs) >= rand, 1));
2336policy = RemovalPolicy.RANDOM;
2337if isfield(sn,'signalrempolicy') && ~isempty(sn.signalrempolicy) && numel(sn.signalrempolicy) >= cls
2338 policy = sn.signalrempolicy(cls);
2342 [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, ...
2343 tgtclasses, policy, R, mi);
2345 break % already drained
2350function [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, tgtclasses, policy, R, mi)
2351% Remove one victim under the signal's removal policy. Waiting jobs live in the
2352% buffer; the rest of each class population is in service.
2355waitIdx = find(ismember(buf, tgtclasses)); % eligible waiting positions
2356nwait = numel(waitIdx);
2359 nsrv = nsrv + max(0, nvec(base + r) - sum(buf == r));
2361if nwait == 0 && nsrv == 0
2365% FCFS/LCFS rank the waiting line by age, which only an ordered buffer records.
2366% The NRM buffer is newest-first / oldest-last, so the head of line (oldest) is
2367% the last eligible position and the most recent arrival the first. A per-class
2368% count buffer (SIRO/SEPT/LEPT) carries no age, so an age-based policy
2369% degenerates to a uniform draw there, exactly as in the reference.
2370isOrdered = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.HOL, SchedStrategy.LCFS]);
2371ageOrdered = isOrdered && (policy == RemovalPolicy.FCFS || policy == RemovalPolicy.LCFS);
2372if ageOrdered && nwait > 0
2373 if policy == RemovalPolicy.FCFS
2374 pick = waitIdx(end); % head of line: the oldest waiting job
2376 pick = waitIdx(1); % the most recent arrival
2379 buffers{jnd}(pick) = [];
2380 nvec(base + victim) = nvec(base + victim) - 1;
2385% RANDOM draws uniformly over waiting and in-service alike; FCFS/LCFS drain the
2386% waiting line before reaching into the servers.
2387if policy == RemovalPolicy.RANDOM
2388 total = nwait + nsrv;
2396if nwait > 0 && (policy ~= RemovalPolicy.RANDOM || u < nwait)
2397 % a waiting victim, uniform over the eligible positions
2398 pick = waitIdx(1 + floor(rand * nwait));
2400 buffers{jnd}(pick) = [];
2401 nvec(base + victim) = nvec(base + victim) - 1;
2406% an in-service victim, uniform over the eligible in-service jobs
2408target = rand * nsrv;
2410 cnt = max(0, nvec(base + r) - sum(buf == r));
2412 if cnt > 0 && target < acc
2413 nvec(base + r) = nvec(base + r) - 1;
2415 % the freed server pulls the head of line in, which in the NRM is just
2416 % the waiting job leaving the buffer (in-service is derived as
2417 % population minus buffer occupancy)
2418 total_new = sum(nvec((base+1):(base+R)));
2419 if numel(buffers{jnd}) > max(0, total_new - mi(jnd))
2420 buffers{jnd}(end) = []; % head of line: the oldest waiting job
2427% ======================================================================
2429% ======================================================================
2431function balk = balkPrecompute(sn)
2432% Per (station,
class) balking threshold table, for the QUEUE_LENGTH strategy.
2433balk = struct('on', false);
2434if ~isfield(sn,'balkingStrategy') || isempty(sn.balkingStrategy)
2437if ~any(sn.balkingStrategy(:) == BalkingStrategy.QUEUE_LENGTH)
2441balk.strategy = sn.balkingStrategy;
2442balk.thresholds = sn.balkingThresholds;
2443% index maps needed by balkDraw, captured so it never needs the whole sn
2444balk.isstation = sn.isstation;
2445balk.nodeToStation = sn.nodeToStation;
2448function tf = balkDraw(balk, nvec, destPos, R, smap)
2449% True if the job routed to state slot DESTPOS balks. The threshold table
is
2450% scanned in order and the FIRST interval containing the pre-arrival total
2451% station population wins, matching State.afterEventStation.
2453jnd = smap.node(destPos);
2454s = smap.class(destPos);
2455if ~balk.isstation(jnd)
2458ist = balk.nodeToStation(jnd);
2459if ist < 1 || balk.strategy(ist, s) ~= BalkingStrategy.QUEUE_LENGTH
2462qlen = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R)); % pre-arrival total population
2463th = balk.thresholds{ist, s};
2467 if qlen >= t{1} && qlen <= t{2}
2472tf = balkProb > 0 && rand < balkProb;
2475function tf = capacityLoss(sn, nvec, destPos, R, smap)
2476% True
if an OPEN-
class job routed to state slot DESTPOS
is lost because its
2477% destination station
is a physically finite-capacity station that
is already
2478% full. Mirrors the hasRoom gate + State.arrivalIsLost of afterEventStation:
2479% total occupancy (buffer + in service)
is capped at sn.cap, per
class at
2480% sn.classcap (0 = no per-
class bound). A refused CLOSED job must block, not
2481% vanish from the conserved population, so it
is NOT dropped here. Inert unless
2482% the destination declares a physical drop rule. This
is the finite-capacity
2483% loss the NRM reaction network otherwise omits, which let a capped queue
2484% overflow well past sn.cap under simulation.
2486jnd = smap.node(destPos);
2487dstC = smap.class(destPos);
2488if ~sn.isstation(jnd)
2491ist = sn.nodeToStation(jnd);
2495if ~State.isPhysicalCapacity(sn, ist, dstC) || ~State.arrivalIsLost(sn, ist, dstC)
2498cc = classCounts(nvec, smap.phOff, smap.nph, jnd, R); % pre-arrival populations
2499capLimit = sn.cap(ist);
2500if isfinite(capLimit) && sum(cc) >= capLimit
2504if ~isempty(sn.classcap) && size(sn.classcap,1) >= ist && size(sn.classcap,2) >= dstC
2505 classCapLimit = sn.classcap(ist, dstC);
2506 if classCapLimit > 0 && cc(dstC) >= classCapLimit
2512% ======================================================================
2513% Finite capacity regions (DROP rule)
2514% ======================================================================
2516function fcr = fcrPrecompute(sn)
2517% Per-region member stations and admission caps, mirroring the FCR precompute
2518% of SOLVER_SSA (the serial engine) field
for field.
2519fcr =
struct(
'on',
false);
2520if ~isfield(sn,
'nregions') || sn.nregions == 0
2526fcr.memberMask =
false(F, sn.nstations);
2527fcr.classCap = cell(F,1);
2528fcr.globalCap = inf(F,1);
2529fcr.memCap = inf(F,1);
2533% Per-(region,
class) admission rule: DROP destroys a refused job, WAITQ parks
2534% it in the region FIFO and admits it head-of-line as capacity frees. Mirrors
2535% SOLVER_SSA
's fcrRule (regionrule ~= DropStrategy.DROP). Regions with no
2536% WAITQ class carry no FIFO, so pure-DROP models pay nothing.
2537fcr.waitq = false(F, K);
2538if isfield(sn,'regionrule
') && ~isempty(sn.regionrule)
2541 fcr.waitq(f,r) = sn.regionrule(f,r) ~= DropStrategy.DROP;
2545fcr.anyWaitq = any(fcr.waitq(:));
2547 Rmat = sn.region{f}; % M x (K+1)
2548 % membership: any job-count cap OR the region memory budget set on the
2549 % station row (a memory-only region has all job-count entries at -1)
2550 memvec = -ones(sn.nstations,1);
2551 if isfield(sn,'regionmaxmem
') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2552 memvec = sn.regionmaxmem{f}(:);
2554 mask = (any(Rmat ~= -1, 2) | memvec ~= -1)';
2555 fcr.memberMask(f, 1:numel(mask)) = mask;
2556 members = find(mask);
2559 cv = Rmat(members, r); cv = cv(cv ~= -1);
2560 if ~isempty(cv); ccap(r) = min(cv); end
2562 fcr.classCap{f} = ccap;
2563 gv = Rmat(members, K+1); gv = gv(gv ~= -1);
2564 if ~isempty(gv); fcr.globalCap(f) = min(gv); end
2565 if isfield(sn,
'regionmaxmem') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2566 mv = sn.regionmaxmem{f}(members); mv = mv(mv ~= -1);
2567 if ~isempty(mv); fcr.memCap(f) = min(mv); end
2569 fcr.sz{f} = sn.regionsz(f,:);
2570 if isfield(sn,
'regionlincon') && size(sn.regionlincon,1) >= f && ~isempty(sn.regionlincon{f,1})
2571 fcr.A{f} = sn.regionlincon{f,1};
2572 fcr.b{f} = sn.regionlincon{f,2};
2575% node-level membership, so the gate can be evaluated straight off the NRM
2576% state vector without going through station indices on every firing
2577fcr.memberNode =
false(F, sn.nnodes);
2579 for ist = find(fcr.memberMask(f,:))
2580 fcr.memberNode(f, sn.stationToNode(ist)) = true;
2585function tf = fcrViolates(xn, ccap, gcap, memcap, sz, A, b)
2586% True
if per-
class population vector XN breaks any admission constraint of
2587% the region. Mirrors fcr_violates in SOLVER_SSA.
2588tf = any(xn > ccap) || sum(xn) > gcap || (xn * sz(:) > memcap);
2589if ~tf && ~isempty(A)
2590 tf = any(A * xn(:) > b(:));
2594function x = fcrRegionPop(nvec, memberNodeRow, R, smap)
2595% Per-
class population of a region, read directly off the NRM state vector.
2597for jnd = find(memberNodeRow)
2598 x = x + classCounts(nvec, smap.phOff, smap.nph, jnd, R)
';
2602function tf = fcrAdmits(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2603% True if a class-DSTCLASS job may enter node DSTNODE, having just left node
2604% SRCNODE as class SRCCLASS. Only regions containing the destination can
2605% refuse the move; a move whose source is in the same region frees a slot
2606% first, so the departure is accounted for before the arrival is tested.
2607tf = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap) == 0;
2610function f = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2611% Index of the FIRST region that refuses a class-DSTCLASS job entering
2612% DSTNODE, having just left SRCNODE as class SRCCLASS; 0 if every region
2613% admits it. Same admission test as the DROP path, but it names the refusing
2614% region so the caller can consult that region's DROP/WAITQ rule. Mirrors the
2615% first-region `
break` of SOLVER_SSA
's blockFCR loop.
2620for ff = 1:size(fcr.memberNode,1)
2621 if ~fcr.memberNode(ff, dstNode)
2622 continue % this region does not constrain the destination
2624 x = fcrRegionPop(nvec, fcr.memberNode(ff,:), R, smap);
2625 % srcNode <= 0 means the mover has no live source in the state (a WAITQ
2626 % release, whose job already left its source when it was parked), so no
2627 % source slot is freed.
2628 if srcNode > 0 && fcr.memberNode(ff, srcNode)
2629 x(srcClass) = x(srcClass) - 1;
2631 x(dstClass) = x(dstClass) + 1;
2632 if fcrViolates(x, fcr.classCap{ff}, fcr.globalCap(ff), fcr.memCap(ff), ...
2633 fcr.sz{ff}, fcr.A{ff}, fcr.b{ff})
2640function [nvec, buffers, fcrBuf, released, svcph, svcChanged] = fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode)
2641% Strict-FIFO head-of-line release of parked WAITQ tokens: admit each region's
2642% FIFO head
while the admission constraints permit, applying the arrival to
2643% the destination station (entry-phase slot plus buffer join). Mirrors
2644% SOLVER_SSA
's fcr_release. A token is (dstNode, dstClass); the phase is drawn
2645% at release, as a routed arrival draws it. Loops until a full pass frees
2646% nothing, so a release that frees capacity elsewhere cascades.
2652 for f = 1:numel(fcrBuf)
2653 if isempty(fcrBuf{f})
2657 dstNode = floor((tok-1)/R) + 1;
2658 dstClass = mod(tok-1, R) + 1;
2659 % The parked job already left its source, so admission is tested with
2660 % the source term absent (srcNode = -1 never matches memberNode).
2661 if fcrRefusingRegion(fcr, nvec, -1, dstClass, dstNode, dstClass, R, smap) ~= 0
2662 continue % head-of-line: this FIFO stays blocked
2664 if bufPHNode(dstNode)
2665 % Buffered-PH destination: the released job lands in the class total
2666 % slot; whether it enters service (and its entry phase) is decided in
2667 % applyArrivalBuffer against the server occupancy, exactly as a routed
2669 nvec(smap.phOff(dstNode,dstClass) + 1) = nvec(smap.phOff(dstNode,dstClass) + 1) + 1;
2670 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2671 svcChanged = svcChanged || arrCh;
2673 pentry = entryProbs(sn, dstNode, dstClass, smap.nph(dstNode,dstClass));
2674 ke = drawFromDist(pentry);
2675 dslot = smap.phOff(dstNode,dstClass) + ke;
2676 nvec(dslot) = nvec(dslot) + 1;
2677 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2678 svcChanged = svcChanged || arrCh;
2681 released = released + 1;
2687function ke = drawFromDist(p)
2688% Index drawn from the (unnormalized, nonnegative) weight vector P.
2695ke = find(c > rand, 1);
2701function [outClass, var, category] = cacheAccess(sn, ind, class, var)
2702% Simulate one cache READ at cache node IND by a class-CLASS job over the cache
2703% state VAR (totalCacheCapacity content slots followed, when a retrieval system
2704% is present, by a per-item retrieval-occupancy bitmap). Returns the class the
2705% job leaves in -- OUTCLASS = 0 means the request was absorbed as a delayed hit
2706% and produces nothing -- the rewritten VAR, and a CATEGORY (1 hit, 2 miss/
2707% retrieval-complete, 3 delayed-hit, 4 begin-retrieval). A faithful port of
2708% State.afterEventCache (READ, isSimulation): non-retrieval hit/miss with all
2709% replacement policies, plus the retrieval (delayed-hit) system where a miss for
2710% an item not yet being fetched begins a retrieval (switch to the item's
2711% retrieval
class, mark the bitmap), a concurrent request
for an item already
2712% being fetched
is absorbed, and a returning retrieval-
class read completes the
2713% miss (clear the bitmap, admit the item).
2714np = sn.nodeparam{ind};
2718replacement_id = np.replacestrat;
2719if isfield(np,
'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
2720 totalCacheCapacity = np.totalCacheCapacity;
2722 totalCacheCapacity = sum(m);
2724hitclassArr = np.hitclass;
2725missclassArr = np.missclass;
2726if isfield(np,'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
2727 rci = np.retrievalClassIndices(:)';
2731isFromRetrieval = any(rci == class);
2732if isfield(np,'retrievalClasses') && ~isempty(np.retrievalClasses)
2733 retrClasses = np.retrievalClasses;
2737hasRetrieval = isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
2738 && any(np.retrievalSystemCapacity > 0);
2741k = drawFromDist(p); % requested item
2742l = drawFromDist(ac{
class,k}(1,:)); % target list
for a miss (1 => reject)
2743posk = find(k == var(1:totalCacheCapacity), 1,
'first');
2745 posk = []; % a returning retrieval always COMPLETES its own miss
2749 % ===================== CACHE HIT =====================
2750 outClass = hitclassArr(
class);
2752 if posk <= sum(m(1:h-1))
2753 % hit in list i < h: promote toward the last list
2754 i = find(posk <= cumsum(m), 1);
2755 j = posk - sum(m(1:i-1));
2756 accrow = ac{
class,k}(1+i, (1+i):end);
2757 inew = i + drawFromDist(accrow / sum(accrow)) - 1;
2758 switch replacement_id
2759 case ReplacementStrategy.FIFO
2762 varp(cpos(i,j)) = var(cpos(inew,m(inew)));
2763 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2764 varp(cpos(inew,1)) = k;
2767 case ReplacementStrategy.RR
2769 rpos = randi(m(inew),1,1);
2770 varp(cpos(i,j)) = var(cpos(inew,rpos));
2771 varp(cpos(inew,rpos)) = k;
2773 case {ReplacementStrategy.LRU, ReplacementStrategy.SFIFO, ...
2774 ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2776 varp(cpos(i,2):cpos(i,j)) = var(cpos(i,1):cpos(i,j-1));
2777 varp(cpos(i,1)) = var(cpos(inew,m(inew)));
2778 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2779 varp(cpos(inew,1)) = k;
2783 % hit in the last list h
2784 j = posk - sum(m(1:h-1));
2785 switch replacement_id
2786 case {ReplacementStrategy.RR, ReplacementStrategy.FIFO, ReplacementStrategy.SFIFO}
2788 case {ReplacementStrategy.LRU, ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2790 varp(cpos(h,2):cpos(h,j)) = var(cpos(h,1):cpos(h,j-1));
2791 varp(cpos(h,1)) = var(cpos(h,j));
2798% ===================== CACHE MISS / retrieval =====================
2799if hasRetrieval && ~isFromRetrieval
2800 % Consult the retrieval system: an item with a retrieval
class is fetched
2801 % rather than admitted directly on a miss.
2803 if ~isempty(retrClasses) && k <= size(retrClasses,1) &&
class <= size(retrClasses,2)
2804 rClass = retrClasses(k,
class);
2807 inRetrieval = (totalCacheCapacity + k <= numel(var)) && var(totalCacheCapacity + k) ~= 0;
2809 % DELAYED HIT:
this request
is served by the in-flight retrieval and
2810 % absorbed (no
class produced), coalescing onto the pending fetch.
2815 % BEGIN retrieval:
switch to the item
's retrieval class and mark the
2816 % item as being fetched; the job routes to the retrieval queue and
2817 % returns later to complete the miss.
2818 var(totalCacheCapacity + k) = 1;
2826% COMPLETE the miss: a returning retrieval, or a plain miss with no retrieval
2827% class. Clear the retrieval bit (if any) and admit item k per the policy.
2828if isFromRetrieval && (totalCacheCapacity + k <= numel(var))
2829 var(totalCacheCapacity + k) = 0;
2831outClass = missclassArr(class);
2834switch replacement_id
2835 case {ReplacementStrategy.FIFO, ReplacementStrategy.LRU, ...
2836 ReplacementStrategy.SFIFO, ReplacementStrategy.HLRU}
2839 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2840 varp(cpos(listidx,1)) = k;
2843 case ReplacementStrategy.RR
2845 rpos = randi(m(listidx),1,1);
2846 var(cpos(listidx,rpos)) = k;
2848 case ReplacementStrategy.QLRU
2849 if isfield(np,'qlru
') && ~isempty(np.qlru), qadm = np.qlru; else, qadm = 1.0; end
2850 if listidx > 0 && rand <= qadm
2852 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2853 varp(cpos(listidx,1)) = k;
2858 function pos = cpos(ii,jj)
2859 pos = sum(m(1:ii-1)) + jj;
2863% ======================================================================
2864% PS-family sharing factors
2866% Each returns the multiplier applied to the class-r service rate, i.e. the
2867% fraction of total service capacity that class r receives in population
2868% state NVECPOP. All mirror the corresponding case of
2869% State.afterEventStation specialized to exponential (single-phase) service,
2870% where the phase population kir equals the class population nir.
2871% ======================================================================
2873function f = dpsshare(w, nvecpop, r)
2874% DPS: rate_r = mu_r * w_r*n_r / (w.n) on a single server.
2875den = w(:)' * nvecpop(:);
2879 f = w(r) * nvecpop(r) / den;
2883function f = gpsshare(w, nvecpop, r)
2884% GPS: rate_r = mu_r * w_r / (w.c), c_s = 1{n_s>0}, on a single server. The
2885% weight denominator counts active classes, not jobs, so a
class with a
2886% single job gets the same share as one with many.
2891cir = double(nvecpop(:) > 0);
2900function [act, niprio] = prioGroup(nvecpop, r, classprio)
2901% Population vector restricted to the priority group of class r, and its
2902% total. Empty classes never define the urgent group.
2903act = zeros(size(nvecpop));
2904same = (classprio(:) == classprio(r));
2905act(same) = nvecpop(same);
2909function tf = isUrgent(nvecpop, r, classprio)
2910% True when class r belongs to the most urgent non-empty priority group.
2911% LINE orders priorities with lower value = more urgent.
2912occupied = nvecpop(:) > 0;
2916 tf = (classprio(r) == min(classprio(occupied)));
2920function n = prioPop(nvecpop, r, c, classprio)
2921% Population that the lld factor is evaluated at: the full station
2922% population below capacity, the priority-group population above it.
2924if ni <= c || ~isUrgent(nvecpop, r, classprio)
2927 [~, n] = prioGroup(nvecpop, r, classprio);
2931function v = prioVec(nvecpop, r, c, classprio)
2932% Population vector that the cd factor is evaluated at for DPSPRIO/GPSPRIO:
2933% the priority-restricted vector above capacity, the full one below it.
2934% Note PSPRIO instead uses the full vector in both branches; that asymmetry
2935% is inherited from State.afterEventStation and is reproduced here.
2937if ni <= c || ~isUrgent(nvecpop, r, classprio)
2940 v = prioGroup(nvecpop, r, classprio);
2944function f = psprioshare(nvecpop, r, c, classprio)
2945% PSPRIO: PS below capacity; above it only the most urgent non-empty group
2946% shares the servers and everyone else is frozen.
2951 f = (nvecpop(r) / ni) * min(ni, c);
2952elseif ~isUrgent(nvecpop, r, classprio)
2955 [~, niprio] = prioGroup(nvecpop, r, classprio);
2956 f = (nvecpop(r) / niprio) * min(niprio, c);
2960function f = dpsprioshare(w, nvecpop, r, c, classprio)
2961% DPSPRIO: DPS below capacity, DPS restricted to the urgent group above it.
2966 f = dpsshare(w, nvecpop, r);
2967elseif ~isUrgent(nvecpop, r, classprio)
2970 f = dpsshare(w, prioGroup(nvecpop, r, classprio), r);
2974function f = gpsprioshare(w, nvecpop, r, c, classprio)
2975% GPSPRIO: GPS below capacity, GPS restricted to the urgent group above it.
2980 f = gpsshare(w, nvecpop, r);
2981elseif ~isUrgent(nvecpop, r, classprio)
2984 f = gpsshare(w, prioGroup(nvecpop, r, classprio), r);
2988function f = cdfac(cdbeta, nvecpop, r)
2989% Class-dependence factor for a class-r completion at a station with per-class
2990% population vector NVECPOP: the class-r component of the 1xR scaling vector
2991% returned by the handle CDBETA (see fes_beta_handle and State.cdclassfactor).
2992% Returns 1 when the station declares no class dependence.
2996 v = cdbeta(nvecpop(:)');
2997 f = v(min(r, numel(v)));
3002% ======================================================================
3003% Polling controller helpers
3004% ======================================================================
3006function ctrl = pollLandCtrl(pinf, q, mode, budget)
3007% Controller row [mode, pos, swk, ctr] the server lands in after
3008% State.pollingNext resolves (q, mode, budget): SERVING q with the visit budget,
3009% SWITCHING into q with the entry phase drawn from the switchover PH, or PARKED
3010% at the canonical q. Mirrors State.pollingLand specialized to the single-server
3011% polling station the NRM carries (exponential service, so no in-service phase).
3014 ctrl = [1, q, 0, budget];
3016 swk = drawFromDist(pinf.swpie{q});
3017 ctrl = [2, q, swk, 0];
3019 ctrl = [0, q, 0, 0]; % parked
3023function g = pollServeGate(ctrl, r)
3024% 1 when the polling controller CTRL
is serving
class r, else 0. This
is the
3025% single-server gate that turns a
class-r service departure on only
while the
3026% server attends
class r.
3027if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == r
3034function rate = pollSwRate(ctrl, pinf)
3035% Total leaving rate of the switchover phase the controller CTRL currently
3036% occupies, i.e. -D0(swk,swk) of the switchover PH into buffer pos; 0 unless the
3037% server
is walking (mode SWITCHING). The competition between advancing to
3038% another phase and absorbing
is resolved at firing time by the run loop.
3040if numel(ctrl) >= 3 && ctrl(1) == 2
3041 pos = ctrl(2); swk = ctrl(3);
3042 D0 = pinf.swD0{pos};
3043 rate = -D0(swk, swk);
3046% ======================================================================
3047% Stochastic Petri net (Place / Transition) via the Next-Reaction Method
3049% A stochastic Petri net maps onto the reaction network exactly: a Place holds
3050% a per-
class token count (a population slot of the state vector), and a timed
3051% Transition mode
is a reaction whose stoichiometry
column is the arc
3052% incidence -- input (enabling) arcs consume, output (firing) arcs produce.
3053% Enabling
is a propensity gate (all input places at or above their arc weight,
3054% every inhibitor place strictly below its threshold); a single-server mode
3055% then fires at its exponential rate, an infinite/k-server mode at that rate
3056% times its enabling degree. Each firing applies the mode
's stoichiometry once
3057% (consume the input weights, produce the output weights), which is the atomic
3058% GSPN firing shared by the exact CTMC (single server), JMT and GreatSPN.
3060% IMMEDIATE transitions fire in zero time and cannot be an exponential reaction.
3061% They are resolved by vanishing-marking elimination: after every timed firing
3062% (and once on the initial marking) every enabled immediate mode is fired,
3063% highest firing-priority first and, among equal priority, chosen in proportion
3064% to firing weight, until the marking is tangible (no immediate enabled). The
3065% timed race only resumes from tangible markings, so the immediate transitions
3066% never consume simulated time.
3068% Not handled here (rejected upstream by the SSA featset, never reached): a
3069% Transition whose firing distribution is non-exponential (phase-type or
3070% general). Representing an in-flight firing's phase needs per-mode phase state
3071% the reaction network does not carry; the exponential path covers the standard
3072% GSPN
case and every all-exponential validation net (spn_inhibiting,
3073% spn_twomodes, spn_fourmodes).
3074% ======================================================================
3075function [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap)
3076samples = options.samples;
3082% Build the reaction list. Timed modes become reactions (rx); immediate modes
3083% are collected separately (imm)
for the vanishing-marking collapse.
3084rx = spnEmptyRx(); rx(1) = [];
3085imm = spnEmptyRx(); imm(1) = [];
3086% consumers{ind,c}: indices into rx of timed modes that consume from place ind,
3087%
class c. Place throughput
is the aggregate firing rate of those modes (once
3088% per firing, unweighted -- the same depRates the CTMC accumulates from PRE
3089% events), so
this map drives the TN accumulator.
3090consumers = cell(I, R);
3092 if sn.nodetype(ind) ~= NodeType.Transition
3095 np = sn.nodeparam{ind};
3097 % Marking-dependent firing rates change the propensity with the marking;
3098 % SolverSSA does not yet apply the g(marking) multiplier (unlike CTMC and
3099 % LDES), so reject rather than silently simulate the nominal rate.
3100 if isfield(np,
'firingdep') && numel(np.firingdep) >= m && ~isempty(np.firingdep{m})
3101 line_error(mfilename, sprintf('Transition %s mode %d uses a marking-dependent firing rate (setFiringRateDependence), which SolverSSA does not support; use SolverCTMC or SolverLDES.', sn.nodenames{ind}, m));
3103 rec = spnBuildMode(sn, ind, m, phOff, NS);
3104 if np.timing(m) == TimingStrategy.IMMEDIATE
3105 imm(end+1) = rec; %#ok<AGROW>
3107 rx(end+1) = rec; %#ok<AGROW>
3109 for a = 1:numel(rec.enSlot)
3110 p = smap.node(rec.enSlot(a));
3111 c = smap.class(rec.enSlot(a));
3112 consumers{p, c}(end+1) = ridx;
3118% Source arrivals. A Source
is not a Transition, so its Poisson arrival
is not
3119% one of the transition modes above; it needs its own reaction or the fed Place
3120% stays empty and the net deadlocks. Add one arrival reaction per (Source node,
3121% open
class, routed Place-
class edge). Splitting a Poisson stream by the
3122% independent routing probabilities yields independent Poisson streams, so an
3123% edge of probability p carries rate lambda*p exactly. The reaction has an EMPTY
3124% enabling set (always enabled, state-independent propensity = lambda*p) and
3125% deposits +1 token into the routed Place slot. producers{node,
class} indexes
3126% these so the Source station reports its arrival rate as throughput, which
is
3127% the reference-station throughput of the open
class (matching JMT).
3128producers = cell(I, R);
3130 if sn.nodetype(ind) ~= NodeType.Source
3133 ist = sn.nodeToStation(ind);
3135 lambda = sn.rates(ist, r);
3136 if isnan(lambda) || lambda <= 0
3139 if sn.procid(ist, r) ~= ProcessType.EXP
3140 line_error(mfilename, sprintf(
'Source %s class %d has a non-exponential arrival, which the NRM SPN path does not support; use method=''serial'' or SolverJMT.', sn.nodenames{ind}, r));
3144 if sn.nodetype(jnd) ~= NodeType.Place
3148 p = sn.rtnodes((ind-1)*R + r, (jnd-1)*R + s);
3155 rec.mode = 0; % arrival, not a transition mode
3156 Svec = zeros(NS, 1);
3157 Svec(phOff(jnd, s) + 1) = Svec(phOff(jnd, s) + 1) + 1;
3159 rec.enSlot = []; rec.enW = [];
3160 rec.inhSlot = []; rec.inhThr = [];
3161 rec.baseRate = lambda * p; % Poisson thinning by the routing prob
3162 rec.nservers = 1; % constant propensity = baseRate
3163 rec.weight = 1; rec.prio = 1;
3164 rx(end+1) = rec; %#ok<AGROW>
3165 producers{ind, r}(end+1) = numel(rx);
3169 line_error(mfilename, sprintf(
'Source %s class %d does not route to any Place; the NRM SPN path needs a Source->Place arc.', sn.nodenames{ind}, r));
3176 line_error(mfilename,
'Stochastic Petri net has no timed reaction; nothing to simulate.');
3179% Initial marking: token counts per (place,
class), read straight off the
3180% initial state as the marginal population of each Place.
3181nvec0 = zeros(NS, 1);
3184 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3187 state_i = state{sn.nodeToStateful(ind)};
3188 [~, nir] = State.toMarginalAggr(sn, ind, state_i);
3191 line_error(mfilename,
'Infinite marking at a Place is not supported.');
3193 nvec0(phOff(ind, c) + 1) = nir(c);
3197maxImmSteps = 100000; % livelock guard
for the vanishing-marking collapse
3199% Finite-capacity Place DROP enforcement. A Place with a finite per-
class
3200% capacity (sn.classcap) or total capacity (sn.cap) loses any arriving token
3201% that would exceed it (JMT/CTMC loss semantics: an M/M/1/1 Place with cap 1
3202% holds mean 0.333 at rho=0.5, not the unbounded-M/M/1 value 1.0). Without this
3203% the deposit nvec+Svec accumulates tokens past capacity. Precompute the per-slot
3204% per-class caps, the per-place total caps, and each reaction's deposited slots
3205% so the clamp in the loop touches only what just grew. Mirrors the Python native
3206% _solver_ssa_nrm_spn.
3207pcapSlot = inf(NS, 1); % per-(place,class) slot cap
3208placeTotalCaps = cell(0, 2); % {totalCap, slotVec} per capped place
3210 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3213 ist = sn.nodeToStation(ind);
3214 slotsHere = zeros(1, R);
3216 slot = phOff(ind, c) + 1;
3217 slotsHere(c) = slot;
3218 if ist <= size(sn.classcap, 1)
3219 cc = sn.classcap(ist, c);
3221 pcapSlot(slot) = cc;
3226 if ist <= numel(sn.cap)
3230 placeTotalCaps(end+1, :) = {tcap, slotsHere}; %#ok<AGROW>
3233hasPlaceCaps = any(isfinite(pcapSlot)) || ~isempty(placeTotalCaps);
3234depSlots = cell(1, nR);
3236 depSlots{k} = find(rx(k).Svec > 0);
3239% ---------------------------------------------------------------------
3240% Next-Reaction Method run loop
3241% ---------------------------------------------------------------------
3242nvec = spnCollapse(nvec0, imm, maxImmSteps);
3244 nvec = applyPlaceCaps(nvec, (1:NS)
', pcapSlot, placeTotalCaps);
3248 Ak(k) = spnProp(nvec, rx(k));
3250Pk = -log(rand(1, nR));
3252tau = (Pk - Tk) ./ Ak;
3255QN = zeros(M, K); UN = zeros(M, K); RN = zeros(M, K);
3256TN = zeros(M, K); CN = zeros(1, K); XN = zeros(1, K);
3262 [dt, kfire] = min(tau);
3264 line_error(mfilename,
'Deadlock: no transition is enabled. Quitting nrm method.');
3266 totalTime = totalTime + dt;
3268 % Time-average accumulators over the sojourn dt. A Place
is an INF station,
3269 % so its utilization
is its mean token count (the SPN convention the CTMC
3270 % analyzer reports). Its throughput
is the summed firing rate of the modes
3271 % consuming from it.
3273 ind = sn.stationToNode(ist);
3275 tokens = classPop(nvec, phOff, nph, ind, c);
3276 QN(ist, c) = QN(ist, c) + tokens * dt;
3277 UN(ist, c) = UN(ist, c) + tokens * dt;
3279 cons = consumers{ind, c};
3280 for a = 1:numel(cons)
3281 depr = depr + Ak(cons(a));
3283 % A Source station has no consuming transition; its throughput
is the
3284 % aggregate arrival rate it injects (producers), so the reference
3285 % station reports the open-
class arrival rate as its throughput.
3286 prod = producers{ind, c};
3287 for a = 1:numel(prod)
3288 depr = depr + Ak(prod(a));
3290 TN(ist, c) = TN(ist, c) + depr * dt;
3294 % Fire the selected timed mode (single atomic firing), then collapse any
3295 % immediate transitions the
new marking enabled. A finite-capacity DROP Place
3296 % loses any token the firing pushed above its capacity, before the immediate
3297 % cascade sees the
new marking.
3298 nvec = nvec + rx(kfire).Svec;
3300 nvec = applyPlaceCaps(nvec, depSlots{kfire}, pcapSlot, placeTotalCaps);
3302 nvec = spnCollapse(nvec, imm, maxImmSteps);
3304 % Advance the Gibson & Bruck clocks with the pre-firing propensities, then
3305 % refresh every propensity from the
new marking. A firing plus its
3306 % immediate cascade can change any place, so every reaction
is refreshed
3307 % rather than a dependency subset -- the SPN reaction count
is small and
3308 %
this removes any dependency-graph blind spot.
3311 Ak(k) = spnProp(nvec, rx(k));
3313 Pk(kfire) = Pk(kfire) - log(rand);
3314 tau = (Pk - Tk) ./ Ak;
3318 if isfield(options,
'verbose') && options.verbose && mod(n, 1e3) == 0 && ~batchStartupOptionUsed
3319 line_printf(
'\b\b\b\b\b\b\b\b\b%9d', n);
3322if isfield(options,
'verbose') && options.verbose
3327 QN = QN / totalTime;
3328 UN = UN / totalTime;
3329 TN = TN / totalTime;
3332 XN(1, c) = TN(sn.refstat(c), c);
3335 RN(ist, c) = QN(ist, c) / TN(ist, c);
3339 CN(1, c) = NK(c) / XN(1, c);
3342QN(isnan(QN)) = 0; UN(isnan(UN)) = 0; RN(isnan(RN)) = 0;
3343XN(isnan(XN)) = 0; TN(isnan(TN)) = 0; CN(isnan(CN)) = 0;
3346function rec = spnEmptyRx()
3347% Prototype record
for a transition-mode reaction, so
struct arrays stay
3348% homogeneous (MATLAB
requires identical fields to concatenate).
3349rec =
struct(
'node', 0,
'mode', 0,
'Svec', [],
'enSlot', [],
'enW', [], ...
3350 'inhSlot', [],
'inhThr', [],
'baseRate', 0,
'nservers', 1, ...
3351 'weight', 1,
'prio', 1);
3354function rec = spnBuildMode(sn, ind, m, phOff, NS)
3355% Assemble the reaction record of transition IND mode M. Enabling/firing/
3356% inhibiting are (nnodes x nclasses) matrices; find() gives linear indices
3357% p+(c-1)*nnodes that decode to the (place, class) whose slot
is phOff(p,c)+1.
3358np = sn.nodeparam{ind};
3364enSlot = []; enW = [];
3368 [p, c] = ind2sub([sn.nnodes, R], li(t));
3369 slot = phOff(p, c) + 1;
3370 enSlot(end+1) = slot; %#ok<AGROW>
3371 enW(end+1) = en(li(t)); %#ok<AGROW>
3372 Svec(slot) = Svec(slot) - en(li(t));
3377 [p, c] = ind2sub([sn.nnodes, R], lf(t));
3378 slot = phOff(p, c) + 1;
3379 Svec(slot) = Svec(slot) + fir(lf(t));
3381inhSlot = []; inhThr = [];
3382inh = np.inhibiting{m};
3383lh = find(~isinf(inh));
3385 [p, c] = ind2sub([sn.nnodes, R], lh(t));
3386 inhSlot(end+1) = phOff(p, c) + 1; %#ok<AGROW>
3387 inhThr(end+1) = inh(lh(t)); %#ok<AGROW>
3390rec.enSlot = enSlot; rec.enW = enW;
3391rec.inhSlot = inhSlot; rec.inhThr = inhThr;
3392% Exponential firing rate: the single-phase completion rate sum(D1). A
3393% non-exponential firing distribution
is rejected by the featset and must not
3395if np.timing(m) ~= TimingStrategy.IMMEDIATE
3396 fK = np.firingphases(m);
3397 if isnan(fK) || fK ~= 1 || isempty(np.firingproc{m})
3398 line_error(mfilename, sprintf('Transition %s mode %d has non-exponential firing, which the NRM SPN path does not support.', sn.nodenames{ind}, m));
3400 D1 = np.firingproc{m}{2};
3401 rec.baseRate = sum(D1(:));
3403ns = np.nmodeservers(m);
3405 ns = GlobalConstants.MaxInt();
3408rec.weight = np.fireweight(m);
3409rec.prio = np.firingprio(m);
3412function d = spnEnDegree(nvec, rx)
3413% Enabling degree of a mode: the number of concurrent firings the marking
3414% supports, min over input arcs of floor(tokens/weight), zeroed by any active
3415% inhibitor arc. A mode with no input arc
is treated as single-degree.
3416for i = 1:numel(rx.inhSlot)
3417 if nvec(rx.inhSlot(i)) >= rx.inhThr(i)
3422if isempty(rx.enSlot)
3427for i = 1:numel(rx.enSlot)
3428 d = min(d, floor(nvec(rx.enSlot(i)) / rx.enW(i)));
3432function a = spnProp(nvec, rx)
3433% Propensity of a timed mode: the exponential rate times the effective number
3434% of servers, min(enabling degree, mode servers). Single-server modes therefore
3435% fire at their rate whenever enabled, infinite/k-server modes at the rate
3436% scaled by the enabling degree.
3437d = spnEnDegree(nvec, rx);
3438eff = min(d, rx.nservers);
3442 a = rx.baseRate * eff;
3446function nvec = applyPlaceCaps(nvec, deposited, pcapSlot, placeTotalCaps)
3447% Drop tokens a firing pushed above a Place per-class or total capacity. Only the
3448% just-deposited slots (Svec > 0) can overflow, so the clamp
is local. Mirrors the
3449% Python native _apply_place_caps.
3450for a = 1:numel(deposited)
3452 if nvec(j) > pcapSlot(j)
3453 nvec(j) = pcapSlot(j);
3456for p = 1:size(placeTotalCaps, 1)
3457 tcap = placeTotalCaps{p, 1};
3458 slots = placeTotalCaps{p, 2};
3459 excess = sum(nvec(slots)) - tcap;
3461 for a = 1:numel(deposited)
3466 if any(slots == j) && nvec(j) > 0
3467 d = min(excess, nvec(j));
3468 nvec(j) = nvec(j) - d;
3469 excess = excess - d;
3476function nvec = spnCollapse(nvec, imm, maxsteps)
3477% Vanishing-marking elimination. Fire enabled immediate transitions until the
3478% marking
is tangible: highest firing priority first, ties resolved in
3479% proportion to firing weight. Immediate firings take zero time and advance no
3480% clock, so the timed race only ever samples from tangible markings.
3487 for m = 1:numel(imm)
3488 if spnEnDegree(nvec, imm(m)) >= 1
3489 enabled(end+1) = m; %#ok<AGROW>
3495 prios = zeros(1, numel(enabled));
3496 for i = 1:numel(enabled)
3497 prios(i) = imm(enabled(i)).prio;
3499 top = enabled(prios == max(prios)); % larger firing priority = more urgent
3503 w = zeros(1, numel(top));
3504 for i = 1:numel(top)
3505 w(i) = imm(top(i)).weight;
3507 pick = top(spnWeightedDraw(w));
3509 nvec = nvec + imm(pick).Svec;
3512 line_error(mfilename,
'Immediate-transition livelock: the vanishing-marking collapse did not reach a tangible marking.');
3517function idx = spnWeightedDraw(w)
3518% Index drawn in proportion to the nonnegative weight vector W.
3525idx = find(c > rand, 1);