LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
solver_ssa_nrm.m
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)
3%
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.
10%
11% Outputs
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.
20%
21% See also SOLVER_SSA_NRM_SPACE, NEXT_REACTION_METHOD_DIRECT.
22
23% ---------------------------------------------------------------------
24% Parameters & shorthands
25% ---------------------------------------------------------------------
26samples = options.samples;
27R = sn.nclasses;
28I = sn.nnodes;
29M = sn.nstations;
30K = sn.nclasses;
31state = sn.state;
32
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.
40%
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.
46nph = ones(I, R);
47for ind = 1:I
48 if sn.isstation(ind)
49 ist = sn.nodeToStation(ind);
50 for r = 1:R
51 nph(ind,r) = max(1, sn.phasessz(ist,r));
52 end
53 end
54end
55phOff = zeros(I, R);
56NS = 0;
57for ind = 1:I
58 for r = 1:R
59 phOff(ind,r) = NS;
60 NS = NS + nph(ind,r);
61 end
62end
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);
71for ind = 1:I
72 for r = 1:R
73 for kk = 1:nph(ind,r)
74 slotNode(phOff(ind,r)+kk) = ind;
75 slotClass(phOff(ind,r)+kk) = r;
76 slotPhase(phOff(ind,r)+kk) = kk;
77 end
78 end
79end
80smap.node = slotNode; smap.class = slotClass; smap.phase = slotPhase;
81smap.phOff = phOff; smap.nph = nph; smap.R = R;
82
83% Buffered phase-type service. A non-preemptive buffered station (FCFS/LCFS/
84% SIRO/HOL/SEPT/LEPT) with phase-type service cannot be handled like the INF/PS
85% family: only the jobs ACTUALLY in service carry a phase, and the waiting jobs
86% in the buffer have not started service, so nvec (which counts the whole class
87% population) does not record the in-service phase composition. That composition
88% is tracked in a SEPARATE auxiliary structure svcph{ind}(r,k) = number of
89% class-r jobs in service in phase k, maintained on arrivals, departures and
90% phase transitions. nvec keeps its meaning (total population) unchanged, so
91% every other consumer (FCR, balking, JSQ, retrial, metrics) is untouched; only
92% the buffered-PH departure/phase reactions read svcph instead of nvec. Pure
93% exponential buffered classes (nph == 1) keep the original rate law and never
94% touch svcph. Preemptive LCFSPR and POLLING are excluded: LCFSPR would need the
95% preempted job's phase remembered in the buffer (preempt-resume), and a polling
96% controller carries only one in-service job; both stay on the serial engine.
97bufPHSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
98 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT];
99bufPHClass = false(I, R);
100for ind = 1:I
101 if sn.isstation(ind) && any(sn.sched(sn.nodeToStation(ind)) == bufPHSched)
102 for r = 1:R
103 if nph(ind,r) > 1
104 bufPHClass(ind,r) = true;
105 end
106 end
107 end
108end
109bufPHNode = any(bufPHClass, 2);
110maxnph = max(nph(:));
111smap.bufPHClass = bufPHClass; smap.bufPHNode = bufPHNode;
112
113% Cache nodes. A Cache is an immediate class-switch: a job arrives in a READ
114% class, reads an item drawn from pread, and leaves in the hit or miss class
115% depending on whether the item is currently cached, after which the replacement
116% policy updates the cache contents. The NRM models this as a state-dependent
117% class-switch reaction at the cache node (consume [cache,readClass], produce
118% [cache,hitClass] or [cache,missClass], chosen at firing by the cache access),
119% mirroring State.afterEventCache. The cache contents ride alongside the buffers
120% (buffers{cacheNode}) since no rate depends on them; the hit/miss draw and the
121% replacement update read and rewrite them at firing. A class r is a READ class
122% of cache ind iff its pread entry is a non-empty probability row.
123isCacheNode = false(I,1);
124isCacheReadClass = false(I,R);
125for ind = 1:I
126 if sn.nodetype(ind) == NodeType.Cache
127 isCacheNode(ind) = true;
128 np = sn.nodeparam{ind};
129 if isfield(np,'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
130 rci = np.retrievalClassIndices(:)';
131 else
132 rci = [];
133 end
134 for r = 1:R
135 % A normal read class has a hit class; a retrieval class (created by
136 % setRetrievalSystem) reads its own one-hot item to COMPLETE a miss
137 % and has hitclass == 0 but a miss class. Both take a cache-access
138 % reaction; the outcome class is resolved at firing.
139 if r <= numel(np.pread) && ~isempty(np.pread{r}) && all(~isnan(np.pread{r}(:))) ...
140 && ((r <= numel(np.hitclass) && np.hitclass(r) > 0) || any(rci == r))
141 isCacheReadClass(ind,r) = true;
142 end
143 end
144 end
145end
146smap.isCacheNode = isCacheNode;
147
148% Destination slot a BEGUN retrieval routes to (the fetch queue). When a miss
149% starts a retrieval the job must go to the retrieval queue, be served (the fetch
150% delay), and only THEN return to the cache to complete the miss. If it were left
151% at [cache,retrievalClass] the cache-access reaction would fire again and
152% complete the miss instantly, collapsing the fetch. So a begin lands the job at
153% the retrieval class's routed destination; only queue-returns occupy
154% [cache,retrievalClass] and trigger completion. Resolved once from rtnodes.
155cacheRetrDest = zeros(I, R);
156for ind = 1:I
157 if isCacheNode(ind)
158 np = sn.nodeparam{ind};
159 if isfield(np,'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
160 for rc = np.retrievalClassIndices(:)'
161 row = sn.rtnodes((ind-1)*R + rc, :);
162 dslot = find(row > 0, 1, 'first');
163 if ~isempty(dslot)
164 jnd = floor((dslot-1)/R) + 1; s = mod(dslot-1,R) + 1;
165 cacheRetrDest(ind, rc) = phOff(jnd, s) + 1;
166 end
167 end
168 end
169 end
170end
171
172% ---------------------------------------------------------------------
173% Stochastic Petri net path --------------------------------------------
174% ---------------------------------------------------------------------
175% A model with Transition nodes is a stochastic Petri net, not a queueing
176% network: its dynamics are firings of transition modes over a place marking,
177% not job departures routed by the rt matrix. The stoichiometry matrix of the
178% reaction network IS the net's incidence matrix, so the NRM is the natural
179% simulator, but the generic (node,class) departure grid below does not apply
180% (a firing produces to several places deterministically, never a routing
181% draw). Route Petri nets to the dedicated builder/runner, which shares the
182% Gibson & Bruck clocks but its own firing application and vanishing-marking
183% collapse for immediate transitions.
184if any(sn.nodetype == NodeType.Transition)
185 [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap);
186 lG = 0;
187 return
188end
189
190% ---------------------------------------------------------------------
191% Stoichiometry & reaction mapping (self‑loops included) ----------------
192% ---------------------------------------------------------------------
193S = zeros(0, NS); % will transpose at the end
194fromIdx = [];
195toIdx = [];
196fromIR = [];
197
198% this is currently M^2*R^2, it can be lowered to M*R decoupling the
199% routing
200% Departure reactions, one per (node, class, PHASE). A departure is the
201% absorption of the phase-type service process, so it fires at mu(k)*phi(k) and
202% the job re-enters its destination in an entry phase drawn from pie: the
203% destination draw therefore carries the product of the routing probability and
204% the entry-phase probability. The existing weighted-destination sampler takes
205% that product unchanged.
206k = 0;
207depPhase = []; % service phase each departure reaction consumes
208isBufSvcRx = false(0,1); % departure reaction of a buffered-PH class (reads svcph)
209isCacheRx = false(0,1); % cache-access reaction (read -> hit/miss at a cache node)
210cacheHitSlot = zeros(0,1); % nvec slot the hit-class job is produced into
211cacheMissSlot = zeros(0,1); % nvec slot the miss-class job is produced into
212for ind = 1:I
213 for r = 1:R
214 for kk = 1:nph(ind,r)
215 k = k + 1;
216 fromIR(k,:) = [ind, r];
217 depPhase(k,1) = kk;
218 isCacheRx(k,1) = false;
219 cacheHitSlot(k,1) = 0;
220 cacheMissSlot(k,1) = 0;
221 if isCacheReadClass(ind,r)
222 % Cache access: consume the read-class job at the cache; its
223 % production (hit or miss class, at the SAME cache node) and the
224 % contents update are resolved at firing by cacheAccess. No
225 % static routing: rtnodes has no out-edge for the read class.
226 fromIdx(k) = phOff(ind,r) + 1;
227 np = sn.nodeparam{ind};
228 Srow = zeros(1, NS);
229 Srow(fromIdx(k)) = -1;
230 S(k,:) = Srow;
231 probIR{k} = [];
232 toIdx{k} = [];
233 isCacheRx(k,1) = true;
234 isBufSvcRx(k,1) = false;
235 % The outcome class (hit/miss/retrieval) is resolved at firing, so
236 % these slots are informational only; a retrieval class has
237 % hitclass 0, so guard the lookup.
238 if r <= numel(np.hitclass) && np.hitclass(r) > 0
239 cacheHitSlot(k,1) = phOff(ind, np.hitclass(r)) + 1;
240 end
241 if r <= numel(np.missclass) && np.missclass(r) > 0
242 cacheMissSlot(k,1) = phOff(ind, np.missclass(r)) + 1;
243 end
244 continue
245 end
246 % At a buffered-PH source only the jobs in service carry a phase and
247 % the phase composition lives in svcph, not nvec; nvec holds the whole
248 % class population in its first phase slot. A departure therefore
249 % removes one job from that total slot regardless of which service
250 % phase completed -- the completing phase kk is carried in depPhase
251 % and consumed from svcph at firing.
252 if bufPHClass(ind,r)
253 fromIdx(k) = phOff(ind,r) + 1;
254 isBufSvcRx(k,1) = true;
255 else
256 fromIdx(k) = phOff(ind,r) + kk;
257 isBufSvcRx(k,1) = false;
258 end
259 probIR{k} = [];
260 toIdx{k} = [];
261 Srow = zeros(1, NS); % build stoichiometry row
262 if sn.isslc(r)
263 Srow(fromIdx(k)) = -Inf;
264 else
265 Srow(fromIdx(k)) = -1;
266 for jnd = 1:I
267 for s = 1:R
268 p = sn.rtnodes((ind-1)*R+r, (jnd-1)*R+s);
269 if p > 0
270 if bufPHClass(jnd,s)
271 % A job arriving at a buffered-PH destination lands
272 % in the total-population slot; whether it enters
273 % service (and in which entry phase) or waits is
274 % decided at firing from the server occupancy and
275 % pie, not by the routing draw. So the destination
276 % collapses to the single total slot with weight p.
277 dslot = phOff(jnd,s) + 1;
278 toIdx{k}(end+1) = dslot;
279 probIR{k}(end+1) = p;
280 Srow(dslot) = Srow(dslot) + p;
281 else
282 pentry = entryProbs(sn, jnd, s, nph(jnd,s));
283 for ke = 1:nph(jnd,s)
284 if pentry(ke) <= 0
285 continue
286 end
287 dslot = phOff(jnd,s) + ke;
288 toIdx{k}(end+1) = dslot;
289 probIR{k}(end+1) = p * pentry(ke);
290 Srow(dslot) = Srow(dslot) + p * pentry(ke);
291 end
292 end
293 end
294 end
295 end
296 end
297 S(k,:) = Srow;
298 end
299 end
300end
301nDepRx = k; % departure reactions occupy 1..nDepRx
302isBufSvcRx(end+1:k,1) = false;
303
304% Phase-transition reactions, one per (node, class, k -> k'). These move a job
305% between the phases of its own service process and so never leave the node;
306% D0's off-diagonal carries their rates (State.afterEventStation, EventType.PHASE).
307isPhaseRx = false(k,1);
308phaseFrom = zeros(k,1);
309phaseTo = zeros(k,1);
310phaseRate = zeros(k,1);
311for ind = 1:I
312 if ~sn.isstation(ind)
313 continue
314 end
315 ist = sn.nodeToStation(ind);
316 for r = 1:R
317 if nph(ind,r) <= 1 || isempty(sn.proc{ist}{r})
318 continue
319 end
320 D0 = sn.proc{ist}{r}{1};
321 for ka = 1:nph(ind,r)
322 for kb = 1:nph(ind,r)
323 if ka == kb || D0(ka,kb) <= 0
324 continue
325 end
326 k = k + 1;
327 fromIR(k,:) = [ind, r];
328 probIR{k} = [];
329 toIdx{k} = [];
330 Srow = zeros(1, NS);
331 if bufPHClass(ind,r)
332 % A buffered-PH class keeps its in-service phase counts in
333 % svcph, not in nvec: a phase transition moves a job between
334 % phases of the SAME in-service composition, so it leaves nvec
335 % (the class total) unchanged. The stoichiometry column is
336 % therefore all zeros; the move is applied to svcph at firing
337 % and, like a retry/switchover, its dependency set must be
338 % supplied through a forced refresh (D cannot derive it from S).
339 fromIdx(k) = phOff(ind,r) + 1;
340 else
341 fromIdx(k) = phOff(ind,r) + ka;
342 Srow(phOff(ind,r) + ka) = -1;
343 Srow(phOff(ind,r) + kb) = 1;
344 end
345 S(k,:) = Srow;
346 isPhaseRx(k,1) = true;
347 phaseFrom(k,1) = ka;
348 phaseTo(k,1) = kb;
349 phaseRate(k,1) = D0(ka,kb);
350 end
351 end
352 end
353end
354isPhaseRx(end+1:k,1) = false;
355depPhase(end+1:k,1) = 0;
356
357% Reneging: each waiting (queued, not-in-service) class-r job abandons at the
358% memoryless rate sn.impatienceMu, so the aggregate rate out of the state is
359% (waiting count)*mu and the job leaves the system (the passive half of the
360% sync is LOCAL in refreshSync). This is a reaction the (node,class) departure
361% grid above cannot express -- it consumes a job without producing one -- so it
362% is appended as an extra column whose stoichiometry is a bare -1 at the source
363% slot. A renege is not a departure and must not count towards throughput; the
364% TN accumulator reads the first reaction with a given source slot, which is
365% always the departure, so the appended columns stay out of it.
366nDep = k; % departure reactions occupy 1..nDep
367isRenegeRx = false(nDep,1);
368renegeMu = zeros(nDep,1);
369if isfield(sn,'impatienceClass') && ~isempty(sn.impatienceClass) ...
370 && any(sn.impatienceClass(:) == ImpatienceType.RENEGING)
371 for ist = 1:M
372 ind = sn.stationToNode(ist);
373 for r = 1:R
374 if sn.impatienceClass(ist,r) == ImpatienceType.RENEGING && sn.impatienceMu(ist,r) > 0
375 k = k + 1;
376 fromIR(k,:) = [ind, r];
377 fromIdx(k) = (ind-1)*R + r;
378 probIR{k} = [];
379 toIdx{k} = [];
380 Srow = zeros(1, I*R);
381 Srow((ind-1)*R + r) = -1; % job abandons and leaves the system
382 S(k,:) = Srow;
383 isRenegeRx(k,1) = true;
384 renegeMu(k,1) = sn.impatienceMu(ist,r);
385 end
386 end
387 end
388end
389% Retrial: an orbiting class-r job retries entry at the memoryless rate
390% sn.retrialMu and succeeds only when a server is free; otherwise the event is
391% a no-op and is simply not generated (State.afterEventStation, EventType.RETRY).
392% The orbit needs no new state: orbiting jobs are already counted in the
393% station population and held in the buffer, so orbit_r is exactly the buffer
394% occupancy the FCFS-family rate law already reads. A retry moves a job from
395% the orbit into service WITHOUT changing any population, so its stoichiometry
396% column is all zeros -- which is why its dependency set has to be supplied by
397% hand below: D is derived from S, and an all-zero column would otherwise leave
398% every rate at the node stale after a retry fires.
399isRetryRx = false(k,1);
400retryMu = zeros(k,1);
401retryNode = zeros(k,1);
402if isfield(sn,'retrialProc') && ~isempty(sn.retrialProc)
403 for ist = 1:M
404 if ~any(~cellfun(@isempty, sn.retrialProc(ist,:)))
405 continue
406 end
407 ind = sn.stationToNode(ist);
408 for r = 1:R
409 if sn.retrialMu(ist,r) > 0
410 k = k + 1;
411 fromIR(k,:) = [ind, r];
412 fromIdx(k) = (ind-1)*R + r;
413 probIR{k} = [];
414 toIdx{k} = [];
415 S(k,:) = zeros(1, I*R); % a retry moves no job between nodes
416 isRetryRx(k,1) = true;
417 retryMu(k,1) = sn.retrialMu(ist,r);
418 retryNode(k,1) = ind;
419 end
420 end
421 end
422end
423
424% Polling switchover reactions. A polling server cycles through the buffers it
425% serves, carrying a controller [mode, pos, swphase, ctr] in the auxiliary
426% buffer of its node (mode 0 parked, 1 serving pos, 2 switching towards pos).
427% A service departure fires only while the controller serves that class (gated
428% in the propensity below); when a visit ends the server walks the cyclic order
429% (State.pollingNext folds every immediate switchover) and, on meeting a timed
430% switchover, dwells in mode 2. That dwell is a genuine timed event with no job
431% movement, so it is appended here as one reaction per polling node with a timed
432% switchover, exactly as a retry is: an all-zero stoichiometry column whose
433% propensity reads the controller and whose firing samples the switchover PH.
434% Only exponential service is expanded at a polling station (phaseNrmOK gates PH
435% service there); the switchover itself may be phase-type, its phases carried in
436% the controller rather than in nvec.
437poll = struct('on', false);
438poll.isPoll = false(1, I);
439poll.pinfo = cell(I, 1);
440poll.swRx = zeros(1, I); % switchover reaction index of each polling node, 0 if none
441for ind = 1:I
442 if sn.isstation(ind) && sn.sched(sn.nodeToStation(ind)) == SchedStrategy.POLLING
443 poll.pinfo{ind} = State.pollingInfo(sn, ind);
444 poll.isPoll(ind) = true;
445 poll.on = true;
446 % The NRM tracks only the controller of a polling station, not the
447 % service phase of the single job in service, so phase-type service at a
448 % polling station is not expanded here. Reject it rather than spread the
449 % class over phases and gate each phase reaction on the same class (which
450 % would serve several fictitious phase-jobs at once). Switchover may be
451 % phase-type: its phase is carried in the controller.
452 for r = 1:R
453 if nph(ind,r) > 1
454 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));
455 end
456 end
457 end
458end
459isPollSwRx = false(k, 1);
460pollSwNode = zeros(k, 1);
461if poll.on
462 for ind = 1:I
463 pinf = poll.pinfo{ind};
464 if isempty(pinf) || ~any(pinf.hasSw)
465 continue % no timed switchover: the server never dwells in a walk
466 end
467 k = k + 1;
468 fromIR(k,:) = [ind, 1]; % class field is a sentinel; never read as a class here
469 fromIdx(k) = (ind-1)*R + 1; % unused slot: a switchover consumes no job
470 probIR{k} = [];
471 toIdx{k} = [];
472 S(k,:) = zeros(1, I*R); % a switchover moves no job between nodes
473 isPollSwRx(k,1) = true;
474 pollSwNode(k,1) = ind;
475 poll.swRx(ind) = k;
476 end
477end
478
479% Pad every per-reaction marker to the final reaction count, so the reaction
480% loops below index them safely regardless of which extra-reaction families
481% (renege, retry, switchover) are present.
482isRenegeRx(end+1:k,1) = false;
483isRetryRx(end+1:k,1) = false;
484isPhaseRx(end+1:k,1) = false;
485depPhase(end+1:k,1) = 0;
486isPollSwRx(end+1:k,1) = false;
487pollSwNode(end+1:k,1) = 0;
488isBufSvcRx(end+1:k,1) = false;
489isCacheRx(end+1:k,1) = false;
490cacheHitSlot(end+1:k,1) = 0;
491cacheMissSlot(end+1:k,1) = 0;
492renegeMu(end+1:k,1) = 0;
493retryMu(end+1:k,1) = 0;
494retryNode(end+1:k,1) = 0;
495
496S = S.'; % states × reactions
497
498% ---------------------------------------------------------------------
499% Initial state vector --------------------------------------------------
500% ---------------------------------------------------------------------
501nvec0 = zeros(NS,1); % initial state (per node, class and phase)
502% Non-preemptive policies that hold waiting jobs in a buffer. They share the
503% rate law (a class-r completion fires at mu_r times the class-r jobs actually
504% in service) and differ only in which waiting job is promoted on a departure;
505% see pickFromBuffer.
506bufferedSched = [SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
507 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
508 SchedStrategy.LCFSPR, SchedStrategy.PAS];
509% Preemptive policies: an arrival at a fully busy station takes a server and
510% pushes the incumbent it displaced back into the buffer, rather than queueing
511% itself (State.afterEventStation, the FCFSPR/LCFSPR arrival group). The rate
512% law is unchanged -- it still counts the jobs actually in service -- so no
513% extra state is needed: in-service is population minus buffer occupancy, and
514% that automatically names the new arrival as the one being served. With
515% exponential service preempt-resume needs no stored phase, because a resumed
516% job has the same memoryless residual as a fresh one. LCFSPI is deliberately
517% absent: SolverSSA.getFeatureSet does not advertise it (nor does SolverCTMC),
518% so the NRM must not claim it either.
519preemptiveSched = SchedStrategy.LCFSPR;
520% Order-independent / pass-and-swap stations keep the FULL ordered job list,
521% not just the waiting jobs: there is no server/buffer split at all, and the
522% rate is a function mu(c) of the whole list (State.afterEventStationPAS). So
523% these carry a different buffer invariant -- numel(buf) == total, rather than
524% max(0, total - mi) -- and the list runs OLDEST-FIRST (c(1) is the oldest),
525% the reverse of every other buffered policy here.
526% sn.sched carries PAS for both PAS and OI stations: OI is canonicalized to
527% pass-and-swap with an all-zero swap graph (see MNetwork.refreshLocalVars), so
528% reading the graph covers both and OI needs no separate case.
529listSched = SchedStrategy.PAS;
530% Of those, the policies whose state keeps the buffer as per-class counts
531% rather than as an ordered list of class ids (State.fromMarginalAndRunning).
532countBufferedSched = [SchedStrategy.SIRO, SchedStrategy.SEPT, SchedStrategy.LEPT];
533buffers0 = cell(I,1); % per-node ordered buffer of waiting job classes (FCFS/LCFS)
534for ind=1:I
535 buffers0{ind} = [];
536end
537% A cache node has no queueing buffer; its buffers slot instead carries the cache
538% CONTENTS (the item held in each of the totalCacheCapacity slots, ordered by
539% list as State.afterEventCache lays them out). Any valid ordered placement is a
540% correct warm start since the chain is ergodic, so slot i starts holding item i.
541for ind=1:I
542 if isCacheNode(ind)
543 np = sn.nodeparam{ind};
544 if isfield(np,'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
545 tcc = np.totalCacheCapacity;
546 else
547 tcc = sum(np.itemcap);
548 end
549 % With a retrieval system the contents are followed by a per-item
550 % occupancy bitmap (State.spaceCache): column tcc+i is 1 iff item i is
551 % currently being retrieved. Start with an empty bitmap.
552 if isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
553 && any(np.retrievalSystemCapacity > 0)
554 buffers0{ind} = [1:tcc, zeros(1, np.nitems)];
555 else
556 buffers0{ind} = 1:tcc;
557 end
558 end
559end
560% In-service phase multiset of each buffered-PH node: svcph0{ind}(r,k) counts the
561% class-r jobs in service in phase k. Empty for every other node. Populated below
562% once the waiting buffer of each buffered-PH node is known (in-service = class
563% total minus waiting), so it is filled after the buffer loop.
564svcph0 = cell(I,1);
565for ind=1:I
566 if bufPHNode(ind)
567 svcph0{ind} = zeros(R, maxnph);
568 else
569 svcph0{ind} = [];
570 end
571end
572for ind=1:I
573 if sn.isstateful(ind)
574 state_i = state{sn.nodeToStateful(ind)};
575 [~,nir] = State.toMarginalAggr(sn, ind, state_i);
576 for r = 1:R
577 if isinf(nir(r))
578 if sn.nodetype(ind) == NodeType.Source
579 nir(r) = 1;
580 else
581 line_error(mfilename, 'Infinite population error.');
582 end
583 end
584 % Spread the class population across its phases. The marginal the
585 % initial state carries is per class, not per phase, so the entry
586 % distribution pie is the natural allocation: it is the phase a job
587 % starts service in. For a single-phase class this puts everything
588 % in slot 1, reproducing the old flat layout exactly. A buffered-PH
589 % class keeps its whole population in slot 1 too -- nvec is the class
590 % total there and the in-service phase composition lives in svcph0
591 % (built below), so the phase slots 2..nph stay empty in nvec.
592 if nph(ind,r) <= 1 || bufPHClass(ind,r)
593 nvec0(phOff(ind,r) + 1,1) = nir(r);
594 else
595 pe = entryProbs(sn, ind, r, nph(ind,r));
596 left = nir(r);
597 for ke = 1:nph(ind,r)
598 if ke == nph(ind,r)
599 take = left;
600 else
601 take = min(left, round(nir(r) * pe(ke)));
602 end
603 nvec0(phOff(ind,r) + ke,1) = take;
604 left = left - take;
605 end
606 end
607 end
608
609 % Populate buffers for buffered nodes from the raw state vector
610 % (only stations have buffered scheduling; skip non-station
611 % stateful nodes such as RROBIN dispatchers/Routers and Caches)
612 ist = sn.nodeToStation(ind);
613 if ist >= 1 && any(sn.sched(ist) == bufferedSched)
614 sumK = sum(sn.phasessz(ist,:));
615 sumNvars = sum(sn.nvars(ind,:));
616 bufCols = size(state_i,2) - sumK - sumNvars;
617 if any(sn.sched(ist) == listSched)
618 % PAS/OI stores the full ordered list left-aligned in the first
619 % cap(ist) columns, c(1) oldest, zero-padded on the right --
620 % already the order the NRM needs, so it is copied verbatim
621 % rather than reversed.
622 % Its width is nCols - nvars, NOT the shared bufCols: a PAS
623 % station has no server/phase block at all (there is no
624 % server/buffer split), yet phasessz still floors to 1 per class
625 % as for any other station, so subtracting sumK here would drop
626 % the last sum(phasessz) entries of the list. Both PAS
627 % authorities, State.afterEventStationPAS and the PAS branch of
628 % State.toMarginal, read W = size(inspace,2) - V.
629 pasCols = size(state_i,2) - sumNvars;
630 for pos = 1:pasCols
631 classId = state_i(1,pos);
632 if classId >= 1 && classId <= R
633 buffers0{ind}(end+1) = classId;
634 end
635 end
636 elseif any(sn.sched(ist) == countBufferedSched)
637 % SIRO/SEPT/LEPT keep an UN-ordered buffer: the first R columns
638 % hold the per-class counts of waiting jobs, not class ids (see
639 % State.fromMarginalAndRunning). Expand them into the NRM's
640 % ordered list; the order within it is immaterial for these
641 % disciplines, which select by class and never by position.
642 for r = 1:min(R, bufCols)
643 buffers0{ind}(end+1:end+state_i(1,r)) = r;
644 end
645 else
646 % FCFS/HOL/LCFS keep an ordered list of class ids
647 for pos = 1:bufCols
648 classId = state_i(1,pos);
649 if classId >= 1 && classId <= R
650 buffers0{ind}(end+1) = classId; % addLast
651 end
652 % classId == 0 means empty position, skip
653 end
654 end
655 end
656
657 % Seed the in-service phase multiset of a buffered-PH node. The jobs in
658 % service are the class total minus the ones waiting in the buffer just
659 % built; their starting phases are drawn from the entry distribution pie,
660 % the same allocation the INF/PS init uses. Only in-service jobs get a
661 % phase -- waiting jobs have not started service and carry none.
662 if bufPHNode(ind)
663 for r = 1:R
664 waiting_r = sum(buffers0{ind} == r);
665 insvc_r = max(0, nir(r) - waiting_r);
666 if nph(ind,r) <= 1
667 svcph0{ind}(r,1) = insvc_r;
668 else
669 pe = entryProbs(sn, ind, r, nph(ind,r));
670 left = insvc_r;
671 for ke = 1:nph(ind,r)
672 if ke == nph(ind,r)
673 take = left;
674 else
675 take = min(left, round(insvc_r * pe(ke)));
676 end
677 svcph0{ind}(r,ke) = take;
678 left = left - take;
679 end
680 end
681 end
682 end
683 end
684end
685
686mi = zeros(I,1);
687rates = zeros(I,R);
688for ind=1:I
689 if sn.isstation(ind)
690 for r=1:R
691 ist = sn.nodeToStation(ind);
692 muir = sn.rates(ist,r);
693 if ~isnan(muir)
694 rates(ind,r) = muir;
695 end
696 mi(ind,1) = sn.nservers(ist);
697 end
698 else
699 for r=1:R
700 rates(ind,r) = GlobalConstants.Immediate;
701 mi(ind,1) = GlobalConstants.MaxInt;
702 end
703 end
704 mi(isinf(mi)) = GlobalConstants.MaxInt;
705end
706
707% Limited load-dependent scaling lld(ist, ntot): a work-conserving factor that
708% multiplies the aggregate service rate at total station population ntot (as in
709% State.afterEventStation). Default (all ones) for stations without load
710% dependence, so it is inert for plain single-/multi-server queues.
711if isempty(sn.lldscaling)
712 lldMat = []; lldlimit = 0;
713else
714 lldMat = sn.lldscaling; lldlimit = size(lldMat,2);
715end
716
717% Class-dependent scaling cdscaling{ist}: a handle mapping the per-class
718% station population vector n to the 1xR vector of rate scalings beta_r(n)
719% (as in State.afterEventStation, evaluated per firing on the current state).
720if isempty(sn.cdscaling)
721 cdCell = {};
722else
723 cdCell = sn.cdscaling;
724end
725
726% Scheduling policies whose rate law reads per-class weights from
727% sn.schedparam. These are single-server only, as in State.afterEventStation.
728weightedSched = [SchedStrategy.DPS, SchedStrategy.GPS, ...
729 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO];
730
731% Finite capacity regions (DROP rule). A region constrains an aggregate of the
732% per-class populations of its member stations, which is a linear function of
733% the NRM state vector, so admission is a multiplicative 0/1 gate on the
734% routing draw. The DROP rule censors the refused transition, and censoring an
735% exponential transition is exactly what zeroing its share of the propensity
736% does. WAITQ instead parks refused jobs in a per-region FIFO, which is extra
737% state the reaction network does not carry, so those models are routed to the
738% serial engine by SOLVER_SSA_ANALYZER and never reach here.
739fcr = fcrPrecompute(sn);
740
741% Balking. An arrival that balks is lost: it has left its source but never
742% joins the destination, so the departure rate is unchanged and only the
743% arrival outcome differs (State.afterEventStation scales the admitted
744% branches by 1-balkProb and adds a balked branch of probability balkProb that
745% leaves the destination state untouched). Only the QUEUE_LENGTH strategy is a
746% pure function of the state vector; EXPECTED_WAIT / COMBINED depend on the
747% mean wait and are rejected by the analyzers.
748balk = balkPrecompute(sn);
749
750% G-network signals. A signal class never joins the station it reaches: it acts
751% on the jobs already there and is annihilated (State.afterEventStationSignal).
752% That makes it an arrival-side effect exactly like balking, so the departure
753% rate is unchanged and only the arrival outcome differs. The reference
754% enumerates every victim subset with its probability because it builds a
755% generator; a simulator instead draws the batch size and the victims, which is
756% equivalent and avoids the enumeration.
757sig = signalPrecompute(sn);
758
759% Round-robin routing. The pointer that RROBIN/WRROBIN walk is a per-(node,
760% class) local variable, not a population, and no rate depends on it: it only
761% decides where a departure goes. In a generator that makes it a genuine extra
762% state dimension, but a simulator can carry it as auxiliary state alongside
763% the buffers, which is what happens here. State.afterEventRouter advances the
764% pointer on the departure and the routing closure then reads state_AFTER, so
765% the destination used is the one the pointer lands on -- advance first, then
766% select.
767rr = rrPrecompute(sn, state);
768
769% Propensity function ---------------------------------------------------
770epstol = GlobalConstants.Zero;
771a = {};
772classprio = sn.classprio(:)'; % lower value = higher priority in LINE
773% Rate of the service-process event each reaction carries: the absorption
774% mu(k)*phi(k) for a departure, the off-diagonal D0(k,k') for a phase change.
775% For a single-phase class this is just the exponential rate, so an exponential
776% model sees exactly the rates it saw before.
777rateOf = zeros(length(fromIdx),1);
778for j = 1:length(fromIdx)
779 ind = fromIR(j,1); r = fromIR(j,2);
780 if isPhaseRx(j)
781 rateOf(j) = phaseRate(j);
782 elseif sn.isstation(ind)
783 ist = sn.nodeToStation(ind);
784 kk = depPhase(j);
785 if nph(ind,r) > 1 && ~isempty(sn.proc{ist}{r})
786 rateOf(j) = sn.mu{ist}{r}(kk) * sn.phi{ist}{r}(kk);
787 else
788 rateOf(j) = rates(ind, r);
789 end
790 else
791 rateOf(j) = rates(ind, r);
792 end
793end
794
795for j=1:length(fromIdx)
796 ind = fromIR(j,1);
797 base = (ind-1)*R + 1; % first per-class state slot of this node
798 ldrow = []; % load-dependent scaling row of the station
799 cdbeta = []; % class-dependence handle of the station
800 wrow = []; % normalized DPS/GPS scheduling weights
801 if sn.isstation(ind)
802 istj = sn.nodeToStation(ind);
803 if istj >= 1 && ~isempty(lldMat), ldrow = lldMat(istj, :); end
804 if istj >= 1 && istj <= numel(cdCell) && ~isempty(cdCell{istj})
805 cdbeta = cdCell{istj};
806 end
807 if istj >= 1 && any(sn.sched(istj) == weightedSched)
808 wrow = sn.schedparam(istj, 1:R);
809 if sum(wrow) <= 0
810 line_error(mfilename, sprintf('Station %d has %s scheduling with non-positive total weight.', istj, SchedStrategy.toText(sn.sched(istj))));
811 end
812 wrow = wrow / sum(wrow);
813 % State.afterEventStation rejects multi-server DPS/GPS, so the
814 % rate law below is only defined for a single server. Fail here
815 % rather than silently simulate a different station.
816 if mi(ind) > 1
817 line_error(mfilename, sprintf('Multi-server %s stations are not supported yet.', SchedStrategy.toText(sn.sched(istj))));
818 end
819 end
820 end
821 % Buffered phase-type service. Only the jobs in service carry a phase, and
822 % their per-phase counts live in svc{ind}(r,k), not in nvec. Both the
823 % departure (absorption of phase kk) and the internal phase transition
824 % (kk -> kb) therefore fire at rate rateOf(j) times the number of class-r
825 % jobs currently in service in the source phase kk -- exactly the INF-family
826 % law rateOf*kir, but with kir read from the in-service multiset svc rather
827 % than from nvec (whose class total also counts the waiting jobs). The
828 % load-/class-dependent factors still read the total population, as for the
829 % exponential buffered law. A single-phase (exponential) buffered class is
830 % NOT bufPHClass and keeps its original rate law below.
831 if sn.isstation(ind) && bufPHClass(ind, fromIR(j,2))
832 rr_ph = fromIR(j,2);
833 if isPhaseRx(j)
834 kk_ph = phaseFrom(j);
835 else
836 kk_ph = depPhase(j);
837 end
838 a{j} = @(X, bufs, svc) rateOf(j) * svc{ind}(rr_ph, kk_ph) ...
839 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
840 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
841 continue
842 end
843 if sn.isstation(ind)
844 switch sn.sched(sn.nodeToStation(ind))
845 case SchedStrategy.EXT
846 % A Source fires at a constant arrival rate. It has no service
847 % phases (nph == 1 there, enforced by phaseNrmOK), so the
848 % kir/nir share must NOT be applied: the Source's fictitious
849 % token would drive kirFrac to 0 and silence the Source, which
850 % deadlocks every open model. rateOf(j) is that constant rate.
851 a{j} = @(X, bufs, svc) rateOf(j);
852 case SchedStrategy.INF
853 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)) ...
854 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
855 case {SchedStrategy.PS, SchedStrategy.LPS}
856 % LPS shares the PS rate law in State.afterEventStation: the
857 % sharing limit is the server count, so min(ni,c) covers both.
858 if R == 1 % single class
859 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))) ...
860 * lldfac(ldrow, classPop(X, phOff, nph, ind, fromIR(j,2)), lldlimit) ...
861 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
862 else
863 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)) ./ ...
864 (epstol+sum( classCounts(X, phOff, nph, ind, R) ) )) * ...
865 min( mi(fromIR(j,1)), (epstol+sum( classCounts(X, phOff, nph, ind, R) )) ) ...
866 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
867 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
868 end
869 case SchedStrategy.DPS
870 % Discriminatory PS: class r receives a share w_r*n_r/(w.n) of
871 % the single server (State.afterEventStation, case DPS).
872 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
873 * dpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
874 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
875 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
876 case SchedStrategy.GPS
877 % Generalized PS: share w_r/(w.c) where c_s = 1{n_s>0}, i.e.
878 % weights are split across the *active* classes only.
879 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
880 * gpsshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2)) ...
881 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
882 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
883 case SchedStrategy.PSPRIO
884 % Below capacity every job is served, so priority is inert;
885 % above it, only the most urgent non-empty group shares the
886 % servers. lld uses the priority-group population, cd the full
887 % one, mirroring State.afterEventStation exactly.
888 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
889 * psprioshare(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
890 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
891 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
892 case SchedStrategy.DPSPRIO
893 % As DPS, but above capacity restricted to the most urgent
894 % non-empty group; cd is evaluated on the priority-restricted
895 % population (State.afterEventStation, case DPSPRIO).
896 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
897 * dpsprioshare(wrow, 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, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
900 case SchedStrategy.GPSPRIO
901 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) ...
902 * gpsprioshare(wrow, classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio) ...
903 * lldfac(ldrow, prioPop(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), lldlimit) ...
904 * cdfac(cdbeta, prioVec(classCounts(X, phOff, nph, ind, R), fromIR(j,2), mi(fromIR(j,1)), classprio), fromIR(j,2));
905 case SchedStrategy.PAS
906 % Position p of the ordered list is served at
907 % Delta_mu(c1..cp) = mu(c1..cp) - mu(c1..c_{p-1}), and
908 % pass-and-swap decides which class that completion ejects. The
909 % class-r departure rate is therefore the total Delta_mu over
910 % the positions whose pass-and-swap ejects a class-r job, which
911 % is exactly what afterEventStationPAS enumerates.
912 muFun = sn.nodeparam{ind}.svcRateFun;
913 if isempty(muFun)
914 line_error(mfilename, 'PAS/OI station has no service rate function mu(c); set it via setService(@(c) ...).');
915 end
916 swapG = sn.nodeparam{ind}.swapGraph;
917 a{j} = @(X, bufs, svc) oirate(muFun, swapG, bufs{ind}, fromIR(j,2));
918 case SchedStrategy.POLLING
919 % A polling station has a single server that serves exactly one
920 % job, of the class its controller currently attends. The
921 % departure of class r therefore fires only while the controller
922 % is SERVING class r, at the plain service rate of the one job in
923 % service -- never scaled by the class population, since the other
924 % class-r jobs wait in the buffer for the server to come back to
925 % them. The controller rides in bufs{ind} = [mode, pos, swk, ctr];
926 % pollServeGate returns 1 exactly when mode==SERVING and pos==r.
927 a{j} = @(X, bufs, svc) rateOf(j) * pollServeGate(bufs{fromIR(j,1)}, fromIR(j,2)) ...
928 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
929 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
930 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
931 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
932 SchedStrategy.LCFSPR}
933 % Invariant: numel(bufs{ind}) == max(0, total - mi(ind)).
934 % Rate is proportional to the jobs actually being served, i.e.
935 % the class-r population minus the class-r jobs waiting in buffer,
936 % scaled by the load-dependent factor at the total population.
937 % Every non-preemptive buffered policy shares this law: with
938 % exponential service the departure rate depends only on the
939 % in-service composition, never on the buffer order, which enters
940 % solely through which job is promoted next (pickFromBuffer).
941 a{j} = @(X, bufs, svc) rateOf(j) * kirFrac(X, fromIdx(j), phOff, nph, fromIR(j,1), fromIR(j,2)) * ...
942 max(0, classPop(X, phOff, nph, ind, fromIR(j,2)) - sum(bufs{fromIR(j,1)} == fromIR(j,2))) ...
943 * lldfac(ldrow, sum(classCounts(X, phOff, nph, ind, R)), lldlimit) ...
944 * cdfac(cdbeta, classCounts(X, phOff, nph, ind, R), fromIR(j,2));
945 end
946 else
947 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)));
948 end
949end
950
951% Reneging propensities ------------------------------------------------
952% Only the jobs actually waiting can abandon, so the rate is the class-r
953% population minus the class-r jobs in service, exactly the buffer occupancy
954% the FCFS-family rate law already relies on.
955for j = (nDep+1):length(fromIdx)
956 ind = fromIR(j,1);
957 a{j} = @(X, bufs, svc) renegeMu(j) * sum(bufs{ind} == fromIR(j,2));
958end
959
960% Retrial propensities --------------------------------------------------
961% Only jobs actually in orbit retry, and only a free server admits them.
962for j = 1:length(fromIdx)
963 if ~isRetryRx(j)
964 continue
965 end
966 ind = fromIR(j,1);
967 a{j} = @(X, bufs, svc) retryMu(j) * sum(bufs{ind} == fromIR(j,2)) ...
968 * double(sum(X(((ind-1)*R+1):(ind*R))) - numel(bufs{ind}) < mi(ind));
969end
970
971% Polling switchover propensities ---------------------------------------
972% The reneging loop above overwrote these appended columns with a zero-rate
973% renege closure; restore the switchover law here. A switchover fires only
974% while the controller is walking (mode SWITCHING, bufs{ind}(1)==2), at the
975% total leaving rate -D0(swk,swk) of the current phase swk of the switchover
976% PH into buffer pos. The competition between advancing to another phase and
977% absorbing (arriving at pos) is resolved at firing time, exactly as a routed
978% departure resolves its destination after it fires.
979for j = 1:length(fromIdx)
980 if ~isPollSwRx(j)
981 continue
982 end
983 ind = pollSwNode(j);
984 pinf = poll.pinfo{ind};
985 a{j} = @(X, bufs, svc) pollSwRate(bufs{ind}, pinf);
986end
987
988% Finite capacity regions do NOT gate the propensities ------------------
989% Under the DROP rule the refused job is DESTROYED, not held back: the
990% departure fires at its full rate and the job simply never reaches the
991% destination. Scaling the propensity by the admitted share instead censors
992% the transition, which keeps the job at its SOURCE -- a different model, and
993% one that diverges as soon as the source is a real queue rather than a Source
994% node (an interior region makes the upstream queue grow without bound while
995% nothing is ever lost). The two coincide only at a Source, whose population is
996% fictitious, which is why every FCR fixture placed a region on a Source-fed
997% station and never saw the difference. Refusal is applied at firing time
998% instead, on the drawn destination, exactly as a balk is (see balkDraw below):
999% the source releases the job and the destination never receives it. This
1000% matches SOLVER_SSA (which marks the refusal and suppresses only the passive
1001% application) and the exact CTMC.
1002
1003% Propensity functions dependencies -----------------------------------
1004D = cell(1,size(S,2));
1005for k=1:size(D,2)
1006 J = find(S(:,k))'; % set of state variables affected by reaction k
1007 vecd = [];
1008 for j=1:length(J)
1009 % Decode through the slot map, never arithmetically: with phase
1010 % expansion a state index is a (node,class,PHASE) slot, so
1011 % mod(pos-1,R)+1 names the wrong node as soon as any class has more
1012 % than one phase. Collect EVERY slot of each affected node, because a
1013 % rate law reads its node's whole class-count vector (classCounts sums
1014 % each class over its phases) and the per-phase share reads the sibling
1015 % phases of its own class.
1016 ind = slotNode(J(j));
1017 % NB: not `rr` -- that name holds the round-robin controller in this
1018 % scope, and shadowing it here would pass an integer to the run loop.
1019 for rcls = 1:R
1020 vecd(end+1:end+nph(ind,rcls)) = (phOff(ind,rcls)+1):(phOff(ind,rcls)+nph(ind,rcls));
1021 end
1022 end
1023 % vecd now contains all state variables affected by the firing of
1024 % reaction k. We now find the propensity functions that depend
1025 % on those variables
1026 if isRetryRx(k)
1027 % A retry has an all-zero stoichiometry column, so the generic
1028 % derivation below would return an empty dependency set and leave every
1029 % rate at the node stale. A retry does change the in-service
1030 % composition, hence every reaction whose source is this node.
1031 base_k = (fromIR(k,1)-1)*R;
1032 D{k} = find(ismember(fromIdx, (base_k+1):(base_k+R)));
1033 continue
1034 end
1035 if ~isempty(vecd)
1036 vecd = unique(vecd);
1037 vecs = [];
1038 for j=1:length(vecd)
1039 % No `fcr.on` widening here: regions no longer gate the
1040 % propensities (see the FCR note above), so a departure's rate
1041 % depends only on its own station's populations, as in the
1042 % unregulated case. Admission is resolved at firing time on the
1043 % drawn destination and changes no rate.
1044 vecs = [vecs,find(S(vecd(j),:)<0)];
1045 end
1046 D{k} = unique(vecs);
1047 else
1048 D{k} = [];
1049 end
1050end
1051
1052% A retry has an all-zero stoichiometry column, so the derivation above -- which
1053% collects reactions by the sign of their S entries -- can never place it in any
1054% OTHER reaction's dependency set. It still has to be refreshed whenever the
1055% node it serves changes, because its rate reads both the orbit occupancy and
1056% whether a server is free: without this, a retry blocked at a busy server keeps
1057% its zero rate after the server frees, the orbit never drains and the station
1058% grows without bound.
1059for j = find(isRetryRx(:)')
1060 indj = fromIR(j,1);
1061 slots = ((indj-1)*R + 1):(indj*R);
1062 for k = 1:size(S,2)
1063 if any(S(slots, k) ~= 0) || fromIR(k,1) == indj
1064 if ~ismember(j, D{k})
1065 D{k}(end+1) = j;
1066 end
1067 end
1068 end
1069end
1070
1071% Having accounted for them in D, we can now remove self-loops markings
1072S(isinf(S))=0;
1073
1074% ---------------------------------------------------------------------
1075% Initialize performance metric matrices
1076% ---------------------------------------------------------------------
1077lG = 0; % Not computed in SSA
1078
1079% ---------------------------------------------------------------------
1080% Run SSA/NRM with direct metric computation
1081% ---------------------------------------------------------------------
1082[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);
1083% Write the measured hit/miss probabilities back into sn so the analyzer can set
1084% them on each Cache node (State.afterEventCache convention: actualhitprob(r) =
1085% hit throughput / (hit+miss) throughput at the cache, per read class r).
1086% The cache hit/miss probability of a read class is the throughput of its hit
1087% class over hit+miss at the cache -- exactly what cacheProd counts per produced
1088% class. A retrieval completion produces the miss class, so retrieval misses are
1089% counted here too; a delayed hit produces nothing and is excluded from the
1090% ratio (matching the serial engine, which folds delayed hits away). Retrieval
1091% classes (hitclass == 0) are internal and get no hit/miss probability of their
1092% own.
1093for ind = 1:I
1094 if isCacheNode(ind)
1095 np = sn.nodeparam{ind};
1096 % Size to nclasses with NaN defaults, exactly as the serial analyzer does:
1097 % the arrival-rate reconstruction (sn_get_arvr_from_tput) indexes
1098 % actual{hit,miss}prob at every origClass whose missclass is set, which
1099 % includes the internal retrieval classes.
1100 np.actualhitprob = NaN(1, R);
1101 np.actualmissprob = NaN(1, R);
1102 for r = 1:R
1103 if isCacheReadClass(ind,r) && r <= numel(np.hitclass) && np.hitclass(r) > 0
1104 hc = np.hitclass(r); mc = np.missclass(r);
1105 hcount = cacheProd(ind, hc);
1106 mcount = cacheProd(ind, mc);
1107 tot = hcount + mcount;
1108 if tot > 0
1109 np.actualhitprob(r) = hcount / tot;
1110 np.actualmissprob(r) = mcount / tot;
1111 end
1112 end
1113 end
1114 sn.nodeparam{ind} = np;
1115 end
1116end
1117
1118end % solver_ssa_nrm
1119
1120% ======================================================================
1121% Next-Reaction Method with direct metric computation
1122% ======================================================================
1123function [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)
1124
1125numReactions = size(S,2);
1126R = sn.nclasses;
1127I = sn.nnodes;
1128M = sn.nstations;
1129K = sn.nclasses;
1130QN = zeros(M, K);
1131UN = zeros(M, K);
1132RN = zeros(M, K);
1133TN = zeros(M, K);
1134CN = zeros(1, K);
1135XN = zeros(1, K);
1136
1137% when a reaction fires, this matrix helps selecting the probability that a
1138% particular routing or phase is selected as a result ------------------
1139P = S; P(P<0)=P(P<0)+1';
1140fromIdxCell = cell(numReactions,1);
1141toIdxCell = cell(numReactions,1);
1142cdfVec = cell(numReactions,1);
1143for r=1:numReactions
1144 nnzP(r) = nnz(P(:,r));
1145 if nnzP(r)>1
1146 fromIdxCell{r} = find(S(:,r)<0);
1147 toIdxCell{r} = find(P(:,r));
1148 cdfVec{r} = cumsum(P(toIdxCell{r},r));
1149 end
1150end
1151
1152% JSQ routing: reactions whose source class routes with JSQ select the
1153% destination node holding the smallest total population at firing time
1154% (each candidate evaluated on its own queue, never the routing node's;
1155% ties split uniformly)
1156isJSQ = false(numReactions,1);
1157% KCHOICES (power-of-k choices): sample k candidates uniformly WITH
1158% replacement, join the one holding the smallest total population, ties broken
1159% by first occurrence in the sampled tuple. This is the sampled form of the
1160% marginal enumerated by sub_kchoices in MNetwork.refreshRoutingMatrix and of
1161% LDES's selectKChoicesDestinationWithClass; drawing directly is equivalent for
1162% a simulator and avoids enumerating the m^k tuples. The withMemory variant
1163% forces the previous pick as the last candidate, which needs a per-(node,class)
1164% memory the reaction network does not carry, so those models are routed to the
1165% serial engine by SOLVER_SSA_ANALYZER and never reach here.
1166isKCH = false(numReactions,1);
1167kchK = zeros(numReactions,1);
1168kchMem = false(numReactions,1); % withMemory variant
1169% Anselmi SQ(d,N) memory: one recorded observation PER eligible destination,
1170% not a single last-selected node. Like the round-robin pointer this is
1171% auxiliary simulator state -- no rate reads it, it only steers the
1172% destination draw -- so it rides alongside the buffers.
1173kchMemory = cell(numReactions,1);
1174for r=1:numReactions
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
1179 isJSQ(r) = true;
1180 elseif sn.routing(srcNode, srcClass) == RoutingStrategy.KCHOICES
1181 isKCH(r) = true;
1182 kk = 2; % sub_kchoices default when nodeparam carries no k
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,'k') && ~isempty(np.k)
1187 kk = np.k;
1188 end
1189 end
1190 kchK(r) = max(1, min(kk, numel(toIdxCell{r})));
1191 if ~isempty(np) && isfield(np,'withMemory') && np.withMemory
1192 kchMem(r) = true;
1193 % Algorithm 1 line 2: Memory[i] = 0 for every destination.
1194 kchMemory{r} = zeros(1, numel(toIdxCell{r}));
1195 end
1196 end
1197 end
1198end
1199
1200% initialise Gillespie clocks ------------------------------------------
1201t = 0;
1202buffers = buffers0; % working copy of the per-node ordered buffers
1203svcph = svcph0; % working copy of the in-service phase multiset (buffered-PH)
1204cacheProd = zeros(numel(buffers0), sn.nclasses); % per (cache node, PRODUCED class) count
1205% Seed each polling controller into the auxiliary buffer of its node. The seed
1206% is a member of the reachable controller space (State.pollingInit's rule): the
1207% server walks from a canonical position and settles on the first tangible
1208% state -- a visit on a class with work, a switchover, or a park -- so the
1209% initial state carries no controller configuration the dynamics cannot reach.
1210if poll.on
1211 for ind = 1:sn.nnodes
1212 if ~poll.isPoll(ind)
1213 continue
1214 end
1215 pinf = poll.pinfo{ind};
1216 nbuf = classCounts(nvec0, phOff, nph, ind, R)'; % 1xR per-class populations
1217 [q0, mode0, budget0] = State.pollingNext(pinf, 1, nbuf, R, true);
1218 buffers{ind} = pollLandCtrl(pinf, q0, mode0, budget0);
1219 end
1220end
1221% Per-region WAITQ FIFO of parked (dstNode, dstClass) tokens, encoded as
1222% (dstNode-1)*R + dstClass. Empty and untouched unless a region uses WAITQ.
1223fcrBuf = {};
1224if fcr.on
1225 fcrBuf = repmat({zeros(1,0)}, numel(fcr.classCap), 1);
1226end
1227for k=1:size(S,2)
1228 Ak(k) = a{k}(nvec0, buffers, svcph);
1229end
1230nvec = nvec0;
1231Pk = -log(rand(1,numReactions));
1232Tk = zeros(1,numReactions);
1233
1234tau = (Pk - Tk) ./ Ak;
1235
1236% Performance tracking variables
1237totalTime = 0;
1238NK = sn.njobs'; % Jobs per class
1239servers = sn.nservers;
1240PH = sn.proc; % service-process MAPs/PHs
1241
1242% Normalized DPS/GPS weights and class priorities, mirroring the propensity
1243% construction so the utilization accumulators use identical sharing factors.
1244classprio = sn.classprio(:)';
1245wnorm = zeros(M, R);
1246for ist = 1:M
1247 if any(sn.sched(ist) == [SchedStrategy.DPS, SchedStrategy.GPS, ...
1248 SchedStrategy.DPSPRIO, SchedStrategy.GPSPRIO])
1249 wnorm(ist, :) = sn.schedparam(ist, 1:R) / sum(sn.schedparam(ist, 1:R));
1250 end
1251end
1252
1253n = 1;
1254while n <= samples
1255 [dt, kfire] = min(tau);
1256 if isinf(dt), line_error(mfilename,'Deadlock. Quitting nrm method.'); end
1257
1258 totalTime = totalTime + dt;
1259
1260 % Accumulate state-dependent metrics during this time interval
1261 for ist = 1:M
1262 ind = sn.stationToNode(ist);
1263 for k = 1:K
1264 % nvec counts jobs per phase now, so the class population is the
1265 % sum over that class's phases.
1266 currentPop = classPop(nvec, phOff, nph, ind, k);
1267
1268 % Accumulate queue length (QN)
1269 QN(ist, k) = QN(ist, k) + currentPop * dt;
1270
1271 % Compute throughput contribution from departures
1272 % Throughput is the total absorption rate of the class: with phase
1273 % expansion each phase owns its own departure reaction, so they are
1274 % summed. Phase-change reactions move no job and are excluded, as
1275 % are the appended renege/retry columns.
1276 depRate = 0;
1277 for jd = 1:nDepRx
1278 if fromIR(jd,1) == ind && fromIR(jd,2) == k && ~isPhaseRx(jd)
1279 depRate = depRate + Ak(jd);
1280 end
1281 end
1282 TN(ist, k) = TN(ist, k) + depRate * dt;
1283
1284 % Compute utilization based on scheduling policy. For the whole PS
1285 % family the class-k utilization is the share of service capacity
1286 % it receives divided by the server count, so the same sharing
1287 % factors that define the propensities are reused here (without the
1288 % lld/cd rate scalings, which rescale work but not occupancy).
1289 switch sn.sched(ist)
1290 case {SchedStrategy.INF, SchedStrategy.EXT}
1291 UN(ist, k) = UN(ist, k) + currentPop * dt;
1292 case {SchedStrategy.PS, SchedStrategy.LPS}
1293 totalPop = sum(classCounts(nvec, phOff, nph, ind, R));
1294 if totalPop > 0
1295 utilization = (currentPop / totalPop) * min(servers(ist), totalPop) / servers(ist);
1296 else
1297 utilization = 0;
1298 end
1299 UN(ist, k) = UN(ist, k) + utilization * dt;
1300 case SchedStrategy.DPS
1301 npop = classCounts(nvec, phOff, nph, ind, R);
1302 UN(ist, k) = UN(ist, k) + dpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1303 case SchedStrategy.GPS
1304 npop = classCounts(nvec, phOff, nph, ind, R);
1305 UN(ist, k) = UN(ist, k) + gpsshare(wnorm(ist,:), npop, k) / servers(ist) * dt;
1306 case SchedStrategy.PSPRIO
1307 npop = classCounts(nvec, phOff, nph, ind, R);
1308 UN(ist, k) = UN(ist, k) + psprioshare(npop, k, servers(ist), classprio) / servers(ist) * dt;
1309 case SchedStrategy.DPSPRIO
1310 npop = classCounts(nvec, phOff, nph, ind, R);
1311 UN(ist, k) = UN(ist, k) + dpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1312 case SchedStrategy.GPSPRIO
1313 npop = classCounts(nvec, phOff, nph, ind, R);
1314 UN(ist, k) = UN(ist, k) + gpsprioshare(wnorm(ist,:), npop, k, servers(ist), classprio) / servers(ist) * dt;
1315 case SchedStrategy.PAS
1316 % Pass-and-swap / order-independent: utilization is the
1317 % time-average number of in-service jobs per class over the
1318 % servers, where "in service" means the positions whose
1319 % marginal rate increment Delta_mu is positive -- so a job
1320 % served by several server types still counts once, not
1321 % 1/rate (solver_ctmc_analyzer, case PAS).
1322 UN(ist, k) = UN(ist, k) + pasInSvc(sn, ind, buffers{ind}, k) / servers(ist) * dt;
1323 case {SchedStrategy.FCFS, SchedStrategy.LCFS, SchedStrategy.SIRO, ...
1324 SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
1325 SchedStrategy.LCFSPR}
1326 if ~isempty(PH{ist}{k})
1327 waiting = sum(buffers{ind} == k);
1328 inService = currentPop - waiting;
1329 UN(ist, k) = UN(ist, k) + (inService / servers(ist)) * dt;
1330 end
1331 case SchedStrategy.POLLING
1332 % The single server is busy on exactly one class-k job while
1333 % the controller serves class k, and idle (switching or
1334 % parked) otherwise; so class-k utilization is the fraction
1335 % of time the controller is SERVING class k.
1336 ctrl = buffers{ind};
1337 if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == k
1338 UN(ist, k) = UN(ist, k) + dt / servers(ist);
1339 end
1340 end
1341 end
1342 end
1343
1344 t = t + dt;
1345
1346 % update aggregate state
1347 destPos = [];
1348 cacheChanged = false;
1349 if isCacheRx(kfire)
1350 % Cache access. The read-class job at the cache reads an item drawn from
1351 % pread, and the cache contents (carried in buffers{cacheNode}) decide a
1352 % hit or a miss; the replacement policy then rewrites the contents.
1353 % Mirrors State.afterEventCache (READ, isSimulation). The job leaves in
1354 % the hit or miss class at the SAME cache node, and the existing
1355 % immediate forwarding routes it downstream from there.
1356 cn = fromIR(kfire,1); rdc = fromIR(kfire,2);
1357 [outClass, newContents, cacheCat] = cacheAccess(sn, cn, rdc, buffers{cn});
1358 buffers{cn} = newContents;
1359 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1; % consume the read-class job
1360 if outClass > 0
1361 if cacheCat == 4
1362 % BEGIN retrieval: the job must travel to the fetch queue and
1363 % return before the miss completes, so it is placed at the
1364 % retrieval class's routed destination (the queue), NOT left at
1365 % the cache where the cache-access reaction would fire again.
1366 destPos = cacheRetrDest(cn, outClass);
1367 else
1368 % Hit or miss/completion: the job leaves in the hit or miss class
1369 % at the SAME cache node; the existing immediate forwarding routes
1370 % it downstream. Count the production per produced class so the
1371 % hit/miss probabilities are the hit/miss-class throughput at the
1372 % cache (State.afterEventCache convention).
1373 destPos = phOff(cn, outClass) + 1;
1374 cacheProd(cn, outClass) = cacheProd(cn, outClass) + 1;
1375 end
1376 nvec(destPos) = nvec(destPos) + 1;
1377 end
1378 % OUTCLASS == 0 is a delayed hit: the request is absorbed (produces
1379 % nothing), coalescing onto the in-flight retrieval.
1380 cacheChanged = true;
1381 elseif nnzP(kfire)>1
1382 cand = toIdxCell{kfire};
1383 % A finite capacity region does NOT filter the routing draw. Routing
1384 % picks the destination first and the region decides admission at the
1385 % destination's entry afterwards, dropping the job on refusal; a
1386 % routing strategy that steered around full regions would be a
1387 % different (and better-behaved) model than the one SOLVER_SSA and the
1388 % CTMC implement. The refusal check is applied to the drawn destination
1389 % below.
1390 if isJSQ(kfire)
1391 % JSQ: join the destination node with the smallest total population
1392 % (ties split uniformly)
1393 npop = inf(numel(cand),1);
1394 for x=1:numel(cand)
1395 jnd = smap.node(cand(x));
1396 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1397 end
1398 amins = find(npop == min(npop));
1399 r = amins(1 + floor(rand*length(amins)));
1400 elseif rr.on && rr.isrr(fromIR(kfire,1), fromIR(kfire,2))
1401 % Round-robin: advance the pointer, then take the destination it
1402 % lands on (State.afterEventRouter advances on DEP and the routing
1403 % closure reads state_after).
1404 [rr, jnd] = rrNext(rr, fromIR(kfire,1), fromIR(kfire,2));
1405 % A phase-type destination contributes ONE candidate per entry phase,
1406 % each weighted by pentry in the routing matrix. The pointer fixes the
1407 % NODE; the entry PHASE must still be drawn from pentry among that
1408 % node's candidates. Taking the first match (phase 0) biases the
1409 % service time -- the RROBIN + phase-type residence bug (RUN-10). Use
1410 % smap.node (not the flat floor((cand-1)/R) formula, which is wrong
1411 % once phases expand the state) to find the node's candidates, then
1412 % sample among them in proportion to their routing weights.
1413 matches = [];
1414 for x = 1:numel(cand)
1415 if smap.node(cand(x)) == jnd
1416 matches(end+1) = x; %#ok<AGROW>
1417 end
1418 end
1419 if isempty(matches)
1420 line_error(mfilename, sprintf('Round-robin selected node %d, which is not a routing destination of node %d.', jnd, fromIR(kfire,1)));
1421 end
1422 if numel(matches) == 1
1423 r = matches(1);
1424 else
1425 cd = cdfVec{kfire};
1426 w = zeros(numel(matches),1);
1427 for ii = 1:numel(matches)
1428 x = matches(ii);
1429 if x > 1
1430 w(ii) = cd(x) - cd(x-1);
1431 else
1432 w(ii) = cd(x);
1433 end
1434 end
1435 wsum = sum(w);
1436 if wsum <= 0
1437 r = matches(1);
1438 else
1439 u = rand * wsum; acc = 0; r = matches(end);
1440 for ii = 1:numel(matches)
1441 acc = acc + w(ii);
1442 if acc > u
1443 r = matches(ii);
1444 break
1445 end
1446 end
1447 end
1448 end
1449 elseif isKCH(kfire) && kchMem(kfire)
1450 % Power-of-d-choices WITH MEMORY, per Anselmi & Dufour, "Power-of-d
1451 % -Choices with Memory: Fluid Limit and Optimality" (Math. Oper.
1452 % Res.), Algorithm 1, SQ(d,N):
1453 % for i = 1..d: rnd = random(1..N); Memory[rnd] = get_state(rnd)
1454 % selected = random(argmin_i Memory[i])
1455 % Memory[selected]++
1456 % The memory holds one observation PER destination and the winner is
1457 % the globally lowest RECORDED state -- not the lowest among the d
1458 % sampled. Observations of unsampled destinations stay stale, and
1459 % the increment charges the winner for the job just sent, which is
1460 % what lets the scheme approach join-the-shortest-queue.
1461 mem = kchMemory{kfire};
1462 for t = 1:kchK(kfire)
1463 % sampled uniformly WITH replacement, as in Algorithm 1
1464 x = 1 + floor(rand * numel(cand));
1465 jnd = smap.node(cand(x));
1466 mem(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1467 end
1468 score = mem;
1469 amins = find(score == min(score));
1470 r = amins(1 + floor(rand * numel(amins)));
1471 mem(r) = mem(r) + 1;
1472 kchMemory{kfire} = mem;
1473 elseif isKCH(kfire)
1474 npop = zeros(numel(cand),1);
1475 for x=1:numel(cand)
1476 jnd = smap.node(cand(x));
1477 npop(x) = sum(classCounts(nvec, phOff, nph, jnd, R));
1478 end
1479 % Memoryless SQ(d): the k candidates are drawn uniformly with
1480 % replacement and the least loaded wins; the strict comparison
1481 % retains the first occurrence, which is the tie rule of
1482 % sub_kchoices. The withMemory variant is handled above and never
1483 % reaches here.
1484 draws = min(kchK(kfire), numel(cand));
1485 r = 1;
1486 bestpop = inf;
1487 for t = 1:draws
1488 x = 1 + floor(rand*numel(cand));
1489 if npop(x) < bestpop
1490 bestpop = npop(x);
1491 r = x;
1492 end
1493 end
1494 else
1495 % Inverse-CDF sampling: smallest r such that cdfVec(r) > rand. The
1496 % previous formulation `1+find(rand>=cdfVec,1)` was a misuse of
1497 % find(...,1) that always returned 2 once rand exceeded cdfVec(1),
1498 % leaving destinations beyond the second one unreachable (e.g. all
1499 % traffic skipping Station3 in a 3-way RAND split).
1500 r = find(cdfVec{kfire} > rand, 1);
1501 if isempty(r)
1502 r = length(cdfVec{kfire});
1503 end
1504 end
1505 % Balking is decided on the pre-arrival population, so it is drawn
1506 % before the state is updated. A balked job is lost: the source still
1507 % releases it, the destination never receives it.
1508 balked = false;
1509 if balk.on
1510 balked = balkDraw(balk, nvec, toIdxCell{kfire}(r), R, smap);
1511 end
1512 % An open arrival at a full physically-capped destination is lost,
1513 % exactly as a balked one is: the source releases it, the destination
1514 % never receives it. Mirrors State.afterEventStation.
1515 if ~balked && capacityLoss(sn, nvec, toIdxCell{kfire}(r), R, smap)
1516 balked = true;
1517 end
1518 % A region refuses the drawn destination on the same pre-arrival
1519 % population. Under DROP the refused job is lost, exactly as a balked
1520 % one is; under WAITQ it is parked in the refusing region's FIFO and
1521 % admitted later, head-of-line. Either way it does not enter the
1522 % destination now, so the source still departs and destPos is cleared.
1523 parkF = 0; parkTok = 0;
1524 if ~balked && fcr.on
1525 dstN = smap.node(toIdxCell{kfire}(r));
1526 dstC = smap.class(toIdxCell{kfire}(r));
1527 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1528 if fref ~= 0
1529 balked = true;
1530 if fcr.waitq(fref, dstC)
1531 parkF = fref; parkTok = (dstN-1)*R + dstC;
1532 end
1533 end
1534 end
1535 nvec(fromIdxCell{kfire}) = nvec(fromIdxCell{kfire}) - 1;
1536 if balked
1537 destPos = [];
1538 if parkF > 0
1539 fcrBuf{parkF}(end+1) = parkTok;
1540 end
1541 elseif sig.on && sigIsSignalArrival(sig, toIdxCell{kfire}(r), R, smap)
1542 % the signal is annihilated on arrival: it never joins the station
1543 [nvec, buffers] = sigApply(sig, nvec, buffers, toIdxCell{kfire}(r), R, mi, smap);
1544 destPos = [];
1545 else
1546 nvec(toIdxCell{kfire}(r)) = nvec(toIdxCell{kfire}(r)) + 1;
1547 destPos = toIdxCell{kfire}(r);
1548 end
1549 else
1550 dpos = find(S(:,kfire) > 0); % deterministic destination (single move)
1551 balked = false;
1552 if balk.on && ~isempty(dpos)
1553 balked = balkDraw(balk, nvec, dpos(1), R, smap);
1554 end
1555 if ~balked && ~isempty(dpos) ...
1556 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1557 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire)) ...
1558 && capacityLoss(sn, nvec, dpos(1), R, smap)
1559 balked = true;
1560 end
1561 % Single-destination departures cross region boundaries too, so the
1562 % region gate applies here exactly as it does to a drawn destination.
1563 % Renege and retry columns carry no destination and are never gated.
1564 parkF = 0; parkTok = 0;
1565 if ~balked && fcr.on && ~isempty(dpos) ...
1566 && ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1567 && ~(kfire <= numel(isRetryRx) && isRetryRx(kfire))
1568 dstN = smap.node(dpos(1));
1569 dstC = smap.class(dpos(1));
1570 fref = fcrRefusingRegion(fcr, nvec, fromIR(kfire,1), fromIR(kfire,2), dstN, dstC, R, smap);
1571 if fref ~= 0
1572 balked = true;
1573 if fcr.waitq(fref, dstC)
1574 parkF = fref; parkTok = (dstN-1)*R + dstC;
1575 end
1576 end
1577 end
1578 if balked
1579 % lost or parked on arrival: apply the source departure only
1580 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1581 destPos = [];
1582 dpos = [];
1583 if parkF > 0
1584 fcrBuf{parkF}(end+1) = parkTok;
1585 end
1586 elseif sig.on && ~isempty(dpos) && sigIsSignalArrival(sig, dpos(1), R, smap)
1587 % the signal is annihilated on arrival: it never joins the station
1588 nvec(fromIdx(kfire)) = nvec(fromIdx(kfire)) - 1;
1589 [nvec, buffers] = sigApply(sig, nvec, buffers, dpos(1), R, mi, smap);
1590 destPos = [];
1591 dpos = [];
1592 else
1593 nvec = nvec + S(:,kfire); % zero change for self-loops
1594 end
1595 if ~isempty(dpos)
1596 destPos = dpos(1);
1597 elseif ~(kfire <= numel(isRenegeRx) && isRenegeRx(kfire)) ...
1598 && ~(kfire <= numel(isPollSwRx) && isPollSwRx(kfire)) && sn.isslc(fromIR(kfire,2))
1599 % Self-looping class: the completed job re-enters the same node and
1600 % class (its stoichiometry is a no-op). At a buffered (FCFS/LCFS)
1601 % station it must rejoin the buffer so the ordering rotates; point
1602 % destPos at the source slot so updateBuffers applies the arrival.
1603 destPos = fromIdx(kfire);
1604 end
1605 end
1606
1607 % maintain the buffers given the source/destination of this firing
1608 svcChanged = false;
1609 if kfire <= numel(isRetryRx) && isRetryRx(kfire)
1610 % A successful retry moves one orbiting job into the free server. The
1611 % population is unchanged (it was already counted at the station), so
1612 % only the orbit shrinks; in-service is read back as population minus
1613 % orbit occupancy.
1614 ind = fromIR(kfire,1);
1615 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first');
1616 if ~isempty(slot)
1617 buffers{ind}(slot) = [];
1618 end
1619 elseif kfire <= numel(isRenegeRx) && isRenegeRx(kfire)
1620 % Reneging removes a job that was WAITING, so no server is freed and no
1621 % queued job is promoted; the abandoning job simply leaves the buffer.
1622 % State.afterEventStation drops the newest waiting job of the class and
1623 % notes that for memoryless patience all waiting jobs are exchangeable,
1624 % so the choice cannot affect the marginal distribution.
1625 ind = fromIR(kfire,1);
1626 slot = find(buffers{ind} == fromIR(kfire,2), 1, 'first');
1627 if ~isempty(slot)
1628 buffers{ind}(slot) = [];
1629 end
1630 elseif kfire <= numel(isPhaseRx) && isPhaseRx(kfire) && bufPHNode(fromIR(kfire,1))
1631 % A buffered-PH phase transition moves one in-service job between phases
1632 % of its own service process. It frees no server and adds no arrival, so
1633 % the buffer is untouched and only svcph changes (INF/PS phase moves are
1634 % already applied to nvec via the stoichiometry and fall to updateBuffers
1635 % below as a no-op, as before).
1636 ind = fromIR(kfire,1); r = fromIR(kfire,2);
1637 svcph{ind}(r, phaseFrom(kfire)) = svcph{ind}(r, phaseFrom(kfire)) - 1;
1638 svcph{ind}(r, phaseTo(kfire)) = svcph{ind}(r, phaseTo(kfire)) + 1;
1639 svcChanged = true;
1640 elseif isCacheRx(kfire)
1641 % The cache access already updated the cache contents (buffers{cacheNode})
1642 % and moved the job to the hit/miss class in the firing block above; there
1643 % is no job buffer to maintain at a cache node.
1644 else
1645 [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase);
1646 end
1647
1648 % Polling controller advance. The controller of each polling node lives in
1649 % its auxiliary buffer as [mode, pos, swk, ctr]; a firing can move it in
1650 % three ways, mirroring State.afterEventStation exactly (EventType.DEP under
1651 % SchedStrategy.POLLING, EventType.SWITCH, and the parked-server arrival):
1652 % * a service completion at the node ends the visit unless the discipline
1653 % still admits another job of the served class, and on ending walks the
1654 % cyclic order to the next tangible controller state;
1655 % * a switchover reaction advances the switchover PH one phase, or on
1656 % absorption arrives at the target buffer and opens a visit or walks on;
1657 % * an arrival to a parked server wakes it, and the walk resolves at once
1658 % to a visit on the newly present work.
1659 % Any of these changes a service gate or the switchover rate, so a change is
1660 % flagged to force a full propensity refresh below (like a WAITQ release).
1661 pollChanged = false;
1662 if poll.on
1663 srcNode = fromIR(kfire,1);
1664 if kfire <= numel(isPollSwRx) && isPollSwRx(kfire)
1665 pind = pollSwNode(kfire);
1666 pinf = poll.pinfo{pind};
1667 ctrl = buffers{pind};
1668 posS = ctrl(2); swkS = ctrl(3);
1669 D0S = pinf.swD0{posS};
1670 KswS = pinf.Ksw(posS);
1671 w = zeros(1, KswS + 1);
1672 for kd = 1:KswS
1673 if kd ~= swkS && D0S(swkS,kd) > 0
1674 w(kd) = D0S(swkS,kd);
1675 end
1676 end
1677 w(KswS + 1) = max(0, -sum(D0S(swkS,:))); % absorption (D1 row sum)
1678 pick = drawFromDist(w);
1679 if pick <= KswS && pick ~= swkS
1680 ctrl(3) = pick; % internal phase advance
1681 buffers{pind} = ctrl;
1682 else
1683 nbufS = classCounts(nvec, phOff, nph, pind, R)';
1684 [qS, mdS, bgS] = State.pollingNext(pinf, posS, nbufS, R, true);
1685 buffers{pind} = pollLandCtrl(pinf, qS, mdS, bgS);
1686 end
1687 pollChanged = true;
1688 elseif poll.isPoll(srcNode) && kfire <= nDepRx && ~isPhaseRx(kfire)
1689 pinf = poll.pinfo{srcNode};
1690 ctrl = buffers{srcNode};
1691 posD = ctrl(2); ctrD = ctrl(4);
1692 nbufD = classCounts(nvec, phOff, nph, srcNode, R)'; % after the departure
1693 switch pinf.ptype
1694 case PollingType.EXHAUSTIVE
1695 ctrnextD = 0; goonD = nbufD(posD) > 0;
1696 case PollingType.GATED
1697 ctrnextD = ctrD - 1; goonD = ctrnextD > 0;
1698 case PollingType.KLIMITED
1699 ctrnextD = ctrD - 1; goonD = ctrnextD > 0 && nbufD(posD) > 0;
1700 case PollingType.DECREMENTING
1701 ctrnextD = ctrD; goonD = nbufD(posD) > ctrD;
1702 end
1703 if goonD
1704 buffers{srcNode} = [1, posD, 0, ctrnextD];
1705 else
1706 [qD, mdD, bgD] = State.pollingNext(pinf, posD, nbufD, R, false);
1707 buffers{srcNode} = pollLandCtrl(pinf, qD, mdD, bgD);
1708 end
1709 pollChanged = true;
1710 end
1711 if ~isempty(destPos) && destPos > 0
1712 jnd = smap.node(destPos);
1713 if poll.isPoll(jnd)
1714 ctrlA = buffers{jnd};
1715 if ~isempty(ctrlA) && ctrlA(1) == 0
1716 pinfA = poll.pinfo{jnd};
1717 nbufA = classCounts(nvec, phOff, nph, jnd, R)'; % includes the arrival
1718 [qA, mdA, bgA] = State.pollingNext(pinfA, ctrlA(2), nbufA, R, true);
1719 buffers{jnd} = pollLandCtrl(pinfA, qA, mdA, bgA);
1720 pollChanged = true;
1721 end
1722 end
1723 end
1724 end
1725
1726 % WAITQ: admit parked jobs whose regions this firing may have relieved.
1727 % A release changes populations at arbitrary destination nodes, so when
1728 % anything is admitted every reaction is refreshed rather than only the
1729 % dependency set of the fired reaction.
1730 nReleased = 0;
1731 if fcr.on && fcr.anyWaitq
1732 [nvec, buffers, fcrBuf, nReleased, svcph, relChanged] = ...
1733 fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode);
1734 svcChanged = svcChanged || relChanged;
1735 end
1736
1737 Tk = Tk + Ak * dt;
1738
1739 % update rates for all reactions dependent on the last fired reaction. A
1740 % polling controller move or a WAITQ release can change rates outside the
1741 % static dependency set of the fired reaction (a switchover reaction has an
1742 % all-zero stoichiometry column, and a controller move flips service gates),
1743 % so either forces a full refresh.
1744 if nReleased > 0 || pollChanged || svcChanged || cacheChanged
1745 for k=1:numReactions
1746 Ak(k) = a{k}(nvec, buffers, svcph);
1747 end
1748 else
1749 for k=D{kfire}
1750 Ak(k) = a{k}(nvec, buffers, svcph);
1751 end
1752 end
1753
1754 % update clocks
1755 Pk(kfire) = Pk(kfire) - log(rand);
1756 tau = (Pk - Tk) ./ Ak;
1757 tau(Ak==0) = inf;
1758
1759 % do not count immediate events
1760 n = n + 1;
1761 print_progress(options, n);
1762end % while
1763% Print newline after progress counter
1764if isfield(options,'verbose') && options.verbose
1765 line_printf('\n');
1766end
1767
1768% Normalize metrics by total time
1769if totalTime > 0
1770 for ist = 1:M
1771 for k = 1:K
1772 QN(ist, k) = QN(ist, k) / totalTime;
1773 UN(ist, k) = UN(ist, k) / totalTime;
1774 TN(ist, k) = TN(ist, k) / totalTime;
1775 end
1776 end
1777end
1778
1779% Class-dependent stations report utilization as T*S/peak, where peak is the
1780% declared per-class peak rate scaling (sn.cdscalingpeak). This matches the
1781% T*S/c convention of the analytic solvers and serial SSA; the accumulated
1782% in-service fraction above divides by the server count (1 for a cd station),
1783% which is not the same quantity. Override those stations here.
1784if ~isempty(sn.cdscaling)
1785 for ist = 1:M
1786 if ist <= numel(sn.cdscaling) && ~isempty(sn.cdscaling{ist})
1787 for k = 1:K
1788 peak = sn.cdscalingpeak(ist, k);
1789 if isfinite(sn.rates(ist, k)) && sn.rates(ist, k) > 0 && peak > 0
1790 UN(ist, k) = TN(ist, k) / sn.rates(ist, k) / peak;
1791 else
1792 UN(ist, k) = 0;
1793 end
1794 end
1795 end
1796 end
1797end
1798
1799% Compute derived metrics
1800for k = 1:K
1801 % System throughput at reference station
1802 XN(1, k) = TN(sn.refstat(k), k);
1803
1804 % Response times
1805 for ist = 1:M
1806 if TN(ist, k) > 0
1807 RN(ist, k) = QN(ist, k) / TN(ist, k);
1808 else
1809 RN(ist, k) = 0;
1810 end
1811 end
1812
1813 % Cycle times
1814 if XN(1, k) > 0
1815 CN(1, k) = NK(k) / XN(1, k);
1816 end
1817end
1818
1819% Handle NaN values
1820QN(isnan(QN)) = 0;
1821UN(isnan(UN)) = 0;
1822RN(isnan(RN)) = 0;
1823XN(isnan(XN)) = 0;
1824TN(isnan(TN)) = 0;
1825CN(isnan(CN)) = 0;
1826
1827 function print_progress(opt, samples_collected)
1828 if ~isfield(opt,'verbose') || ~opt.verbose || batchStartupOptionUsed, return; end
1829 if samples_collected == 1e3
1830 line_printf('\nSSA samples: %8d', samples_collected);
1831 elseif opt.verbose == 2
1832 if samples_collected == 0
1833 line_printf('\nSSA samples: %9d', samples_collected);
1834 else
1835 line_printf('\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1836 end
1837 elseif mod(samples_collected,1e3)==0 || opt.verbose == 2
1838 line_printf('\b\b\b\b\b\b\b\b\b%9d', samples_collected);
1839 end
1840 end
1841end % next_reaction_method_direct
1842
1843% ======================================================================
1844% Buffer maintenance for FCFS/LCFS nodes
1845% ======================================================================
1846function [buffers, svcph, svcChanged] = updateBuffers(kfire, nvec, buffers, fromIR, destPos, mi, R, sn, smap, svcph, bufPHNode, isBufSvcRx, depPhase)
1847% Maintain the ordered per-node buffers when reaction KFIRE fires. A departure
1848% frees a server, so the buffered job selected by the station's discipline is
1849% promoted into service and leaves the buffer; an arrival at a buffered
1850% destination whose servers are all busy joins the buffer head. At a buffered-PH
1851% node the same events also move jobs in and out of the in-service phase multiset
1852% svcph, and SVCCHANGED flags that so the caller forces a full propensity refresh
1853% (svcph is not part of the stoichiometry, so the static dependency set misses it).
1854ind = fromIR(kfire,1); % source node of the firing
1855svcChanged = false;
1856
1857% Buffered-PH departure: the completing job leaves service, so drop it from the
1858% in-service phase it occupied (carried in depPhase). The promotion below refills
1859% the freed server from the buffer at a fresh entry phase.
1860if bufPHNode(ind) && isBufSvcRx(kfire)
1861 r = fromIR(kfire,2);
1862 svcph{ind}(r, depPhase(kfire)) = svcph{ind}(r, depPhase(kfire)) - 1;
1863 svcChanged = true;
1864end
1865
1866% An order-independent station keeps the full ordered list, so a departure is
1867% not a promotion but a pass-and-swap rewrite: the completing position's chain
1868% shifts classes along and removes one slot. Which position completed is
1869% redrawn here in proportion to the Delta_mu of the positions that eject this
1870% class, which is the same split afterEventStationPAS enumerates.
1871if isListSched(ind, sn) && ~isempty(buffers{ind})
1872 buffers{ind} = oiDepart(sn, ind, buffers{ind}, fromIR(kfire,2));
1873 return
1874end
1875
1876% Handle departure from a buffered source node: promote one waiting job. A
1877% retrial station is the exception -- the freed server is NOT filled from the
1878% orbit, orbiting jobs re-enter only through RETRY events at the memoryless
1879% retrial rate (State.afterEventStation suppresses promotion likewise).
1880if isBuffered(ind, sn) && ~isempty(buffers{ind}) && ~isRetrialStation(ind, sn) ...
1881 && ~isListSched(ind, sn)
1882 pos = pickFromBuffer(buffers{ind}, sn, sn.nodeToStation(ind));
1883 promoted = buffers{ind}(pos);
1884 buffers{ind}(pos) = [];
1885 if bufPHNode(ind)
1886 % The promoted waiting job starts service now, entering a phase drawn
1887 % from its entry distribution pie (the same allocation the init uses).
1888 ke = drawEntryPhase(sn, ind, promoted, smap.nph(ind, promoted));
1889 svcph{ind}(promoted, ke) = svcph{ind}(promoted, ke) + 1;
1890 svcChanged = true;
1891 end
1892end
1893
1894% Handle arrival at a buffered destination node
1895if ~isempty(destPos) && destPos > 0
1896 [buffers, svcph, arrChanged] = applyArrivalBuffer(smap.node(destPos), smap.class(destPos), ...
1897 nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
1898 svcChanged = svcChanged || arrChanged;
1899end
1900end
1901
1902function [buffers, svcph, svcChanged] = applyArrivalBuffer(jnd, s, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode)
1903% Join a just-arrived class-S job to the ordered buffer of destination node
1904% JND, if that node is buffered. NVEC already includes the arrival. Shared by
1905% updateBuffers (routed arrivals) and fcrReleaseCascade (WAITQ releases), so
1906% the two paths cannot drift. At a buffered-PH destination a job that enters
1907% service (rather than waiting) is added to the in-service phase multiset svcph
1908% at a pie-drawn entry phase; SVCCHANGED flags that for a propensity refresh.
1909 svcChanged = false;
1910 if isListSched(jnd, sn)
1911 % PAS/OI: the arrival simply joins the back of the ordered list; there
1912 % is no server/buffer split, so no capacity test against mi. Capacity
1913 % is the station's own cap, and an arrival past it is lost.
1914 if numel(buffers{jnd}) < sn.cap(sn.nodeToStation(jnd))
1915 buffers{jnd}(end+1) = s; % append at the back (newest last)
1916 end
1917 elseif isBuffered(jnd, sn)
1918 totalAtDest = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R));
1919 enteredService = false;
1920 if isRetrialStation(jnd, sn)
1921 % A retrial station breaks the buffer invariant the other policies
1922 % share: because a departure does not promote, the orbit can be
1923 % occupied while servers sit idle, so "total > mi" no longer means
1924 % "the servers are busy". An arrival must consult the servers
1925 % directly and only join the orbit when none is free.
1926 inSvc = (totalAtDest - 1) - numel(buffers{jnd});
1927 if inSvc >= mi(jnd)
1928 buffers{jnd} = [s, buffers{jnd}];
1929 else
1930 enteredService = true;
1931 end
1932 elseif totalAtDest > mi(jnd)
1933 if isPreemptive(jnd, sn)
1934 % Preempt-resume: the arrival seizes a server and the incumbent
1935 % it displaces is the one that joins the buffer. The victim is
1936 % drawn in proportion to the class occupancies of the servers,
1937 % as State.afterEventStation weights its preemption branches by
1938 % si_preempt/sum(space_srv). Buffering the incumbent rather than
1939 % the arrival is what leaves the new job in service, since
1940 % in-service is read back as population minus buffer occupancy.
1941 c = pickPreempted(nvec, buffers{jnd}, jnd, s, R);
1942 if c > 0
1943 buffers{jnd} = [c, buffers{jnd}]; % addFirst
1944 end
1945 enteredService = true;
1946 else
1947 % All servers busy - arriving job joins back of buffer
1948 buffers{jnd} = [s, buffers{jnd}]; % addFirst
1949 end
1950 else
1951 % A server is free: the job goes straight into service.
1952 enteredService = true;
1953 end
1954 if enteredService && bufPHNode(jnd)
1955 ke = drawEntryPhase(sn, jnd, s, smap.nph(jnd, s));
1956 svcph{jnd}(s, ke) = svcph{jnd}(s, ke) + 1;
1957 svcChanged = true;
1958 end
1959 end
1960end
1961
1962function ke = drawEntryPhase(sn, jnd, s, nphjs)
1963% Sample the service phase a class-S job starts in at node JND from its entry
1964% distribution pie. A single-phase class always enters phase 1.
1965if nphjs <= 1
1966 ke = 1;
1967 return
1968end
1969pe = entryProbs(sn, jnd, s, nphjs);
1970ke = drawFromDist(pe);
1971end
1972
1973function pos = pickFromBuffer(buf, sn, ist)
1974% Index of the waiting job that the discipline at station IST promotes into
1975% service. BUF is ordered newest-first / oldest-last, matching the convention
1976% of State.afterEventStation's space_buf (which inserts arrivals at column 1
1977% and, for HOL, promotes the rightmost job of the urgent priority group).
1978switch sn.sched(ist)
1979 case SchedStrategy.FCFS
1980 pos = numel(buf); % oldest
1981 case {SchedStrategy.LCFS, SchedStrategy.LCFSPR}
1982 pos = 1; % newest / most recently preempted
1983 case SchedStrategy.SIRO
1984 % Uniform over the waiting jobs. State.afterEventStation promotes a
1985 % class-r job with probability (nir(r)-sir(r))/(ni-sum(sir)), i.e. the
1986 % waiting class-r fraction, which is exactly a uniform draw over buf.
1987 pos = 1 + floor(rand * numel(buf));
1988 case SchedStrategy.HOL
1989 % Highest priority (lowest classprio value); FCFS within the group, so
1990 % the oldest = the last matching position.
1991 prio = sn.classprio(buf);
1992 pos = find(prio == min(prio), 1, 'last');
1993 case {SchedStrategy.SEPT, SchedStrategy.LEPT}
1994 % sn.schedparam(ist,r) is the rank of class r's mean service time
1995 % (ascending for SEPT, descending for LEPT), so the promoted class is
1996 % the waiting one of least rank. Oldest first within a class.
1997 ranks = sn.schedparam(ist, buf);
1998 pos = find(ranks == min(ranks), 1, 'last');
1999 otherwise
2000 line_error(mfilename, sprintf('pickFromBuffer: unsupported buffered policy %s.', ...
2001 SchedStrategy.toText(sn.sched(ist))));
2002end
2003end
2004
2005function n = pasInSvc(sn, ind, c, r)
2006% Number of class-r jobs in service at a PAS/OI station holding the ordered
2007% list C: the positions whose marginal rate increment Delta_mu is positive.
2008% Mirrors the sir the PAS branch of State.toMarginal reports, which is what
2009% solver_ctmc_analyzer divides by the server count.
2010n = 0;
2011if isempty(c)
2012 return
2013end
2014muFun = sn.nodeparam{ind}.svcRateFun;
2015muPrev = 0;
2016for p = 1:numel(c)
2017 muCur = muFun(c(1:p));
2018 if muCur - muPrev > 0 && c(p) == r
2019 n = n + 1;
2020 end
2021 muPrev = muCur;
2022end
2023end
2024
2025function buf = oiDepart(sn, ind, buf, r)
2026% Apply the pass-and-swap rewrite for a class-r departure at OI station IND.
2027% The completing position is drawn among those whose pass-and-swap ejects class
2028% r, weighted by that position's own service rate Delta_mu.
2029muFun = sn.nodeparam{ind}.svcRateFun;
2030G = sn.nodeparam{ind}.swapGraph;
2031c = buf;
2032n = numel(c);
2033pos = [];
2034w = [];
2035muPrev = 0;
2036for p = 1:n
2037 muCur = muFun(c(1:p));
2038 ratep = muCur - muPrev;
2039 muPrev = muCur;
2040 if ratep <= 0
2041 continue
2042 end
2043 [~, depClass] = State.passAndSwap(c, p, G);
2044 if depClass == r
2045 pos(end+1) = p; %#ok<AGROW>
2046 w(end+1) = ratep; %#ok<AGROW>
2047 end
2048end
2049if isempty(pos)
2050 return % this class cannot depart from the current list
2051end
2052u = rand * sum(w);
2053acc = 0;
2054pick = pos(end);
2055for x = 1:numel(pos)
2056 acc = acc + w(x);
2057 if u < acc
2058 pick = pos(x);
2059 break
2060 end
2061end
2062buf = State.passAndSwap(c, pick, G);
2063end
2064
2065function rt = oirate(muFun, G, c, r)
2066% Aggregate class-r departure rate of an order-independent / pass-and-swap
2067% station holding the ordered list C (oldest first). Mirrors the DEP branch of
2068% State.afterEventStationPAS: every position contributes its own service token
2069% at Delta_mu, and pass-and-swap decides which class actually leaves.
2070rt = 0;
2071n = numel(c);
2072if n == 0
2073 return
2074end
2075muPrev = 0; % mu of the empty prefix is 0
2076for p = 1:n
2077 muCur = muFun(c(1:p));
2078 ratep = muCur - muPrev;
2079 muPrev = muCur;
2080 if ratep <= 0
2081 continue % position p receives no service
2082 end
2083 [~, depClass] = State.passAndSwap(c, p, G);
2084 if depClass == r
2085 rt = rt + ratep;
2086 end
2087end
2088end
2089
2090function tf = isListSched(ind, sn)
2091% True for stations whose buffer holds the FULL ordered job list rather than
2092% only the waiting jobs.
2093tf = false;
2094if sn.isstation(ind)
2095 tf = (sn.sched(sn.nodeToStation(ind)) == SchedStrategy.PAS);
2096end
2097end
2098
2099function tf = isRetrialStation(ind, sn)
2100% True for stations with a retrial orbit: their freed servers are not filled by
2101% promotion, only by a successful RETRY.
2102tf = false;
2103if sn.isstation(ind) && isfield(sn,'retrialProc') && ~isempty(sn.retrialProc)
2104 ist = sn.nodeToStation(ind);
2105 tf = ist > 0 && any(~cellfun(@isempty, sn.retrialProc(ist,:)));
2106end
2107end
2108
2109function tf = isPreemptive(ind, sn)
2110% True for the preempt-resume / preempt-independent policies, whose arrivals
2111% displace an incumbent instead of queueing behind it.
2112tf = false;
2113if sn.isstation(ind)
2114 ist = sn.nodeToStation(ind);
2115 tf = any(sn.sched(ist) == [SchedStrategy.LCFSPR]);
2116end
2117end
2118
2119function c = pickPreempted(nvec, buf, jnd, arrClass, R)
2120% Class of the incumbent displaced by an arrival of class ARRCLASS at node JND,
2121% drawn in proportion to the servers' class occupancies. NVEC already counts
2122% the arrival, so it is discounted here to recover the pre-arrival in-service
2123% composition (in-service = population minus buffer occupancy).
2124base = (jnd-1)*R;
2125insvc = zeros(1,R);
2126for r = 1:R
2127 insvc(r) = nvec(base + r) - sum(buf == r);
2128 if r == arrClass
2129 insvc(r) = insvc(r) - 1; % discount the job that just arrived
2130 end
2131end
2132insvc(insvc < 0) = 0;
2133tot = sum(insvc);
2134if tot <= 0
2135 c = 0;
2136 return
2137end
2138u = rand * tot;
2139acc = 0;
2140c = find(insvc > 0, 1, 'last');
2141for r = 1:R
2142 acc = acc + insvc(r);
2143 if insvc(r) > 0 && u < acc
2144 c = r;
2145 return
2146 end
2147end
2148end
2149
2150function npop = classCounts(X, phOff, nph, ind, R)
2151% Per-class populations at node IND, summing each class over its phases. The
2152% scheduling rate laws are class-level: they are unchanged by phase expansion,
2153% and only the per-phase share (see kirFrac) is layered on top.
2154npop = zeros(R,1);
2155for r = 1:R
2156 npop(r) = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2157end
2158end
2159
2160function n = classPop(X, phOff, nph, ind, r)
2161% Population of class R at node IND, summed over its phases.
2162n = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2163end
2164
2165function f = kirFrac(X, slot, phOff, nph, ind, r)
2166% Share of its class that the job population in one phase represents: kir/nir.
2167% The class-level rate law is split across the class's phases in this ratio,
2168% which is exactly how State.afterEventStation writes every phase-aware case
2169% (e.g. DPS uses (kir/nir) * [class share]). For a single-phase class this is
2170% 1 whenever the class is present, so an exponential model is unaffected.
2171nir = sum(X((phOff(ind,r)+1):(phOff(ind,r)+nph(ind,r))));
2172if nir <= 0
2173 f = 0;
2174else
2175 f = X(slot) / nir;
2176end
2177end
2178
2179function pentry = entryProbs(sn, jnd, s, nphjs)
2180% Entry-phase distribution of a class-s job arriving at node JND: pie of its
2181% service process there. A non-station node, or a station whose process is
2182% absent (a disabled class), has a single phase entered with probability 1.
2183pentry = zeros(1, nphjs);
2184if nphjs <= 1
2185 pentry(1) = 1;
2186 return
2187end
2188ist = sn.nodeToStation(jnd);
2189p = sn.pie{ist}{s};
2190p = p(:)';
2191if isempty(p) || all(isnan(p)) || sum(p) <= 0
2192 % no entry distribution declared: enter the first phase
2193 pentry(1) = 1;
2194 return
2195end
2196pentry(1:min(nphjs,numel(p))) = p(1:min(nphjs,numel(p)));
2197pentry = pentry / sum(pentry);
2198end
2199
2200function tf = isBuffered(ind, sn)
2201% True for stations whose waiting jobs are held in an ordered buffer.
2202tf = false;
2203if sn.isstation(ind)
2204 ist = sn.nodeToStation(ind);
2205 tf = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.LCFS, ...
2206 SchedStrategy.SIRO, SchedStrategy.HOL, SchedStrategy.SEPT, SchedStrategy.LEPT, ...
2207 SchedStrategy.LCFSPR]);
2208end
2209end
2210
2211function f = lldfac(ldrow, ntot, lldlimit)
2212% Limited load-dependent scaling factor at total station population NTOT. Returns
2213% 1 when the station has no load dependence or is empty; otherwise the tabulated
2214% factor, clamped to the last entry beyond the tabulated limit.
2215if isempty(ldrow) || ntot < 1
2216 f = 1;
2217else
2218 f = ldrow(min(round(ntot), lldlimit));
2219end
2220end
2221
2222% ======================================================================
2223% Round-robin routing pointers
2224% ======================================================================
2225
2226function rr = rrPrecompute(sn, state)
2227% Per-(node,class) round-robin pointers, seeded from the initial state. RROBIN
2228% stores the destination node index in its slot, WRROBIN a POSITION in the
2229% weighted cycle (each outlink replicated by its weight), matching
2230% State.fromMarginal and State.afterEventRouter.
2231rr = struct('on', false);
2232if ~isfield(sn,'routing') || isempty(sn.routing)
2233 return
2234end
2235if ~any(sn.routing(:) == RoutingStrategy.RROBIN | sn.routing(:) == RoutingStrategy.WRROBIN)
2236 return
2237end
2238R = sn.nclasses;
2239rr.on = true;
2240rr.isrr = false(sn.nnodes, R);
2241rr.iswrr = false(sn.nnodes, R);
2242rr.cycle = cell(sn.nnodes, R); % ordered destination list walked per dispatch
2243rr.pos = ones(sn.nnodes, R); % current position in that list
2244for ind = 1:sn.nnodes
2245 for r = 1:R
2246 isRR = sn.routing(ind,r) == RoutingStrategy.RROBIN;
2247 isWRR = sn.routing(ind,r) == RoutingStrategy.WRROBIN;
2248 if ~isRR && ~isWRR
2249 continue
2250 end
2251 np = sn.nodeparam{ind}{r};
2252 if isWRR && isfield(np,'weighted_outlinks') && ~isempty(np.weighted_outlinks)
2253 cyc = np.weighted_outlinks;
2254 else
2255 cyc = np.outlinks;
2256 end
2257 rr.isrr(ind,r) = true;
2258 rr.iswrr(ind,r) = isWRR;
2259 rr.cycle{ind,r} = cyc(:)';
2260 % seed the pointer from the initial state slot so a warm start is honored
2261 p0 = 1;
2262 if sn.isstateful(ind)
2263 st = state{sn.nodeToStateful(ind)};
2264 slot = sum(sn.nvars(ind, 1:(R + r)));
2265 if ~isempty(st) && slot >= 1 && slot <= numel(st)
2266 v = st(1, slot);
2267 if isWRR
2268 if v >= 1 && v <= numel(cyc), p0 = v; end
2269 else
2270 j = find(cyc == v, 1);
2271 if ~isempty(j), p0 = j; end
2272 end
2273 end
2274 end
2275 rr.pos(ind,r) = p0;
2276 end
2277end
2278end
2279
2280function [rr, jnd] = rrNext(rr, ind, r)
2281% Advance the pointer cyclically and return the destination it lands on.
2282cyc = rr.cycle{ind,r};
2283p = rr.pos(ind,r);
2284if p >= numel(cyc)
2285 p = 1;
2286else
2287 p = p + 1;
2288end
2289rr.pos(ind,r) = p;
2290jnd = cyc(p);
2291end
2292
2293% ======================================================================
2294% G-network signals
2295% ======================================================================
2296
2297function sig = signalPrecompute(sn)
2298% Signal classes and their removal parameters. A signal never joins a station:
2299% it removes jobs there and is annihilated (State.afterEventStationSignal).
2300sig = struct('on', false);
2301if ~isfield(sn,'issignal') || isempty(sn.issignal) || ~any(sn.issignal)
2302 return
2303end
2304sig.on = true;
2305sig.sn = sn; % signalBatchPMF and isCatastropheSignal need it
2306sig.issignal = logical(sn.issignal(:)');
2307sig.nonsignal = find(~sig.issignal);
2308end
2309
2310function tf = sigIsSignalArrival(sig, destPos, R, smap)
2311% True when the state slot DESTPOS is a signal class at a station.
2312s = smap.class(destPos);
2313jnd = smap.node(destPos);
2314tf = sig.issignal(s) && sig.sn.isstation(jnd);
2315end
2316
2317function [nvec, buffers] = sigApply(sig, nvec, buffers, destPos, R, mi, smap)
2318% Apply the arrival of a signal class at a station: pick the victims and remove
2319% them. Mirrors State.afterEventStationSignal, sampled instead of enumerated.
2320sn = sig.sn;
2321jnd = smap.node(destPos);
2322cls = smap.class(destPos);
2323base = smap.phOff(jnd,1); % first slot of this node
2324ist = sn.nodeToStation(jnd);
2325
2326% CATASTROPHE empties the station of every job, ignoring the batch-size
2327% distribution: a catastrophe removes all jobs by definition.
2328if State.isCatastropheSignal(sn, cls)
2329 nvec((base+1):(base+R)) = 0;
2330 buffers{jnd} = [];
2331 return
2332end
2333
2334% Eligible victim classes. A signal that declares a target (forJobClass,
2335% sn.signaltarget >= 1) only removes that class; otherwise every non-signal
2336% class is eligible, which is the classic Gelenbe negative customer and is what
2337% SolverMAM and SolverLDES both do.
2338tgt = -1;
2339if isfield(sn,'signaltarget') && ~isempty(sn.signaltarget) && numel(sn.signaltarget) >= cls
2340 tgt = sn.signaltarget(cls);
2341end
2342if tgt >= 1
2343 tgtclasses = tgt;
2344else
2345 tgtclasses = sig.nonsignal;
2346end
2347tgtclasses = tgtclasses(nvec(base + tgtclasses) > 0);
2348ntot = sum(nvec(base + tgtclasses));
2349if isempty(tgtclasses) || ntot <= 0
2350 return % no victim: the signal simply vanishes
2351end
2352
2353% Batch size, drawn from the pmf the reference enumerates. It is already
2354% clipped at the eligible population, so an oversized batch empties it rather
2355% than driving the queue negative.
2356[kvals, kprobs] = State.signalBatchPMF(sn, cls, ntot);
2357k = kvals(find(cumsum(kprobs) >= rand, 1));
2358if isempty(k)
2359 k = kvals(end);
2360end
2361
2362policy = RemovalPolicy.RANDOM;
2363if isfield(sn,'signalrempolicy') && ~isempty(sn.signalrempolicy) && numel(sn.signalrempolicy) >= cls
2364 policy = sn.signalrempolicy(cls);
2365end
2366
2367for step = 1:k
2368 [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, ...
2369 tgtclasses, policy, R, mi);
2370 if ~removed
2371 break % already drained
2372 end
2373end
2374end
2375
2376function [nvec, buffers, removed] = sigRemoveOne(sn, nvec, buffers, jnd, ist, base, tgtclasses, policy, R, mi)
2377% Remove one victim under the signal's removal policy. Waiting jobs live in the
2378% buffer; the rest of each class population is in service.
2379removed = false;
2380buf = buffers{jnd};
2381waitIdx = find(ismember(buf, tgtclasses)); % eligible waiting positions
2382nwait = numel(waitIdx);
2383nsrv = 0;
2384for r = tgtclasses
2385 nsrv = nsrv + max(0, nvec(base + r) - sum(buf == r));
2386end
2387if nwait == 0 && nsrv == 0
2388 return
2389end
2390
2391% FCFS/LCFS rank the waiting line by age, which only an ordered buffer records.
2392% The NRM buffer is newest-first / oldest-last, so the head of line (oldest) is
2393% the last eligible position and the most recent arrival the first. A per-class
2394% count buffer (SIRO/SEPT/LEPT) carries no age, so an age-based policy
2395% degenerates to a uniform draw there, exactly as in the reference.
2396isOrdered = any(sn.sched(ist) == [SchedStrategy.FCFS, SchedStrategy.HOL, SchedStrategy.LCFS]);
2397ageOrdered = isOrdered && (policy == RemovalPolicy.FCFS || policy == RemovalPolicy.LCFS);
2398if ageOrdered && nwait > 0
2399 if policy == RemovalPolicy.FCFS
2400 pick = waitIdx(end); % head of line: the oldest waiting job
2401 else
2402 pick = waitIdx(1); % the most recent arrival
2403 end
2404 victim = buf(pick);
2405 buffers{jnd}(pick) = [];
2406 nvec(base + victim) = nvec(base + victim) - 1;
2407 removed = true;
2408 return
2409end
2410
2411% RANDOM draws uniformly over waiting and in-service alike; FCFS/LCFS drain the
2412% waiting line before reaching into the servers.
2413if policy == RemovalPolicy.RANDOM
2414 total = nwait + nsrv;
2415else
2416 total = nwait;
2417 if total == 0
2418 total = nsrv;
2419 end
2420end
2421u = rand * total;
2422if nwait > 0 && (policy ~= RemovalPolicy.RANDOM || u < nwait)
2423 % a waiting victim, uniform over the eligible positions
2424 pick = waitIdx(1 + floor(rand * nwait));
2425 victim = buf(pick);
2426 buffers{jnd}(pick) = [];
2427 nvec(base + victim) = nvec(base + victim) - 1;
2428 removed = true;
2429 return
2430end
2431
2432% an in-service victim, uniform over the eligible in-service jobs
2433acc = 0;
2434target = rand * nsrv;
2435for r = tgtclasses
2436 cnt = max(0, nvec(base + r) - sum(buf == r));
2437 acc = acc + cnt;
2438 if cnt > 0 && target < acc
2439 nvec(base + r) = nvec(base + r) - 1;
2440 removed = true;
2441 % the freed server pulls the head of line in, which in the NRM is just
2442 % the waiting job leaving the buffer (in-service is derived as
2443 % population minus buffer occupancy)
2444 total_new = sum(nvec((base+1):(base+R)));
2445 if numel(buffers{jnd}) > max(0, total_new - mi(jnd))
2446 buffers{jnd}(end) = []; % head of line: the oldest waiting job
2447 end
2448 return
2449 end
2450end
2451end
2452
2453% ======================================================================
2454% Balking
2455% ======================================================================
2456
2457function balk = balkPrecompute(sn)
2458% Per (station,class) balking threshold table, for the QUEUE_LENGTH strategy.
2459balk = struct('on', false);
2460if ~isfield(sn,'balkingStrategy') || isempty(sn.balkingStrategy)
2461 return
2462end
2463if ~any(sn.balkingStrategy(:) == BalkingStrategy.QUEUE_LENGTH)
2464 return
2465end
2466balk.on = true;
2467balk.strategy = sn.balkingStrategy;
2468balk.thresholds = sn.balkingThresholds;
2469% index maps needed by balkDraw, captured so it never needs the whole sn
2470balk.isstation = sn.isstation;
2471balk.nodeToStation = sn.nodeToStation;
2472end
2473
2474function tf = balkDraw(balk, nvec, destPos, R, smap)
2475% True if the job routed to state slot DESTPOS balks. The threshold table is
2476% scanned in order and the FIRST interval containing the pre-arrival total
2477% station population wins, matching State.afterEventStation.
2478tf = false;
2479jnd = smap.node(destPos);
2480s = smap.class(destPos);
2481if ~balk.isstation(jnd)
2482 return
2483end
2484ist = balk.nodeToStation(jnd);
2485if ist < 1 || balk.strategy(ist, s) ~= BalkingStrategy.QUEUE_LENGTH
2486 return
2487end
2488qlen = sum(classCounts(nvec, smap.phOff, smap.nph, jnd, R)); % pre-arrival total population
2489th = balk.thresholds{ist, s};
2490balkProb = 0;
2491for ti = 1:numel(th)
2492 t = th{ti};
2493 if qlen >= t{1} && qlen <= t{2}
2494 balkProb = t{3};
2495 break
2496 end
2497end
2498tf = balkProb > 0 && rand < balkProb;
2499end
2500
2501function tf = capacityLoss(sn, nvec, destPos, R, smap)
2502% True if an OPEN-class job routed to state slot DESTPOS is lost because its
2503% destination station is a physically finite-capacity station that is already
2504% full. Mirrors the hasRoom gate + State.arrivalIsLost of afterEventStation:
2505% total occupancy (buffer + in service) is capped at sn.cap, per class at
2506% sn.classcap (0 = no per-class bound). A refused CLOSED job must block, not
2507% vanish from the conserved population, so it is NOT dropped here. Inert unless
2508% the destination declares a physical drop rule. This is the finite-capacity
2509% loss the NRM reaction network otherwise omits, which let a capped queue
2510% overflow well past sn.cap under simulation.
2511tf = false;
2512jnd = smap.node(destPos);
2513dstC = smap.class(destPos);
2514if ~sn.isstation(jnd)
2515 return
2516end
2517ist = sn.nodeToStation(jnd);
2518if ist < 1
2519 return
2520end
2521if ~State.isPhysicalCapacity(sn, ist, dstC) || ~State.arrivalIsLost(sn, ist, dstC)
2522 return
2523end
2524cc = classCounts(nvec, smap.phOff, smap.nph, jnd, R); % pre-arrival populations
2525capLimit = sn.cap(ist);
2526if isfinite(capLimit) && sum(cc) >= capLimit
2527 tf = true;
2528 return
2529end
2530if ~isempty(sn.classcap) && size(sn.classcap,1) >= ist && size(sn.classcap,2) >= dstC
2531 classCapLimit = sn.classcap(ist, dstC);
2532 if classCapLimit > 0 && cc(dstC) >= classCapLimit
2533 tf = true;
2534 end
2535end
2536end
2537
2538% ======================================================================
2539% Finite capacity regions (DROP rule)
2540% ======================================================================
2541
2542function fcr = fcrPrecompute(sn)
2543% Per-region member stations and admission caps, mirroring the FCR precompute
2544% of SOLVER_SSA (the serial engine) field for field.
2545fcr = struct('on', false);
2546if ~isfield(sn,'nregions') || sn.nregions == 0
2547 return
2548end
2549K = sn.nclasses;
2550F = sn.nregions;
2551fcr.on = true;
2552fcr.memberMask = false(F, sn.nstations);
2553fcr.classCap = cell(F,1);
2554fcr.globalCap = inf(F,1);
2555fcr.memCap = inf(F,1);
2556fcr.sz = cell(F,1);
2557fcr.A = cell(F,1);
2558fcr.b = cell(F,1);
2559% Per-(region,class) admission rule: DROP destroys a refused job, WAITQ parks
2560% it in the region FIFO and admits it head-of-line as capacity frees. Mirrors
2561% SOLVER_SSA's fcrRule (regionrule ~= DropStrategy.DROP). Regions with no
2562% WAITQ class carry no FIFO, so pure-DROP models pay nothing.
2563fcr.waitq = false(F, K);
2564if isfield(sn,'regionrule') && ~isempty(sn.regionrule)
2565 for f = 1:F
2566 for r = 1:K
2567 fcr.waitq(f,r) = sn.regionrule(f,r) ~= DropStrategy.DROP;
2568 end
2569 end
2570end
2571fcr.anyWaitq = any(fcr.waitq(:));
2572for f = 1:F
2573 Rmat = sn.region{f}; % M x (K+1)
2574 % membership: any job-count cap OR the region memory budget set on the
2575 % station row (a memory-only region has all job-count entries at -1)
2576 memvec = -ones(sn.nstations,1);
2577 if isfield(sn,'regionmaxmem') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2578 memvec = sn.regionmaxmem{f}(:);
2579 end
2580 mask = (any(Rmat ~= -1, 2) | memvec ~= -1)';
2581 fcr.memberMask(f, 1:numel(mask)) = mask;
2582 members = find(mask);
2583 ccap = inf(1,K);
2584 for r = 1:K
2585 cv = Rmat(members, r); cv = cv(cv ~= -1);
2586 if ~isempty(cv); ccap(r) = min(cv); end
2587 end
2588 fcr.classCap{f} = ccap;
2589 gv = Rmat(members, K+1); gv = gv(gv ~= -1);
2590 if ~isempty(gv); fcr.globalCap(f) = min(gv); end
2591 if isfield(sn,'regionmaxmem') && numel(sn.regionmaxmem) >= f && ~isempty(sn.regionmaxmem{f})
2592 mv = sn.regionmaxmem{f}(members); mv = mv(mv ~= -1);
2593 if ~isempty(mv); fcr.memCap(f) = min(mv); end
2594 end
2595 fcr.sz{f} = sn.regionsz(f,:);
2596 if isfield(sn,'regionlincon') && size(sn.regionlincon,1) >= f && ~isempty(sn.regionlincon{f,1})
2597 fcr.A{f} = sn.regionlincon{f,1};
2598 fcr.b{f} = sn.regionlincon{f,2};
2599 end
2600end
2601% node-level membership, so the gate can be evaluated straight off the NRM
2602% state vector without going through station indices on every firing
2603fcr.memberNode = false(F, sn.nnodes);
2604for f = 1:F
2605 for ist = find(fcr.memberMask(f,:))
2606 fcr.memberNode(f, sn.stationToNode(ist)) = true;
2607 end
2608end
2609end
2610
2611function tf = fcrViolates(xn, ccap, gcap, memcap, sz, A, b)
2612% True if per-class population vector XN breaks any admission constraint of
2613% the region. Mirrors fcr_violates in SOLVER_SSA.
2614tf = any(xn > ccap) || sum(xn) > gcap || (xn * sz(:) > memcap);
2615if ~tf && ~isempty(A)
2616 tf = any(A * xn(:) > b(:));
2617end
2618end
2619
2620function x = fcrRegionPop(nvec, memberNodeRow, R, smap)
2621% Per-class population of a region, read directly off the NRM state vector.
2622x = zeros(1, R);
2623for jnd = find(memberNodeRow)
2624 x = x + classCounts(nvec, smap.phOff, smap.nph, jnd, R)';
2625end
2626end
2627
2628function tf = fcrAdmits(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2629% True if a class-DSTCLASS job may enter node DSTNODE, having just left node
2630% SRCNODE as class SRCCLASS. Only regions containing the destination can
2631% refuse the move; a move whose source is in the same region frees a slot
2632% first, so the departure is accounted for before the arrival is tested.
2633tf = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap) == 0;
2634end
2635
2636function f = fcrRefusingRegion(fcr, nvec, srcNode, srcClass, dstNode, dstClass, R, smap)
2637% Index of the FIRST region that refuses a class-DSTCLASS job entering
2638% DSTNODE, having just left SRCNODE as class SRCCLASS; 0 if every region
2639% admits it. Same admission test as the DROP path, but it names the refusing
2640% region so the caller can consult that region's DROP/WAITQ rule. Mirrors the
2641% first-region `break` of SOLVER_SSA's blockFCR loop.
2642f = 0;
2643if ~fcr.on
2644 return
2645end
2646for ff = 1:size(fcr.memberNode,1)
2647 if ~fcr.memberNode(ff, dstNode)
2648 continue % this region does not constrain the destination
2649 end
2650 x = fcrRegionPop(nvec, fcr.memberNode(ff,:), R, smap);
2651 % srcNode <= 0 means the mover has no live source in the state (a WAITQ
2652 % release, whose job already left its source when it was parked), so no
2653 % source slot is freed.
2654 if srcNode > 0 && fcr.memberNode(ff, srcNode)
2655 x(srcClass) = x(srcClass) - 1;
2656 end
2657 x(dstClass) = x(dstClass) + 1;
2658 if fcrViolates(x, fcr.classCap{ff}, fcr.globalCap(ff), fcr.memCap(ff), ...
2659 fcr.sz{ff}, fcr.A{ff}, fcr.b{ff})
2660 f = ff;
2661 return
2662 end
2663end
2664end
2665
2666function [nvec, buffers, fcrBuf, released, svcph, svcChanged] = fcrReleaseCascade(fcr, nvec, buffers, fcrBuf, mi, R, sn, smap, svcph, bufPHNode)
2667% Strict-FIFO head-of-line release of parked WAITQ tokens: admit each region's
2668% FIFO head while the admission constraints permit, applying the arrival to
2669% the destination station (entry-phase slot plus buffer join). Mirrors
2670% SOLVER_SSA's fcr_release. A token is (dstNode, dstClass); the phase is drawn
2671% at release, as a routed arrival draws it. Loops until a full pass frees
2672% nothing, so a release that frees capacity elsewhere cascades.
2673released = 0;
2674svcChanged = false;
2675progress = true;
2676while progress
2677 progress = false;
2678 for f = 1:numel(fcrBuf)
2679 if isempty(fcrBuf{f})
2680 continue
2681 end
2682 tok = fcrBuf{f}(1);
2683 dstNode = floor((tok-1)/R) + 1;
2684 dstClass = mod(tok-1, R) + 1;
2685 % The parked job already left its source, so admission is tested with
2686 % the source term absent (srcNode = -1 never matches memberNode).
2687 if fcrRefusingRegion(fcr, nvec, -1, dstClass, dstNode, dstClass, R, smap) ~= 0
2688 continue % head-of-line: this FIFO stays blocked
2689 end
2690 if bufPHNode(dstNode)
2691 % Buffered-PH destination: the released job lands in the class total
2692 % slot; whether it enters service (and its entry phase) is decided in
2693 % applyArrivalBuffer against the server occupancy, exactly as a routed
2694 % arrival is.
2695 nvec(smap.phOff(dstNode,dstClass) + 1) = nvec(smap.phOff(dstNode,dstClass) + 1) + 1;
2696 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2697 svcChanged = svcChanged || arrCh;
2698 else
2699 pentry = entryProbs(sn, dstNode, dstClass, smap.nph(dstNode,dstClass));
2700 ke = drawFromDist(pentry);
2701 dslot = smap.phOff(dstNode,dstClass) + ke;
2702 nvec(dslot) = nvec(dslot) + 1;
2703 [buffers, svcph, arrCh] = applyArrivalBuffer(dstNode, dstClass, nvec, buffers, mi, R, sn, smap, svcph, bufPHNode);
2704 svcChanged = svcChanged || arrCh;
2705 end
2706 fcrBuf{f}(1) = [];
2707 released = released + 1;
2708 progress = true;
2709 end
2710end
2711end
2712
2713function ke = drawFromDist(p)
2714% Index drawn from the (unnormalized, nonnegative) weight vector P.
2715tot = sum(p);
2716if tot <= 0
2717 ke = 1;
2718 return
2719end
2720c = cumsum(p) / tot;
2721ke = find(c > rand, 1);
2722if isempty(ke)
2723 ke = numel(p);
2724end
2725end
2726
2727function [outClass, var, category] = cacheAccess(sn, ind, class, var)
2728% Simulate one cache READ at cache node IND by a class-CLASS job over the cache
2729% state VAR (totalCacheCapacity content slots followed, when a retrieval system
2730% is present, by a per-item retrieval-occupancy bitmap). Returns the class the
2731% job leaves in -- OUTCLASS = 0 means the request was absorbed as a delayed hit
2732% and produces nothing -- the rewritten VAR, and a CATEGORY (1 hit, 2 miss/
2733% retrieval-complete, 3 delayed-hit, 4 begin-retrieval). A faithful port of
2734% State.afterEventCache (READ, isSimulation): non-retrieval hit/miss with all
2735% replacement policies, plus the retrieval (delayed-hit) system where a miss for
2736% an item not yet being fetched begins a retrieval (switch to the item's
2737% retrieval class, mark the bitmap), a concurrent request for an item already
2738% being fetched is absorbed, and a returning retrieval-class read completes the
2739% miss (clear the bitmap, admit the item).
2740np = sn.nodeparam{ind};
2741m = np.itemcap;
2742ac = np.accost;
2743h = length(m);
2744replacement_id = np.replacestrat;
2745if isfield(np,'totalCacheCapacity') && ~isempty(np.totalCacheCapacity)
2746 totalCacheCapacity = np.totalCacheCapacity;
2747else
2748 totalCacheCapacity = sum(m);
2749end
2750hitclassArr = np.hitclass;
2751missclassArr = np.missclass;
2752if isfield(np,'retrievalClassIndices') && ~isempty(np.retrievalClassIndices)
2753 rci = np.retrievalClassIndices(:)';
2754else
2755 rci = [];
2756end
2757isFromRetrieval = any(rci == class);
2758if isfield(np,'retrievalClasses') && ~isempty(np.retrievalClasses)
2759 retrClasses = np.retrievalClasses;
2760else
2761 retrClasses = [];
2762end
2763hasRetrieval = isfield(np,'retrievalSystemCapacity') && ~isempty(np.retrievalSystemCapacity) ...
2764 && any(np.retrievalSystemCapacity > 0);
2765
2766p = np.pread{class};
2767k = drawFromDist(p); % requested item
2768l = drawFromDist(ac{class,k}(1,:)); % target list for a miss (1 => reject)
2769posk = find(k == var(1:totalCacheCapacity), 1, 'first');
2770if isFromRetrieval
2771 posk = []; % a returning retrieval always COMPLETES its own miss
2772end
2773
2774if ~isempty(posk)
2775 % ===================== CACHE HIT =====================
2776 outClass = hitclassArr(class);
2777 category = 1;
2778 if posk <= sum(m(1:h-1))
2779 % hit in list i < h: promote toward the last list
2780 i = find(posk <= cumsum(m), 1);
2781 j = posk - sum(m(1:i-1));
2782 accrow = ac{class,k}(1+i, (1+i):end);
2783 inew = i + drawFromDist(accrow / sum(accrow)) - 1;
2784 switch replacement_id
2785 case ReplacementStrategy.FIFO
2786 if inew ~= i
2787 varp = var;
2788 varp(cpos(i,j)) = var(cpos(inew,m(inew)));
2789 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2790 varp(cpos(inew,1)) = k;
2791 var = varp;
2792 end
2793 case ReplacementStrategy.RR
2794 varp = var;
2795 rpos = randi(m(inew),1,1);
2796 varp(cpos(i,j)) = var(cpos(inew,rpos));
2797 varp(cpos(inew,rpos)) = k;
2798 var = varp;
2799 case {ReplacementStrategy.LRU, ReplacementStrategy.SFIFO, ...
2800 ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2801 varp = var;
2802 varp(cpos(i,2):cpos(i,j)) = var(cpos(i,1):cpos(i,j-1));
2803 varp(cpos(i,1)) = var(cpos(inew,m(inew)));
2804 varp(cpos(inew,2):cpos(inew,m(inew))) = var(cpos(inew,1):cpos(inew,m(inew)-1));
2805 varp(cpos(inew,1)) = k;
2806 var = varp;
2807 end
2808 else
2809 % hit in the last list h
2810 j = posk - sum(m(1:h-1));
2811 switch replacement_id
2812 case {ReplacementStrategy.RR, ReplacementStrategy.FIFO, ReplacementStrategy.SFIFO}
2813 % no reordering
2814 case {ReplacementStrategy.LRU, ReplacementStrategy.HLRU, ReplacementStrategy.QLRU}
2815 varp = var;
2816 varp(cpos(h,2):cpos(h,j)) = var(cpos(h,1):cpos(h,j-1));
2817 varp(cpos(h,1)) = var(cpos(h,j));
2818 var = varp;
2819 end
2820 end
2821 return
2822end
2823
2824% ===================== CACHE MISS / retrieval =====================
2825if hasRetrieval && ~isFromRetrieval
2826 % Consult the retrieval system: an item with a retrieval class is fetched
2827 % rather than admitted directly on a miss.
2828 rClass = -1;
2829 if ~isempty(retrClasses) && k <= size(retrClasses,1) && class <= size(retrClasses,2)
2830 rClass = retrClasses(k, class);
2831 end
2832 if rClass ~= -1
2833 inRetrieval = (totalCacheCapacity + k <= numel(var)) && var(totalCacheCapacity + k) ~= 0;
2834 if inRetrieval
2835 % DELAYED HIT: this request is served by the in-flight retrieval and
2836 % absorbed (no class produced), coalescing onto the pending fetch.
2837 outClass = 0;
2838 category = 3;
2839 return
2840 else
2841 % BEGIN retrieval: switch to the item's retrieval class and mark the
2842 % item as being fetched; the job routes to the retrieval queue and
2843 % returns later to complete the miss.
2844 var(totalCacheCapacity + k) = 1;
2845 outClass = rClass;
2846 category = 4;
2847 return
2848 end
2849 end
2850end
2851
2852% COMPLETE the miss: a returning retrieval, or a plain miss with no retrieval
2853% class. Clear the retrieval bit (if any) and admit item k per the policy.
2854if isFromRetrieval && (totalCacheCapacity + k <= numel(var))
2855 var(totalCacheCapacity + k) = 0;
2856end
2857outClass = missclassArr(class);
2858category = 2;
2859listidx = l - 1;
2860switch replacement_id
2861 case {ReplacementStrategy.FIFO, ReplacementStrategy.LRU, ...
2862 ReplacementStrategy.SFIFO, ReplacementStrategy.HLRU}
2863 if listidx > 0
2864 varp = var;
2865 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2866 varp(cpos(listidx,1)) = k;
2867 var = varp;
2868 end
2869 case ReplacementStrategy.RR
2870 if listidx > 0
2871 rpos = randi(m(listidx),1,1);
2872 var(cpos(listidx,rpos)) = k;
2873 end
2874 case ReplacementStrategy.QLRU
2875 if isfield(np,'qlru') && ~isempty(np.qlru), qadm = np.qlru; else, qadm = 1.0; end
2876 if listidx > 0 && rand <= qadm
2877 varp = var;
2878 varp(cpos(listidx,2):cpos(listidx,m(listidx))) = var(cpos(listidx,1):cpos(listidx,m(listidx)-1));
2879 varp(cpos(listidx,1)) = k;
2880 var = varp;
2881 end
2882end
2883
2884 function pos = cpos(ii,jj)
2885 pos = sum(m(1:ii-1)) + jj;
2886 end
2887end
2888
2889% ======================================================================
2890% PS-family sharing factors
2891%
2892% Each returns the multiplier applied to the class-r service rate, i.e. the
2893% fraction of total service capacity that class r receives in population
2894% state NVECPOP. All mirror the corresponding case of
2895% State.afterEventStation specialized to exponential (single-phase) service,
2896% where the phase population kir equals the class population nir.
2897% ======================================================================
2898
2899function f = dpsshare(w, nvecpop, r)
2900% DPS: rate_r = mu_r * w_r*n_r / (w.n) on a single server.
2901den = w(:)' * nvecpop(:);
2902if den <= 0
2903 f = 0;
2904else
2905 f = w(r) * nvecpop(r) / den;
2906end
2907end
2908
2909function f = gpsshare(w, nvecpop, r)
2910% GPS: rate_r = mu_r * w_r / (w.c), c_s = 1{n_s>0}, on a single server. The
2911% weight denominator counts active classes, not jobs, so a class with a
2912% single job gets the same share as one with many.
2913if nvecpop(r) <= 0
2914 f = 0;
2915 return
2916end
2917cir = double(nvecpop(:) > 0);
2918den = w(:)' * cir;
2919if den <= 0
2920 f = 0;
2921else
2922 f = w(r) / den;
2923end
2924end
2925
2926function [act, niprio] = prioGroup(nvecpop, r, classprio)
2927% Population vector restricted to the priority group of class r, and its
2928% total. Empty classes never define the urgent group.
2929act = zeros(size(nvecpop));
2930same = (classprio(:) == classprio(r));
2931act(same) = nvecpop(same);
2932niprio = sum(act);
2933end
2934
2935function tf = isUrgent(nvecpop, r, classprio)
2936% True when class r belongs to the most urgent non-empty priority group.
2937% LINE orders priorities with lower value = more urgent.
2938occupied = nvecpop(:) > 0;
2939if ~any(occupied)
2940 tf = false;
2941else
2942 tf = (classprio(r) == min(classprio(occupied)));
2943end
2944end
2945
2946function n = prioPop(nvecpop, r, c, classprio)
2947% Population that the lld factor is evaluated at: the full station
2948% population below capacity, the priority-group population above it.
2949ni = sum(nvecpop);
2950if ni <= c || ~isUrgent(nvecpop, r, classprio)
2951 n = ni;
2952else
2953 [~, n] = prioGroup(nvecpop, r, classprio);
2954end
2955end
2956
2957function v = prioVec(nvecpop, r, c, classprio)
2958% Population vector that the cd factor is evaluated at for DPSPRIO/GPSPRIO:
2959% the priority-restricted vector above capacity, the full one below it.
2960% Note PSPRIO instead uses the full vector in both branches; that asymmetry
2961% is inherited from State.afterEventStation and is reproduced here.
2962ni = sum(nvecpop);
2963if ni <= c || ~isUrgent(nvecpop, r, classprio)
2964 v = nvecpop;
2965else
2966 v = prioGroup(nvecpop, r, classprio);
2967end
2968end
2969
2970function f = psprioshare(nvecpop, r, c, classprio)
2971% PSPRIO: PS below capacity; above it only the most urgent non-empty group
2972% shares the servers and everyone else is frozen.
2973ni = sum(nvecpop);
2974if ni <= 0
2975 f = 0;
2976elseif ni <= c
2977 f = (nvecpop(r) / ni) * min(ni, c);
2978elseif ~isUrgent(nvecpop, r, classprio)
2979 f = 0;
2980else
2981 [~, niprio] = prioGroup(nvecpop, r, classprio);
2982 f = (nvecpop(r) / niprio) * min(niprio, c);
2983end
2984end
2985
2986function f = dpsprioshare(w, nvecpop, r, c, classprio)
2987% DPSPRIO: DPS below capacity, DPS restricted to the urgent group above it.
2988ni = sum(nvecpop);
2989if ni <= 0
2990 f = 0;
2991elseif ni <= c
2992 f = dpsshare(w, nvecpop, r);
2993elseif ~isUrgent(nvecpop, r, classprio)
2994 f = 0;
2995else
2996 f = dpsshare(w, prioGroup(nvecpop, r, classprio), r);
2997end
2998end
2999
3000function f = gpsprioshare(w, nvecpop, r, c, classprio)
3001% GPSPRIO: GPS below capacity, GPS restricted to the urgent group above it.
3002ni = sum(nvecpop);
3003if ni <= 0
3004 f = 0;
3005elseif ni <= c
3006 f = gpsshare(w, nvecpop, r);
3007elseif ~isUrgent(nvecpop, r, classprio)
3008 f = 0;
3009else
3010 f = gpsshare(w, prioGroup(nvecpop, r, classprio), r);
3011end
3012end
3013
3014function f = cdfac(cdbeta, nvecpop, r)
3015% Class-dependence factor for a class-r completion at a station with per-class
3016% population vector NVECPOP: the class-r component of the 1xR scaling vector
3017% returned by the handle CDBETA (see fes_beta_handle and State.cdclassfactor).
3018% Returns 1 when the station declares no class dependence.
3019if isempty(cdbeta)
3020 f = 1;
3021else
3022 v = cdbeta(nvecpop(:)');
3023 f = v(min(r, numel(v)));
3024end
3025end
3026
3027
3028% ======================================================================
3029% Polling controller helpers
3030% ======================================================================
3031
3032function ctrl = pollLandCtrl(pinf, q, mode, budget)
3033% Controller row [mode, pos, swk, ctr] the server lands in after
3034% State.pollingNext resolves (q, mode, budget): SERVING q with the visit budget,
3035% SWITCHING into q with the entry phase drawn from the switchover PH, or PARKED
3036% at the canonical q. Mirrors State.pollingLand specialized to the single-server
3037% polling station the NRM carries (exponential service, so no in-service phase).
3038switch mode
3039 case 1
3040 ctrl = [1, q, 0, budget];
3041 case 2
3042 swk = drawFromDist(pinf.swpie{q});
3043 ctrl = [2, q, swk, 0];
3044 otherwise
3045 ctrl = [0, q, 0, 0]; % parked
3046end
3047end
3048
3049function g = pollServeGate(ctrl, r)
3050% 1 when the polling controller CTRL is serving class r, else 0. This is the
3051% single-server gate that turns a class-r service departure on only while the
3052% server attends class r.
3053if numel(ctrl) >= 2 && ctrl(1) == 1 && ctrl(2) == r
3054 g = 1;
3055else
3056 g = 0;
3057end
3058end
3059
3060function rate = pollSwRate(ctrl, pinf)
3061% Total leaving rate of the switchover phase the controller CTRL currently
3062% occupies, i.e. -D0(swk,swk) of the switchover PH into buffer pos; 0 unless the
3063% server is walking (mode SWITCHING). The competition between advancing to
3064% another phase and absorbing is resolved at firing time by the run loop.
3065rate = 0;
3066if numel(ctrl) >= 3 && ctrl(1) == 2
3067 pos = ctrl(2); swk = ctrl(3);
3068 D0 = pinf.swD0{pos};
3069 rate = -D0(swk, swk);
3070end
3071end
3072% ======================================================================
3073% Stochastic Petri net (Place / Transition) via the Next-Reaction Method
3074%
3075% A stochastic Petri net maps onto the reaction network exactly: a Place holds
3076% a per-class token count (a population slot of the state vector), and a timed
3077% Transition mode is a reaction whose stoichiometry column is the arc
3078% incidence -- input (enabling) arcs consume, output (firing) arcs produce.
3079% Enabling is a propensity gate (all input places at or above their arc weight,
3080% every inhibitor place strictly below its threshold); a single-server mode
3081% then fires at its exponential rate, an infinite/k-server mode at that rate
3082% times its enabling degree. Each firing applies the mode's stoichiometry once
3083% (consume the input weights, produce the output weights), which is the atomic
3084% GSPN firing shared by the exact CTMC (single server), JMT and GreatSPN.
3085%
3086% IMMEDIATE transitions fire in zero time and cannot be an exponential reaction.
3087% They are resolved by vanishing-marking elimination: after every timed firing
3088% (and once on the initial marking) every enabled immediate mode is fired,
3089% highest firing-priority first and, among equal priority, chosen in proportion
3090% to firing weight, until the marking is tangible (no immediate enabled). The
3091% timed race only resumes from tangible markings, so the immediate transitions
3092% never consume simulated time.
3093%
3094% Not handled here (rejected upstream by the SSA featset, never reached): a
3095% Transition whose firing distribution is non-exponential (phase-type or
3096% general). Representing an in-flight firing's phase needs per-mode phase state
3097% the reaction network does not carry; the exponential path covers the standard
3098% GSPN case and every all-exponential validation net (spn_inhibiting,
3099% spn_twomodes, spn_fourmodes).
3100% ======================================================================
3101function [QN, UN, RN, TN, CN, XN] = solver_ssa_nrm_spn(sn, options, phOff, nph, NS, smap)
3102samples = options.samples;
3103R = sn.nclasses;
3104I = sn.nnodes;
3105M = sn.nstations;
3106K = sn.nclasses;
3107
3108% Build the reaction list. Timed modes become reactions (rx); immediate modes
3109% are collected separately (imm) for the vanishing-marking collapse.
3110rx = spnEmptyRx(); rx(1) = [];
3111imm = spnEmptyRx(); imm(1) = [];
3112% consumers{ind,c}: indices into rx of timed modes that consume from place ind,
3113% class c. Place throughput is the aggregate firing rate of those modes (once
3114% per firing, unweighted -- the same depRates the CTMC accumulates from PRE
3115% events), so this map drives the TN accumulator.
3116consumers = cell(I, R);
3117for ind = 1:I
3118 if sn.nodetype(ind) ~= NodeType.Transition
3119 continue
3120 end
3121 np = sn.nodeparam{ind};
3122 for m = 1:np.nmodes
3123 rec = spnBuildMode(sn, ind, m, phOff, NS);
3124 if np.timing(m) == TimingStrategy.IMMEDIATE
3125 imm(end+1) = rec; %#ok<AGROW>
3126 else
3127 rx(end+1) = rec; %#ok<AGROW>
3128 ridx = numel(rx);
3129 for a = 1:numel(rec.enSlot)
3130 p = smap.node(rec.enSlot(a));
3131 c = smap.class(rec.enSlot(a));
3132 consumers{p, c}(end+1) = ridx;
3133 end
3134 end
3135 end
3136end
3137
3138% Source arrivals. A Source is not a Transition, so its Poisson arrival is not
3139% one of the transition modes above; it needs its own reaction or the fed Place
3140% stays empty and the net deadlocks. Add one arrival reaction per (Source node,
3141% open class, routed Place-class edge). Splitting a Poisson stream by the
3142% independent routing probabilities yields independent Poisson streams, so an
3143% edge of probability p carries rate lambda*p exactly. The reaction has an EMPTY
3144% enabling set (always enabled, state-independent propensity = lambda*p) and
3145% deposits +1 token into the routed Place slot. producers{node,class} indexes
3146% these so the Source station reports its arrival rate as throughput, which is
3147% the reference-station throughput of the open class (matching JMT).
3148producers = cell(I, R);
3149for ind = 1:I
3150 if sn.nodetype(ind) ~= NodeType.Source
3151 continue
3152 end
3153 ist = sn.nodeToStation(ind);
3154 for r = 1:R
3155 lambda = sn.rates(ist, r);
3156 if isnan(lambda) || lambda <= 0
3157 continue
3158 end
3159 if sn.procid(ist, r) ~= ProcessType.EXP
3160 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));
3161 end
3162 foundPlace = false;
3163 for jnd = 1:I
3164 if sn.nodetype(jnd) ~= NodeType.Place
3165 continue
3166 end
3167 for s = 1:R
3168 p = sn.rtnodes((ind-1)*R + r, (jnd-1)*R + s);
3169 if p <= 0
3170 continue
3171 end
3172 foundPlace = true;
3173 rec = spnEmptyRx();
3174 rec.node = ind;
3175 rec.mode = 0; % arrival, not a transition mode
3176 Svec = zeros(NS, 1);
3177 Svec(phOff(jnd, s) + 1) = Svec(phOff(jnd, s) + 1) + 1;
3178 rec.Svec = Svec;
3179 rec.enSlot = []; rec.enW = [];
3180 rec.inhSlot = []; rec.inhThr = [];
3181 rec.baseRate = lambda * p; % Poisson thinning by the routing prob
3182 rec.nservers = 1; % constant propensity = baseRate
3183 rec.weight = 1; rec.prio = 1;
3184 rx(end+1) = rec; %#ok<AGROW>
3185 producers{ind, r}(end+1) = numel(rx);
3186 end
3187 end
3188 if ~foundPlace
3189 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));
3190 end
3191 end
3192end
3193
3194nR = numel(rx);
3195if nR == 0
3196 line_error(mfilename, 'Stochastic Petri net has no timed reaction; nothing to simulate.');
3197end
3198
3199% Initial marking: token counts per (place, class), read straight off the
3200% initial state as the marginal population of each Place.
3201nvec0 = zeros(NS, 1);
3202state = sn.state;
3203for ind = 1:I
3204 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3205 continue
3206 end
3207 state_i = state{sn.nodeToStateful(ind)};
3208 [~, nir] = State.toMarginalAggr(sn, ind, state_i);
3209 for c = 1:R
3210 if isinf(nir(c))
3211 line_error(mfilename, 'Infinite marking at a Place is not supported.');
3212 end
3213 nvec0(phOff(ind, c) + 1) = nir(c);
3214 end
3215end
3216
3217maxImmSteps = 100000; % livelock guard for the vanishing-marking collapse
3218
3219% Finite-capacity Place DROP enforcement. A Place with a finite per-class
3220% capacity (sn.classcap) or total capacity (sn.cap) loses any arriving token
3221% that would exceed it (JMT/CTMC loss semantics: an M/M/1/1 Place with cap 1
3222% holds mean 0.333 at rho=0.5, not the unbounded-M/M/1 value 1.0). Without this
3223% the deposit nvec+Svec accumulates tokens past capacity. Precompute the per-slot
3224% per-class caps, the per-place total caps, and each reaction's deposited slots
3225% so the clamp in the loop touches only what just grew. Mirrors the Python native
3226% _solver_ssa_nrm_spn.
3227pcapSlot = inf(NS, 1); % per-(place,class) slot cap
3228placeTotalCaps = cell(0, 2); % {totalCap, slotVec} per capped place
3229for ind = 1:I
3230 if sn.nodetype(ind) ~= NodeType.Place || ~sn.isstateful(ind)
3231 continue
3232 end
3233 ist = sn.nodeToStation(ind);
3234 slotsHere = zeros(1, R);
3235 for c = 1:R
3236 slot = phOff(ind, c) + 1;
3237 slotsHere(c) = slot;
3238 if ist <= size(sn.classcap, 1)
3239 cc = sn.classcap(ist, c);
3240 if isfinite(cc)
3241 pcapSlot(slot) = cc;
3242 end
3243 end
3244 end
3245 tcap = Inf;
3246 if ist <= numel(sn.cap)
3247 tcap = sn.cap(ist);
3248 end
3249 if isfinite(tcap)
3250 placeTotalCaps(end+1, :) = {tcap, slotsHere}; %#ok<AGROW>
3251 end
3252end
3253hasPlaceCaps = any(isfinite(pcapSlot)) || ~isempty(placeTotalCaps);
3254depSlots = cell(1, nR);
3255for k = 1:nR
3256 depSlots{k} = find(rx(k).Svec > 0);
3257end
3258
3259% ---------------------------------------------------------------------
3260% Next-Reaction Method run loop
3261% ---------------------------------------------------------------------
3262nvec = spnCollapse(nvec0, imm, maxImmSteps);
3263if hasPlaceCaps
3264 nvec = applyPlaceCaps(nvec, (1:NS)', pcapSlot, placeTotalCaps);
3265end
3266Ak = zeros(1, nR);
3267for k = 1:nR
3268 Ak(k) = spnProp(nvec, rx(k));
3269end
3270Pk = -log(rand(1, nR));
3271Tk = zeros(1, nR);
3272tau = (Pk - Tk) ./ Ak;
3273tau(Ak == 0) = inf;
3274
3275QN = zeros(M, K); UN = zeros(M, K); RN = zeros(M, K);
3276TN = zeros(M, K); CN = zeros(1, K); XN = zeros(1, K);
3277totalTime = 0;
3278NK = sn.njobs';
3279
3280n = 1;
3281while n <= samples
3282 [dt, kfire] = min(tau);
3283 if isinf(dt)
3284 line_error(mfilename, 'Deadlock: no transition is enabled. Quitting nrm method.');
3285 end
3286 totalTime = totalTime + dt;
3287
3288 % Time-average accumulators over the sojourn dt. A Place is an INF station,
3289 % so its utilization is its mean token count (the SPN convention the CTMC
3290 % analyzer reports). Its throughput is the summed firing rate of the modes
3291 % consuming from it.
3292 for ist = 1:M
3293 ind = sn.stationToNode(ist);
3294 for c = 1:K
3295 tokens = classPop(nvec, phOff, nph, ind, c);
3296 QN(ist, c) = QN(ist, c) + tokens * dt;
3297 UN(ist, c) = UN(ist, c) + tokens * dt;
3298 depr = 0;
3299 cons = consumers{ind, c};
3300 for a = 1:numel(cons)
3301 depr = depr + Ak(cons(a));
3302 end
3303 % A Source station has no consuming transition; its throughput is the
3304 % aggregate arrival rate it injects (producers), so the reference
3305 % station reports the open-class arrival rate as its throughput.
3306 prod = producers{ind, c};
3307 for a = 1:numel(prod)
3308 depr = depr + Ak(prod(a));
3309 end
3310 TN(ist, c) = TN(ist, c) + depr * dt;
3311 end
3312 end
3313
3314 % Fire the selected timed mode (single atomic firing), then collapse any
3315 % immediate transitions the new marking enabled. A finite-capacity DROP Place
3316 % loses any token the firing pushed above its capacity, before the immediate
3317 % cascade sees the new marking.
3318 nvec = nvec + rx(kfire).Svec;
3319 if hasPlaceCaps
3320 nvec = applyPlaceCaps(nvec, depSlots{kfire}, pcapSlot, placeTotalCaps);
3321 end
3322 nvec = spnCollapse(nvec, imm, maxImmSteps);
3323
3324 % Advance the Gibson & Bruck clocks with the pre-firing propensities, then
3325 % refresh every propensity from the new marking. A firing plus its
3326 % immediate cascade can change any place, so every reaction is refreshed
3327 % rather than a dependency subset -- the SPN reaction count is small and
3328 % this removes any dependency-graph blind spot.
3329 Tk = Tk + Ak * dt;
3330 for k = 1:nR
3331 Ak(k) = spnProp(nvec, rx(k));
3332 end
3333 Pk(kfire) = Pk(kfire) - log(rand);
3334 tau = (Pk - Tk) ./ Ak;
3335 tau(Ak == 0) = inf;
3336
3337 n = n + 1;
3338 if isfield(options, 'verbose') && options.verbose && mod(n, 1e3) == 0 && ~batchStartupOptionUsed
3339 line_printf('\b\b\b\b\b\b\b\b\b%9d', n);
3340 end
3341end
3342if isfield(options, 'verbose') && options.verbose
3343 line_printf('\n');
3344end
3345
3346if totalTime > 0
3347 QN = QN / totalTime;
3348 UN = UN / totalTime;
3349 TN = TN / totalTime;
3350end
3351for c = 1:K
3352 XN(1, c) = TN(sn.refstat(c), c);
3353 for ist = 1:M
3354 if TN(ist, c) > 0
3355 RN(ist, c) = QN(ist, c) / TN(ist, c);
3356 end
3357 end
3358 if XN(1, c) > 0
3359 CN(1, c) = NK(c) / XN(1, c);
3360 end
3361end
3362QN(isnan(QN)) = 0; UN(isnan(UN)) = 0; RN(isnan(RN)) = 0;
3363XN(isnan(XN)) = 0; TN(isnan(TN)) = 0; CN(isnan(CN)) = 0;
3364end
3365
3366function rec = spnEmptyRx()
3367% Prototype record for a transition-mode reaction, so struct arrays stay
3368% homogeneous (MATLAB requires identical fields to concatenate).
3369rec = struct('node', 0, 'mode', 0, 'Svec', [], 'enSlot', [], 'enW', [], ...
3370 'inhSlot', [], 'inhThr', [], 'baseRate', 0, 'nservers', 1, ...
3371 'weight', 1, 'prio', 1);
3372end
3373
3374function rec = spnBuildMode(sn, ind, m, phOff, NS)
3375% Assemble the reaction record of transition IND mode M. Enabling/firing/
3376% inhibiting are (nnodes x nclasses) matrices; find() gives linear indices
3377% p+(c-1)*nnodes that decode to the (place, class) whose slot is phOff(p,c)+1.
3378np = sn.nodeparam{ind};
3379R = sn.nclasses;
3380rec = spnEmptyRx();
3381rec.node = ind;
3382rec.mode = m;
3383Svec = zeros(NS, 1);
3384enSlot = []; enW = [];
3385en = np.enabling{m};
3386li = find(en);
3387for t = 1:numel(li)
3388 [p, c] = ind2sub([sn.nnodes, R], li(t));
3389 slot = phOff(p, c) + 1;
3390 enSlot(end+1) = slot; %#ok<AGROW>
3391 enW(end+1) = en(li(t)); %#ok<AGROW>
3392 Svec(slot) = Svec(slot) - en(li(t));
3393end
3394fir = np.firing{m};
3395lf = find(fir);
3396for t = 1:numel(lf)
3397 [p, c] = ind2sub([sn.nnodes, R], lf(t));
3398 slot = phOff(p, c) + 1;
3399 Svec(slot) = Svec(slot) + fir(lf(t));
3400end
3401inhSlot = []; inhThr = [];
3402inh = np.inhibiting{m};
3403lh = find(~isinf(inh));
3404for t = 1:numel(lh)
3405 [p, c] = ind2sub([sn.nnodes, R], lh(t));
3406 inhSlot(end+1) = phOff(p, c) + 1; %#ok<AGROW>
3407 inhThr(end+1) = inh(lh(t)); %#ok<AGROW>
3408end
3409rec.Svec = Svec;
3410rec.enSlot = enSlot; rec.enW = enW;
3411rec.inhSlot = inhSlot; rec.inhThr = inhThr;
3412% Exponential firing rate: the single-phase completion rate sum(D1). A
3413% non-exponential firing distribution is rejected by the featset and must not
3414% reach here.
3415if np.timing(m) ~= TimingStrategy.IMMEDIATE
3416 fK = np.firingphases(m);
3417 if isnan(fK) || fK ~= 1 || isempty(np.firingproc{m})
3418 line_error(mfilename, sprintf('Transition %s mode %d has non-exponential firing, which the NRM SPN path does not support.', sn.nodenames{ind}, m));
3419 end
3420 D1 = np.firingproc{m}{2};
3421 rec.baseRate = sum(D1(:));
3422end
3423ns = np.nmodeservers(m);
3424if isinf(ns)
3425 ns = GlobalConstants.MaxInt();
3426end
3427rec.nservers = ns;
3428rec.weight = np.fireweight(m);
3429rec.prio = np.firingprio(m);
3430end
3431
3432function d = spnEnDegree(nvec, rx)
3433% Enabling degree of a mode: the number of concurrent firings the marking
3434% supports, min over input arcs of floor(tokens/weight), zeroed by any active
3435% inhibitor arc. A mode with no input arc is treated as single-degree.
3436for i = 1:numel(rx.inhSlot)
3437 if nvec(rx.inhSlot(i)) >= rx.inhThr(i)
3438 d = 0;
3439 return
3440 end
3441end
3442if isempty(rx.enSlot)
3443 d = 1;
3444 return
3445end
3446d = inf;
3447for i = 1:numel(rx.enSlot)
3448 d = min(d, floor(nvec(rx.enSlot(i)) / rx.enW(i)));
3449end
3450end
3451
3452function a = spnProp(nvec, rx)
3453% Propensity of a timed mode: the exponential rate times the effective number
3454% of servers, min(enabling degree, mode servers). Single-server modes therefore
3455% fire at their rate whenever enabled, infinite/k-server modes at the rate
3456% scaled by the enabling degree.
3457d = spnEnDegree(nvec, rx);
3458eff = min(d, rx.nservers);
3459if eff <= 0
3460 a = 0;
3461else
3462 a = rx.baseRate * eff;
3463end
3464end
3465
3466function nvec = applyPlaceCaps(nvec, deposited, pcapSlot, placeTotalCaps)
3467% Drop tokens a firing pushed above a Place per-class or total capacity. Only the
3468% just-deposited slots (Svec > 0) can overflow, so the clamp is local. Mirrors the
3469% Python native _apply_place_caps.
3470for a = 1:numel(deposited)
3471 j = deposited(a);
3472 if nvec(j) > pcapSlot(j)
3473 nvec(j) = pcapSlot(j);
3474 end
3475end
3476for p = 1:size(placeTotalCaps, 1)
3477 tcap = placeTotalCaps{p, 1};
3478 slots = placeTotalCaps{p, 2};
3479 excess = sum(nvec(slots)) - tcap;
3480 if excess > 0
3481 for a = 1:numel(deposited)
3482 if excess <= 0
3483 break
3484 end
3485 j = deposited(a);
3486 if any(slots == j) && nvec(j) > 0
3487 d = min(excess, nvec(j));
3488 nvec(j) = nvec(j) - d;
3489 excess = excess - d;
3490 end
3491 end
3492 end
3493end
3494end
3495
3496function nvec = spnCollapse(nvec, imm, maxsteps)
3497% Vanishing-marking elimination. Fire enabled immediate transitions until the
3498% marking is tangible: highest firing priority first, ties resolved in
3499% proportion to firing weight. Immediate firings take zero time and advance no
3500% clock, so the timed race only ever samples from tangible markings.
3501if isempty(imm)
3502 return
3503end
3504steps = 0;
3505while true
3506 enabled = [];
3507 for m = 1:numel(imm)
3508 if spnEnDegree(nvec, imm(m)) >= 1
3509 enabled(end+1) = m; %#ok<AGROW>
3510 end
3511 end
3512 if isempty(enabled)
3513 return
3514 end
3515 prios = zeros(1, numel(enabled));
3516 for i = 1:numel(enabled)
3517 prios(i) = imm(enabled(i)).prio;
3518 end
3519 top = enabled(prios == max(prios)); % larger firing priority = more urgent
3520 if numel(top) == 1
3521 pick = top;
3522 else
3523 w = zeros(1, numel(top));
3524 for i = 1:numel(top)
3525 w(i) = imm(top(i)).weight;
3526 end
3527 pick = top(spnWeightedDraw(w));
3528 end
3529 nvec = nvec + imm(pick).Svec;
3530 steps = steps + 1;
3531 if steps > maxsteps
3532 line_error(mfilename, 'Immediate-transition livelock: the vanishing-marking collapse did not reach a tangible marking.');
3533 end
3534end
3535end
3536
3537function idx = spnWeightedDraw(w)
3538% Index drawn in proportion to the nonnegative weight vector W.
3539tot = sum(w);
3540if tot <= 0
3541 idx = 1;
3542 return
3543end
3544c = cumsum(w) / tot;
3545idx = find(c > rand, 1);
3546if isempty(idx)
3547 idx = numel(w);
3548end
3549end
3550
Definition Station.m:287
Definition fjtag.m:157
Definition Station.m:245