LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_ssa_nrm_spn.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_SOLVERS_SSA_SOLVER_SSA_NRM_SPN_H
6#define LINE_SOLVERS_SSA_SOLVER_SSA_NRM_SPN_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverSSA on a STOCHASTIC PETRI NET: the port of `solver_ssa_nrm_spn`, the
12 * sub-engine `solver_ssa_nrm.m:174` hands a net carrying Transition nodes to.
13 *
14 * WHY IT IS A SEPARATE ENGINE. The queueing reaction builder of
15 * `solver_ssa_nrm.h` reads a state vector of JOBS PER (node, class, phase) and
16 * a departure is the absorption of a service process. A Petri net has neither:
17 * a Place holds TOKENS, a Transition mode is a reaction whose stoichiometry is
18 * the arc incidence, and nothing in the net has a service phase. The reference
19 * therefore branches on `any(sn.nodetype == NodeType.Transition)` before it
20 * builds a single reaction, and so does this port.
21 *
22 * THE MAPPING IS EXACT, not an approximation. A Place holds a per-class token
23 * count (one slot of the state vector), and a timed Transition mode is a
24 * reaction: input (enabling) arcs consume, output (firing) arcs produce.
25 * Enabling is a propensity gate -- every input place at or above its arc
26 * weight, every inhibitor place strictly below its threshold -- and a
27 * single-server mode fires at its exponential rate while an infinite or
28 * k-server mode fires at that rate times its enabling degree. One firing
29 * applies the stoichiometry once, which is the atomic GSPN firing the exact
30 * CTMC, JMT and the standard GSPN tools all take.
31 *
32 * IMMEDIATE MODES ARE NOT REACTIONS. They fire in zero time, so they are
33 * resolved by VANISHING-MARKING ELIMINATION: after every timed firing (and once
34 * on the initial marking) every enabled immediate mode is fired -- highest
35 * firing priority first and, among equal priority, drawn in proportion to
36 * firing weight -- until the marking is tangible. The timed race therefore only
37 * ever samples from tangible markings and the immediate modes consume no
38 * simulated time.
39 *
40 * A SOURCE IS NOT A TRANSITION and needs a reaction of its own, or the Place it
41 * feeds stays empty and the net deadlocks on the first draw. Splitting a
42 * Poisson stream by independent routing probabilities yields independent
43 * Poisson streams, so the edge of probability p carries rate lambda*p exactly;
44 * the reaction has an EMPTY enabling set (a constant propensity) and deposits
45 * one token into the routed Place slot.
46 *
47 * WHAT IT REFUSES, and each refusal is the reference's: a non-exponential timed
48 * firing (representing an in-flight firing's phase would need per-mode phase
49 * state the reaction network does not carry), a non-exponential Source arrival
50 * into a Place, a Source that reaches no Place, an infinite initial marking, and
51 * a marking-dependent firing rate -- the last under EVERY SSA method, since no
52 * SSA engine applies the g(marking) multiplier and answering with the nominal
53 * rate would be silently wrong. `spn_nrm_supported` is the predicate; the
54 * `default` dispatch reads it to decide whether to prefer this engine, and the
55 * explicit `nrm` arm reads it to refuse by name.
56 *
57 * THE SLOT LAYOUT IS FLAT, one slot per (node, class), where the queueing
58 * engine's is per (node, class, PHASE). Nothing here has a phase: a Place holds
59 * tokens rather than jobs in service, and the reference's own SPN path only
60 * ever addresses `phOff(place, class) + 1`, the first phase slot of the pair.
61 * The two layouts therefore agree on every slot this engine touches.
62 *
63 * NOT A SAMPLE-PATH TWIN OF THE REFERENCE. The random stream is this port's
64 * MT19937 (`SsaRng`) and the draw order is the algorithm's, so a seeded run
65 * matches the reference STATISTICALLY and never bit for bit, exactly as the
66 * queueing NRM does.
67 */
68
69#include <algorithm>
70#include <cmath>
71#include <cstddef>
72#include <limits>
73#include <string>
74#include <vector>
75
78#include "line/util/error.h"
80#include "line/util/matrix.h"
81
82namespace line {
83namespace ssa {
84
85namespace detail {
86
87/**
88 * One reaction of the net: a timed Transition mode, an immediate one, or a
89 * Source arrival. The reference's `spnEmptyRx` record, field for field.
90 */
91struct SpnReaction {
92 std::size_t node = 0; ///< 1-based node the reaction belongs to
93 std::size_t mode = 0; ///< 1-based mode; 0 marks a Source arrival
94 /** Stoichiometry over the (node, class) slots: consume negative, produce positive. */
95 std::vector<double> S;
96 std::vector<std::size_t> en_slot; ///< input arcs: the slots read
97 std::vector<double> en_w; ///< and the tokens each one needs
98 std::vector<std::size_t> inh_slot; ///< inhibitor arcs: the slots read
99 std::vector<double> inh_thr; ///< and the count at which each blocks
100 double base_rate = 0.0; ///< exponential firing rate, sum(D1)
101 double nservers = 1.0; ///< concurrent firings the mode may run
102 double weight = 1.0; ///< immediate: weight among equal priorities
103 double prio = 1.0; ///< immediate: firing priority
104 std::vector<std::size_t> dep_slots; ///< slots this reaction DEPOSITS into
105};
106
107/**
108 * Enabling degree: how many concurrent firings the marking supports, the
109 * minimum over the input arcs of floor(tokens / weight), zeroed by any active
110 * inhibitor. A mode with no input arc is single-degree -- which is what makes a
111 * Source arrival a constant-propensity reaction.
112 */
113inline double spn_en_degree(const std::vector<double>& n, const SpnReaction& rx) {
114 for (std::size_t i = 0; i < rx.inh_slot.size(); ++i)
115 if (n[rx.inh_slot[i]] >= rx.inh_thr[i]) return 0.0;
116 if (rx.en_slot.empty()) return 1.0;
117 double d = std::numeric_limits<double>::infinity();
118 for (std::size_t i = 0; i < rx.en_slot.size(); ++i)
119 d = std::min(d, std::floor(n[rx.en_slot[i]] / rx.en_w[i]));
120 return d;
121}
122
123/**
124 * Propensity of a timed mode: the exponential rate times the effective server
125 * count, min(enabling degree, mode servers). A single-server mode therefore
126 * fires at its rate whenever enabled and an infinite-server one at the rate
127 * scaled by the enabling degree.
128 */
129inline double spn_propensity(const std::vector<double>& n, const SpnReaction& rx) {
130 const double eff = std::min(spn_en_degree(n, rx), rx.nservers);
131 return eff <= 0.0 ? 0.0 : rx.base_rate * eff;
132}
133
134/**
135 * `ssa_firingdep_refusal.m`: the NRM cannot answer a net whose firing rates
136 * depend on the marking, and says so by name.
137 *
138 * The NRM builds one CONSTANT-propensity reaction per timed mode, so the
139 * g(marking) multiplier has no place in it and answering with the nominal rate
140 * would be a wrong number under the caller's own method name.
141 *
142 * NARROWER THAN THE REFERENCE'S, DELIBERATELY. MATLAB raises this from
143 * `solver_ssa_analyzer` before an engine is chosen, so it refuses under every
144 * SSA method; this port refuses only the NRM, because its SERIAL engine reaches
145 * the multiplier for real -- `state_events.h:3649` scales the mode's firing rate
146 * by `firingdep[m](marking)` on the state it is leaving, which is exactly the
147 * semantics SolverCTMC gives it. Raising here as well would withdraw an answer
148 * this port computes correctly. The gate therefore reads it with `raise=false`,
149 * so `default` FALLS BACK to the serial engine, and with `raise=true` only when
150 * the caller named `nrm`.
151 */
152template <class T>
153bool ssa_check_firingdep(const qn::NetworkStruct<T>& sn, bool raise = true) {
154 for (typename std::map<std::size_t, qn::TransitionParam<T>>::const_iterator it =
155 sn.transparam.begin();
156 it != sn.transparam.end(); ++it) {
157 const qn::TransitionParam<T>& tp = it->second;
158 for (std::size_t m = 0; m < tp.nmodes && m < tp.firingdep.size(); ++m) {
159 if (!tp.firingdep[m]) continue;
160 if (!raise) return false;
161 const std::size_t ind = it->first;
162 throw UnsupportedError(
163 "SolverSSA: transition '" +
164 (ind >= 1 && ind <= sn.nodes.size() ? sn.nodes[ind - 1].name : std::string("?")) +
165 "' mode " + std::to_string(m + 1) +
166 " uses a marking-dependent firing rate (setFiringRateDependence), which the "
167 "NRM does not support: it builds one constant-propensity reaction per timed "
168 "mode and never evaluates the handle. Ask for method='serial', which applies "
169 "it, or use SolverCTMC or SolverLDES");
170 }
171 }
172 return true;
173}
174
175/**
176 * `ssa_nrm_guards.m`'s `g.spn`: the shapes the SPN reaction builder can read.
177 *
178 * FOUR CONDITIONS, and all four are the reference's. Each one the reference
179 * RAISES from inside the builder, and each is a condition the serial engine
180 * serves, so `default` falls back rather than refusing -- which is why this is
181 * asked with `raise = false` by the eligibility test and with `raise = true`
182 * only when the caller named `nrm` itself.
183 */
184template <class T>
185bool spn_nrm_supported(const qn::NetworkStruct<T>& sn, bool raise = true) {
186 const std::size_t I = sn.nodes.size();
187 const std::size_t K = sn.nclasses;
188 bool any_transition = false;
189 for (std::size_t i = 0; i < I; ++i)
190 if (sn.nodes[i].nodetype == qn::NodeType::Transition) any_transition = true;
191 if (!any_transition) return true;
192 if (!ssa_check_firingdep(sn, raise)) return false;
193
194 for (std::size_t i = 0; i < I; ++i) {
195 if (sn.nodes[i].nodetype == qn::NodeType::Transition) {
196 const typename std::map<std::size_t, qn::TransitionParam<T>>::const_iterator it =
197 sn.transparam.find(i + 1);
198 if (it == sn.transparam.end()) continue;
199 const qn::TransitionParam<T>& tp = it->second;
200 for (std::size_t m = 0; m < tp.nmodes; ++m) {
201 if (m < tp.timing.size() && tp.timing[m] == lang::TimingStrategy::IMMEDIATE)
202 continue;
203 const bool one_phase = m < tp.firingphases.size() && tp.firingphases[m] == 1;
204 const bool has_proc = m < tp.firingproc.size() && tp.firingproc[m].D1.rows() > 0;
205 if (one_phase && has_proc) continue;
206 if (!raise) return false;
207 throw UnsupportedError(
208 "SolverSSA(method='nrm'): transition '" + sn.nodes[i].name + "' mode " +
209 std::to_string(m + 1) +
210 " has a non-exponential timed firing. The reaction network carries one "
211 "constant-rate reaction per mode and no per-mode phase, so an in-flight "
212 "firing has nowhere to keep its phase; use method='serial'");
213 }
214 } else if (sn.nodes[i].nodetype == qn::NodeType::Source) {
215 const std::size_t ist = sn.nodes[i].station;
216 if (ist == 0) continue;
217 for (std::size_t r = 0; r < K; ++r) {
218 const double lambda = num_traits<T>::to_double(sn.rates(ist - 1, r));
219 if (std::isnan(lambda) || lambda <= 0.0) continue;
220 if (sn.service[ist - 1][r].type != lang::ProcessType::EXP) {
221 if (!raise) return false;
222 throw UnsupportedError(
223 "SolverSSA(method='nrm'): source '" + sn.nodes[i].name + "' class '" +
224 sn.classes[r].name +
225 "' has a non-exponential arrival. An arrival into a Place is one "
226 "constant-propensity reaction here, which only a Poisson stream is; "
227 "use method='serial'");
228 }
229 bool feeds = false;
230 for (std::size_t j = 0; j < I && !feeds; ++j) {
231 if (sn.nodes[j].nodetype != qn::NodeType::Place) continue;
232 for (std::size_t s = 0; s < K && !feeds; ++s)
233 if (num_traits<T>::to_double(sn.rtnodes(i * K + r, j * K + s)) > 0.0)
234 feeds = true;
235 }
236 if (!feeds) {
237 if (!raise) return false;
238 throw UnsupportedError(
239 "SolverSSA(method='nrm'): source '" + sn.nodes[i].name + "' class '" +
240 sn.classes[r].name +
241 "' routes to no Place. The SPN path deposits an arrival into a Place "
242 "slot and has nowhere else to put one; use method='serial'");
243 }
244 }
245 } else if (sn.nodes[i].nodetype == qn::NodeType::Place) {
246 const typename std::map<std::size_t, std::vector<T>>::const_iterator im =
247 sn.initmarking.find(i + 1);
248 if (im == sn.initmarking.end()) continue;
249 for (std::size_t r = 0; r < im->second.size(); ++r) {
250 if (std::isfinite(num_traits<T>::to_double(im->second[r]))) continue;
251 if (!raise) return false;
252 throw UnsupportedError("SolverSSA(method='nrm'): place '" + sn.nodes[i].name +
253 "' declares an infinite initial marking, which the "
254 "reaction network cannot count; use method='serial'");
255 }
256 }
257 }
258 return true;
259}
260
261} // namespace detail
262
263/**
264 * The SPN Next-Reaction-Method engine, `solver_ssa_nrm_spn` of the reference.
265 *
266 * DOUBLE ONLY, for the reason `NrmEngine` is: the sample path is generated from
267 * exponential clocks, which are logarithms of uniform draws, so there is no
268 * exact value a wider arithmetic could carry. The analyzer refuses a non-double
269 * backend by name before this class is instantiated.
270 */
271template <class T>
273public:
275 : sn_(sn), opt_(opt), rng_(opt.seed) {
276 build();
277 }
278
279 /** Run `opt.samples` firings and return the time-averaged metrics. */
281
282 /** The reaction count, for the tests that assert the builder. */
283 std::size_t nreactions() const { return rx_.size(); }
284 /** The immediate-mode count, likewise. */
285 std::size_t nimmediate() const { return imm_.size(); }
286
287private:
288 using Rx = detail::SpnReaction;
289
290 const qn::NetworkStruct<T>& sn_;
291 SsaOptions opt_;
292 SsaRng rng_;
293
294 std::size_t I_ = 0, K_ = 0, M_ = 0, NS_ = 0;
295
296 std::vector<Rx> rx_; ///< the timed reactions, the ones that race
297 std::vector<Rx> imm_; ///< the immediate modes, resolved between races
298 /** Timed reactions consuming from each (0-based node, class): the Place throughput. */
299 std::vector<std::vector<std::vector<std::size_t>>> consumers_;
300 /** Arrival reactions injecting at each (0-based Source node, class). */
301 std::vector<std::vector<std::vector<std::size_t>>> producers_;
302
303 std::vector<double> nvec0_; ///< the initial marking, before the collapse
304 std::vector<double> pcap_slot_; ///< per-(place, class) capacity, infinite when none
305 /** Per-place total capacities: the bound and the slots it is taken over. */
306 std::vector<std::pair<double, std::vector<std::size_t>>> place_total_caps_;
307 bool has_caps_ = false;
308
309 /** The livelock guard of the vanishing-marking collapse, the reference's 1e5. */
310 static const std::size_t kMaxImmSteps = 100000;
311
312 std::size_t slot(std::size_t node0, std::size_t cls) const { return node0 * K_ + cls; }
313
314 void build();
315 Rx build_mode(std::size_t ind0, std::size_t m) const;
316 void apply_caps(std::vector<double>& n, const std::vector<std::size_t>& deposited) const;
317 void collapse(std::vector<double>& n);
318};
319
320/**
321 * The reaction list, the initial marking and the capacity tables.
322 *
323 * ONE PASS PER NODE KIND, in the reference's order: the Transition modes become
324 * reactions (timed) or immediate records, then the Source arrivals become
325 * constant-propensity reactions, then the Places contribute the marking and
326 * their capacities.
327 */
328template <class T>
329void NrmSpnEngine<T>::build() {
330 I_ = sn_.nodes.size();
331 K_ = sn_.nclasses;
332 M_ = sn_.nstations;
333 NS_ = I_ * K_;
334 consumers_.assign(I_, std::vector<std::vector<std::size_t>>(K_));
335 producers_.assign(I_, std::vector<std::vector<std::size_t>>(K_));
336
337 // Refused here as well as in the gate, as the reference raises it from
338 // inside its own builder too (`solver_ssa_nrm.m:3426`): that is the path a
339 // caller reaches with the checks disabled, and this engine must not run a
340 // net whose rates it would silently ignore however it was entered.
341 detail::ssa_check_firingdep(sn_, true);
342
343 for (std::size_t ind = 0; ind < I_; ++ind) {
344 if (sn_.nodes[ind].nodetype != qn::NodeType::Transition) continue;
345 const typename std::map<std::size_t, qn::TransitionParam<T>>::const_iterator it =
346 sn_.transparam.find(ind + 1);
347 if (it == sn_.transparam.end()) continue;
348 const qn::TransitionParam<T>& tp = it->second;
349 for (std::size_t m = 0; m < tp.nmodes; ++m) {
350 const Rx rec = build_mode(ind, m);
351 if (m < tp.timing.size() && tp.timing[m] == lang::TimingStrategy::IMMEDIATE) {
352 imm_.push_back(rec);
353 continue;
354 }
355 rx_.push_back(rec);
356 const std::size_t ridx = rx_.size() - 1;
357 for (std::size_t a = 0; a < rec.en_slot.size(); ++a)
358 consumers_[rec.en_slot[a] / K_][rec.en_slot[a] % K_].push_back(ridx);
359 }
360 }
361
362 // Source arrivals. Thinning a Poisson stream by the independent routing
363 // probabilities leaves independent Poisson streams, so one reaction per
364 // routed (Source, class) -> (Place, class) edge carries rate lambda*p
365 // exactly. `producers_` is what makes the Source station report that rate as
366 // its throughput, which is the open class's reference-station throughput.
367 for (std::size_t ind = 0; ind < I_; ++ind) {
368 if (sn_.nodes[ind].nodetype != qn::NodeType::Source) continue;
369 const std::size_t ist = sn_.nodes[ind].station;
370 if (ist == 0) continue;
371 for (std::size_t r = 0; r < K_; ++r) {
372 const double lambda = num_traits<T>::to_double(sn_.rates(ist - 1, r));
373 if (std::isnan(lambda) || lambda <= 0.0) continue;
374 if (sn_.service[ist - 1][r].type != lang::ProcessType::EXP)
375 throw UnsupportedError(
376 "solver_ssa_nrm_spn: source '" + sn_.nodes[ind].name + "' class '" +
377 sn_.classes[r].name +
378 "' has a non-exponential arrival, which the SPN reaction network cannot "
379 "express; use method='serial' or SolverJMT");
380 bool found_place = false;
381 for (std::size_t jnd = 0; jnd < I_; ++jnd) {
382 if (sn_.nodes[jnd].nodetype != qn::NodeType::Place) continue;
383 for (std::size_t s = 0; s < K_; ++s) {
384 const double p =
385 num_traits<T>::to_double(sn_.rtnodes(ind * K_ + r, jnd * K_ + s));
386 if (p <= 0.0) continue;
387 found_place = true;
388 Rx rec;
389 rec.node = ind + 1;
390 rec.mode = 0;
391 rec.S.assign(NS_, 0.0);
392 rec.S[slot(jnd, s)] += 1.0;
393 rec.base_rate = lambda * p;
394 rec.nservers = 1.0;
395 rec.dep_slots.push_back(slot(jnd, s));
396 rx_.push_back(rec);
397 producers_[ind][r].push_back(rx_.size() - 1);
398 }
399 }
400 if (!found_place)
401 throw UnsupportedError("solver_ssa_nrm_spn: source '" + sn_.nodes[ind].name +
402 "' class '" + sn_.classes[r].name +
403 "' does not route to any Place; the SPN path needs a "
404 "Source->Place arc");
405 }
406 }
407
408 if (rx_.empty())
409 throw InputError(
410 "solver_ssa_nrm_spn: the stochastic Petri net has no timed reaction; there is "
411 "nothing to simulate");
412
413 // THE INITIAL MARKING, in the reference's two steps. MATLAB reads it off
414 // `sn.state`, which `initDefault` fills by putting each closed class's whole
415 // population at its reference station and which `Place.setState` then
416 // overrides. This port has no State package -- the same position
417 // `NrmEngine::build_initial_state` takes, and for the same reason -- so the
418 // two steps are taken directly: a closed class whose reference station IS a
419 // Place seeds that Place with its population, and a DECLARED `initmarking`
420 // then replaces that Place's whole row, because an explicit marking is a
421 // statement about every class of the place and not an addition to one.
422 //
423 // WITHOUT THE FIRST STEP a closed Petri net starts empty and deadlocks on the
424 // first draw, since `initmarking` is only written by an explicit
425 // `set_initial_marking` and a net that declares its tokens through a
426 // ClosedClass population carries none.
427 nvec0_.assign(NS_, 0.0);
428 for (std::size_t r = 0; r < K_; ++r) {
429 const double pop = sn_.classes[r].population;
430 if (!std::isfinite(pop) || pop <= 0.0) continue;
431 const std::size_t rs = sn_.classes[r].refstat;
432 if (rs < 1 || rs > M_) continue;
433 const std::size_t ind = sn_.station_to_node[rs - 1] - 1;
434 if (sn_.nodes[ind].nodetype != qn::NodeType::Place) continue;
435 nvec0_[slot(ind, r)] = pop;
436 }
437 for (std::size_t ind = 0; ind < I_; ++ind) {
438 if (sn_.nodes[ind].nodetype != qn::NodeType::Place) continue;
439 const typename std::map<std::size_t, std::vector<T>>::const_iterator im =
440 sn_.initmarking.find(ind + 1);
441 if (im == sn_.initmarking.end()) continue;
442 for (std::size_t r = 0; r < K_; ++r) {
443 const double v =
444 r < im->second.size() ? num_traits<T>::to_double(im->second[r]) : 0.0;
445 if (!std::isfinite(v))
446 throw UnsupportedError("solver_ssa_nrm_spn: place '" + sn_.nodes[ind].name +
447 "' declares an infinite initial marking, which the "
448 "reaction network cannot count");
449 nvec0_[slot(ind, r)] = v > 0.0 ? v : 0.0;
450 }
451 }
452
453 // FINITE-CAPACITY PLACES DROP. A Place with a finite per-class (`classcap`)
454 // or total (`cap`) capacity loses any arriving token that would exceed it,
455 // which is the loss semantics JMT and the exact CTMC both give it: an
456 // M/M/1/1 Place at rho = 0.5 holds a mean 1/3, not the unbounded 1. Without
457 // the clamp the deposit accumulates past the bound.
458 const double inf = std::numeric_limits<double>::infinity();
459 pcap_slot_.assign(NS_, inf);
460 for (std::size_t ind = 0; ind < I_; ++ind) {
461 if (sn_.nodes[ind].nodetype != qn::NodeType::Place) continue;
462 const std::size_t ist = sn_.nodes[ind].station;
463 if (ist == 0) continue;
464 std::vector<std::size_t> slots_here;
465 for (std::size_t r = 0; r < K_; ++r) {
466 slots_here.push_back(slot(ind, r));
467 if (ist - 1 < sn_.classcap.size() && r < sn_.classcap[ist - 1].size()) {
468 const double cc = sn_.classcap[ist - 1][r];
469 if (std::isfinite(cc)) pcap_slot_[slot(ind, r)] = cc;
470 }
471 }
472 if (ist - 1 < sn_.cap.size() && std::isfinite(sn_.cap[ist - 1]))
473 place_total_caps_.push_back(std::make_pair(sn_.cap[ist - 1], slots_here));
474 }
475 has_caps_ = !place_total_caps_.empty();
476 for (std::size_t j = 0; j < NS_ && !has_caps_; ++j)
477 if (std::isfinite(pcap_slot_[j])) has_caps_ = true;
478}
479
480/**
481 * `spnBuildMode`: the reaction record of transition `ind0` mode `m`.
482 *
483 * The enabling, firing and inhibiting matrices are (nnodes x nclasses), so a
484 * nonzero entry (p, c) is an arc on class c of place p and its slot is the
485 * (p, c) pair. An inhibiting entry is an arc only when it is FINITE: infinity
486 * is how "this place never blocks the mode" is written, here as in the
487 * reference.
488 */
489template <class T>
490typename NrmSpnEngine<T>::Rx NrmSpnEngine<T>::build_mode(std::size_t ind0, std::size_t m) const {
491 const qn::TransitionParam<T>& tp = sn_.transparam.at(ind0 + 1);
492 Rx rec;
493 rec.node = ind0 + 1;
494 rec.mode = m + 1;
495 rec.S.assign(NS_, 0.0);
496
497 if (m < tp.enabling.size()) {
498 const Matrix<T>& en = tp.enabling[m];
499 for (std::size_t p = 0; p < en.rows() && p < I_; ++p)
500 for (std::size_t c = 0; c < en.cols() && c < K_; ++c) {
501 const double w = num_traits<T>::to_double(en(p, c));
502 if (w == 0.0) continue;
503 rec.en_slot.push_back(slot(p, c));
504 rec.en_w.push_back(w);
505 rec.S[slot(p, c)] -= w;
506 }
507 }
508 if (m < tp.firing.size()) {
509 const Matrix<T>& fir = tp.firing[m];
510 for (std::size_t p = 0; p < fir.rows() && p < I_; ++p)
511 for (std::size_t c = 0; c < fir.cols() && c < K_; ++c) {
512 const double w = num_traits<T>::to_double(fir(p, c));
513 if (w == 0.0) continue;
514 rec.S[slot(p, c)] += w;
515 }
516 }
517 if (m < tp.inhibiting.size()) {
518 const Matrix<T>& inh = tp.inhibiting[m];
519 for (std::size_t p = 0; p < inh.rows() && p < I_; ++p)
520 for (std::size_t c = 0; c < inh.cols() && c < K_; ++c) {
521 const double thr = num_traits<T>::to_double(inh(p, c));
522 if (!std::isfinite(thr)) continue;
523 rec.inh_slot.push_back(slot(p, c));
524 rec.inh_thr.push_back(thr);
525 }
526 }
527 for (std::size_t j = 0; j < NS_; ++j)
528 if (rec.S[j] > 0.0) rec.dep_slots.push_back(j);
529
530 // The exponential firing rate is the single-phase completion rate sum(D1).
531 // A non-exponential firing is refused by `spn_nrm_supported` upstream and
532 // again here, so no route reaches the run loop with a rate it invented.
533 if (!(m < tp.timing.size() && tp.timing[m] == lang::TimingStrategy::IMMEDIATE)) {
534 const bool one_phase = m < tp.firingphases.size() && tp.firingphases[m] == 1;
535 if (!one_phase || m >= tp.firingproc.size() || tp.firingproc[m].D1.rows() == 0)
536 throw UnsupportedError("solver_ssa_nrm_spn: transition '" + sn_.nodes[ind0].name +
537 "' mode " + std::to_string(m + 1) +
538 " has a non-exponential firing, which the SPN reaction "
539 "network cannot express; use method='serial'");
540 const Matrix<T>& d1 = tp.firingproc[m].D1;
541 double s = 0.0;
542 for (std::size_t a = 0; a < d1.rows(); ++a)
543 for (std::size_t b = 0; b < d1.cols(); ++b) s += num_traits<T>::to_double(d1(a, b));
544 rec.base_rate = s;
545 }
546 rec.nservers = m < tp.nmodeservers.size() ? tp.nmodeservers[m] : 1.0;
547 if (std::isinf(rec.nservers)) rec.nservers = lang::GlobalConstants::MaxInt;
548 rec.weight = m < tp.fireweight.size() ? num_traits<T>::to_double(tp.fireweight[m]) : 1.0;
549 rec.prio = m < tp.firingprio.size() ? tp.firingprio[m] : 1.0;
550 return rec;
551}
552
553/**
554 * `applyPlaceCaps`: drop the tokens a firing pushed above a Place's bound.
555 *
556 * Only the just-deposited slots can overflow, so the clamp is local to them --
557 * which also keeps it from disturbing a marking that was already over a bound
558 * when the engine started.
559 */
560template <class T>
561void NrmSpnEngine<T>::apply_caps(std::vector<double>& n,
562 const std::vector<std::size_t>& deposited) const {
563 for (std::size_t a = 0; a < deposited.size(); ++a) {
564 const std::size_t j = deposited[a];
565 if (n[j] > pcap_slot_[j]) n[j] = pcap_slot_[j];
566 }
567 for (std::size_t p = 0; p < place_total_caps_.size(); ++p) {
568 const double tcap = place_total_caps_[p].first;
569 const std::vector<std::size_t>& slots = place_total_caps_[p].second;
570 double total = 0.0;
571 for (std::size_t a = 0; a < slots.size(); ++a) total += n[slots[a]];
572 double excess = total - tcap;
573 for (std::size_t a = 0; a < deposited.size() && excess > 0.0; ++a) {
574 const std::size_t j = deposited[a];
575 if (std::find(slots.begin(), slots.end(), j) == slots.end()) continue;
576 if (n[j] <= 0.0) continue;
577 const double d = std::min(excess, n[j]);
578 n[j] -= d;
579 excess -= d;
580 }
581 }
582}
583
584/**
585 * `spnCollapse`: vanishing-marking elimination.
586 *
587 * Fire enabled immediate modes until the marking is tangible, highest firing
588 * priority first and ties drawn in proportion to firing weight. These firings
589 * take zero time and advance no clock, so the timed race only ever resumes from
590 * a tangible marking.
591 */
592template <class T>
593void NrmSpnEngine<T>::collapse(std::vector<double>& n) {
594 if (imm_.empty()) return;
595 std::size_t steps = 0;
596 while (true) {
597 std::vector<std::size_t> enabled;
598 for (std::size_t m = 0; m < imm_.size(); ++m)
599 if (detail::spn_en_degree(n, imm_[m]) >= 1.0) enabled.push_back(m);
600 if (enabled.empty()) return;
601 double top_prio = -std::numeric_limits<double>::infinity();
602 for (std::size_t i = 0; i < enabled.size(); ++i)
603 top_prio = std::max(top_prio, imm_[enabled[i]].prio);
604 std::vector<std::size_t> top;
605 std::vector<double> w;
606 for (std::size_t i = 0; i < enabled.size(); ++i)
607 if (imm_[enabled[i]].prio == top_prio) {
608 top.push_back(enabled[i]);
609 w.push_back(imm_[enabled[i]].weight);
610 }
611 const std::size_t pick = top.size() == 1 ? top[0] : top[rng_.draw(w)];
612 for (std::size_t j = 0; j < NS_; ++j) n[j] += imm_[pick].S[j];
613 if (++steps > kMaxImmSteps)
614 throw NumericError(
615 "solver_ssa_nrm_spn: immediate-transition livelock -- the vanishing-marking "
616 "collapse did not reach a tangible marking");
617 }
618}
619
620/**
621 * The Next-Reaction-Method run loop over the tangible markings.
622 *
623 * EVERY PROPENSITY IS REFRESHED after a firing rather than a dependency subset:
624 * a firing plus its immediate cascade can change any place, and the reaction
625 * count of a Petri net is small, so refreshing all of them removes any
626 * dependency-graph blind spot at no cost worth measuring.
627 *
628 * A PLACE IS AN INF STATION, so its utilization IS its mean token count -- the
629 * SPN convention the CTMC analyzer also reports -- and its throughput is the
630 * summed firing rate of the modes consuming from it, counted once per firing.
631 */
632template <class T>
634 const std::size_t nrx = rx_.size();
635 SsaSolution out;
636 out.QN = Matrix<double>(M_, K_, 0.0);
637 out.UN = Matrix<double>(M_, K_, 0.0);
638 out.RN = Matrix<double>(M_, K_, 0.0);
639 out.TN = Matrix<double>(M_, K_, 0.0);
640 out.CN.assign(K_, 0.0);
641 out.XN.assign(K_, 0.0);
642 out.StartN = Matrix<double>(M_, K_, 0.0);
643 out.PreemptN = Matrix<double>(M_, K_, 0.0);
644 out.method = "nrm";
645
646 std::vector<double> nvec = nvec0_;
647 collapse(nvec);
648 if (has_caps_) {
649 std::vector<std::size_t> all(NS_);
650 for (std::size_t j = 0; j < NS_; ++j) all[j] = j;
651 apply_caps(nvec, all);
652 }
653
654 std::vector<double> Ak(nrx, 0.0), Pk(nrx, 0.0), Tk(nrx, 0.0), tau(nrx, 0.0);
655 const double inf = std::numeric_limits<double>::infinity();
656 for (std::size_t k = 0; k < nrx; ++k) {
657 Ak[k] = detail::spn_propensity(nvec, rx_[k]);
658 Pk[k] = -std::log(rng_.uniform());
659 tau[k] = Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : inf;
660 }
661
662 double total_time = 0.0;
663 std::size_t n = 0;
664 line::util::LineConsole::loop("drawing the sample path: %zu samples requested",
665 static_cast<std::size_t>(opt_.samples));
666 const std::size_t console_every = std::max<std::size_t>(1, opt_.samples / 20);
667 for (; n < opt_.samples; ++n) {
668 if ((n + 1) % console_every == 0)
670 static_cast<long>((n + 1) / console_every),
671 "simulated %zu of %zu samples (%.0f%%), simulated time %.4g", n + 1,
672 static_cast<std::size_t>(opt_.samples),
673 100.0 * static_cast<double>(n + 1) / static_cast<double>(opt_.samples),
674 total_time);
675 std::size_t kfire = 0;
676 double dt = inf;
677 for (std::size_t k = 0; k < nrx; ++k)
678 if (tau[k] < dt) {
679 dt = tau[k];
680 kfire = k;
681 }
682 if (std::isinf(dt))
683 throw NumericError(
684 "solver_ssa_nrm_spn: deadlock -- no transition is enabled, so the sample path "
685 "cannot advance");
686 total_time += dt;
687
688 for (std::size_t ist = 0; ist < M_; ++ist) {
689 const std::size_t ind = sn_.station_to_node[ist] - 1;
690 for (std::size_t c = 0; c < K_; ++c) {
691 const double tokens = nvec[slot(ind, c)];
692 out.QN(ist, c) += tokens * dt;
693 out.UN(ist, c) += tokens * dt;
694 double depr = 0.0;
695 const std::vector<std::size_t>& cons = consumers_[ind][c];
696 for (std::size_t a = 0; a < cons.size(); ++a) depr += Ak[cons[a]];
697 // A Source has no consuming transition: its throughput is the
698 // aggregate arrival rate it injects, which is what makes the
699 // reference station report the open class's arrival rate.
700 const std::vector<std::size_t>& prod = producers_[ind][c];
701 for (std::size_t a = 0; a < prod.size(); ++a) depr += Ak[prod[a]];
702 out.TN(ist, c) += depr * dt;
703 }
704 }
705
706 // One atomic firing, then the immediate cascade the new marking enabled.
707 // A capacity-bound Place loses the tokens pushed above its bound BEFORE
708 // the cascade sees the marking.
709 for (std::size_t j = 0; j < NS_; ++j) nvec[j] += rx_[kfire].S[j];
710 if (has_caps_) apply_caps(nvec, rx_[kfire].dep_slots);
711 collapse(nvec);
712
713 // Advance the Gibson and Bruck clocks with the PRE-firing propensities,
714 // then refresh every propensity from the new marking.
715 for (std::size_t k = 0; k < nrx; ++k) Tk[k] += Ak[k] * dt;
716 for (std::size_t k = 0; k < nrx; ++k) Ak[k] = detail::spn_propensity(nvec, rx_[k]);
717 Pk[kfire] -= std::log(rng_.uniform());
718 for (std::size_t k = 0; k < nrx; ++k)
719 tau[k] = Ak[k] > 0.0 ? (Pk[k] - Tk[k]) / Ak[k] : inf;
720 }
721
722 if (total_time > 0.0)
723 for (std::size_t ist = 0; ist < M_; ++ist)
724 for (std::size_t c = 0; c < K_; ++c) {
725 out.QN(ist, c) /= total_time;
726 out.UN(ist, c) /= total_time;
727 out.TN(ist, c) /= total_time;
728 }
729 for (std::size_t c = 0; c < K_; ++c) {
730 out.XN[c] = out.TN(sn_.classes[c].refstat - 1, c);
731 for (std::size_t ist = 0; ist < M_; ++ist)
732 out.RN(ist, c) = out.TN(ist, c) > 0.0 ? out.QN(ist, c) / out.TN(ist, c) : 0.0;
733 if (out.XN[c] > 0.0) out.CN[c] = sn_.classes[c].population / out.XN[c];
734 }
735 out.simulated_time = total_time;
736 out.samples = n;
737 return out;
738}
739
740} // namespace ssa
741} // namespace line
742
743#endif // LINE_SOLVERS_SSA_SOLVER_SSA_NRM_SPN_H
InputError(const std::string &what)
Definition error.h:39
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
NrmSpnEngine(const qn::NetworkStruct< T > &sn, const SsaOptions &opt)
SsaSolution run()
Run opt.samples firings and return the time-averaged metrics.
std::size_t nimmediate() const
The immediate-mode count, likewise.
std::size_t nreactions() const
The reaction count, for the tests that assert the builder.
The uniform source, MATLAB's rand.
Definition ssa_types.h:149
static void loop(const char *fmt,...)
Announce an iteration loop and reset its reporting budget.
static void iter(long k, const char *fmt,...)
Report iteration k of the current loop.
The exception types the port throws.
Running progress log of a LINE solver run (the "solver console").
Dense matrix and non-owning view.
@ IMMEDIATE
fires with zero delay, resolved by weight and priority
Definition lang_types.h:365
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
Controls, results and the random source of SolverSSA.
static constexpr double MaxInt
Stand-in for an unbounded COUNT, MATLAB GlobalConstants.MaxInt.
Definition lang_types.h:771
Controls, defaulting to SolverOptions('SSA') in the reference.
Definition ssa_types.h:69
What the analyzer returns, in the same shape as the MVA and fluid results.
Definition ssa_types.h:101
std::vector< double > XN
Definition ssa_types.h:103
std::vector< double > CN
Definition ssa_types.h:103
Matrix< double > UN
Definition ssa_types.h:102
Matrix< double > RN
Definition ssa_types.h:102
Matrix< double > StartN
The DERIVED rates, (nstations x nclasses): how often per unit time a class-r service STARTS at statio...
Definition ssa_types.h:111
double simulated_time
Simulated time the metrics are averaged over; the reference's totalTime.
Definition ssa_types.h:115
Matrix< double > TN
Definition ssa_types.h:102
std::size_t samples
Reaction firings actually performed.
Definition ssa_types.h:117
Matrix< double > QN
Definition ssa_types.h:102
std::string method
The concrete algorithm, as the reference's method.
Definition ssa_types.h:113
Matrix< double > PreemptN
Definition ssa_types.h:111