LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_fluid.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_FLUID_SOLVER_FLUID_H
6#define LINE_SOLVERS_FLUID_SOLVER_FLUID_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverFluid: the `closing` method, a port of `solver_fluid.m`,
12 * `solver_fluid_iteration.m` and `solver_fluid_closing.m`.
13 *
14 * WHAT THE SOLVER DOES. The fluid approximation replaces the integer queue
15 * lengths of the CTMC with real-valued masses and follows their mean drift.
16 * The drift is built in `fluid_odes.h`; this file integrates it and turns the
17 * end state into the usual Q/U/R/T/C/X table.
18 *
19 * WHY THE INTEGRATION IS AN ITERATION RATHER THAN ONE LONG SOLVE. The steady
20 * state is the drift's fixed point, and how long it takes to get there is set
21 * by the SLOWEST rate in the model. The reference integrates to
22 * 10*iter/min(rate) on iteration `iter`, restarting from the previous end
23 * state: each pass buys another ten mean events of the slowest transition. A
24 * single solve to a guessed horizon either stops short on a stiff model or
25 * wastes most of its steps on one that settled early.
26 *
27 * A NOTE ON THE CONVERGENCE TEST. `movedMassRatio` is the mass moved over ONE
28 * window, and for a mode relaxing at rate r it underestimates the distance
29 * still to go by (1-exp(-r*window)). On M/M/1 at rho = 0.9 with `minnormal` the
30 * fixed point is Q = 7.021524680 and stopping at `iter_tol = 1e-4` lands on
31 * 7.014672, out by 0.1%. Both `solver_fluid_iteration.m` and this port
32 * therefore ran every one of their `iter_max` passes, which is what made a
33 * fluid solve cost a fixed 150 windows however close it started to the answer.
34 * The fix is to stop on what the ratio DROPS: summing the geometric tail,
35 * ratio*rho/(1-rho) with rho read off the iteration itself, bounds the distance
36 * left rather than the distance just travelled, and needs no rate to stand in
37 * for the slowest system mode -- when one does, as a bare drift norm must, the
38 * stop lands 3% short. `earlystop` (default true, `options.config.fluid_earlystop`)
39 * selects it; `iter_tol > 0` remains the caller's own cruder trade. A FINITE
40 * `timespan_end` is a transient request and is exempt from both: it integrates
41 * to its end time even once the state has settled.
42 *
43 * THE PRICE OF RUNNING EVERY PASS is this port's own, and it is small: LSODA is
44 * restarted once per pass, so at the default `tol = 1e-4` its error accumulates
45 * on a state that is already at the fixed point. On Delay(Z=1) -> PS(c=2), N=6,
46 * whose `closing` fixed point is exactly (2,4) and which the reference returns
47 * to nine digits, this port is right to 1e-8 by pass 8 and 7e-6 by pass 200.
48 * `tol = 1e-6` removes it, at the cost of a different trajectory row count.
49 *
50 * DOUBLE ONLY. The drift is integrated by LSODA, whose coefficients assume
51 * `double` (see `util/lsoda.h`), so a non-`double` backend is refused BY NAME
52 * rather than silently narrowed.
53 */
54
55#include <algorithm>
56#include <cmath>
57#include <cstddef>
58#include <limits>
59#include <string>
60#include <vector>
61
67#include "line/lang/qn/state.h"
80#include "line/util/error.h"
81#include "line/util/lsoda.h"
82#include "line/util/matrix.h"
83
84namespace line {
85namespace fluid {
86
87/** Controls, defaulting to `SolverOptions('Fluid')` in the reference. */
89 std::string method = "default";
90 double tol = 1e-4; ///< absolute and relative tolerance handed to the integrator
91 double iter_tol = 0.0; ///< >0 stops early when the moved-mass ratio falls below it; 0 runs to iter_max, as the reference does
92 bool earlystop = true; ///< `options.config.fluid_earlystop`: stop on the geometric tail of the window iteration
93 std::size_t iter_max = 200; ///< cap on outer integrations
94 /**
95 * `options.config.nonmkvorder`: the phase budget `sn_nonmarkov_toph` spends
96 * on a non-Markovian service law. The fluid path always takes the PH fit,
97 * so this is the Bernstein order.
98 */
99 std::size_t nonmkv_order = 20;
100 double timespan_end = std::numeric_limits<double>::infinity();
101 std::vector<double> init_sol; ///< initial state; empty selects the default below
102 /**
103 * `options.config.kp_init_sol`: the `kp` method's initial state, in the
104 * KO-PENDER layout -- one offset counter walking the stations in order, an
105 * arrival-phase block at each EXT station-class and a service-phase block at
106 * every other, with no mass returning to the source.
107 *
108 * NOT `init_sol`, which is laid out for the CLOSING state vector: the two
109 * can have the same length on the same model, so sharing one field lets a
110 * closing-layout seed be consumed here, silently zeroing the source phase
111 * mass and with it the whole network. Empty selects the stationary arrival
112 * phase; a wrong-sized seed is refused rather than ignored.
113 */
114 std::vector<double> kp_init_sol;
115 /**
116 * `options.config.init_cov`: the `kp` method's initial covariance Sigma(0),
117 * dim-by-dim in the same layout as `kp_init_sol`.
118 *
119 * A caller that carries a DISTRIBUTION across a handoff supplies the second
120 * moment beside the mean, so the next stage does not restart from a point
121 * mass it never had. Empty keeps the default diag(theta) - theta theta' of
122 * the initial arrival phase.
123 */
125 double softmin_alpha = 20.0; ///< sharpness of the 'softmin' smoothing
126 double pstar = 20.0; ///< exponent of the 'pnorm' smoothing
127 /**
128 * Opt in to the p-norm under `matrix`/`default` too, which is what
129 * `options.config.pstar` does in MATLAB, the JAR and native Python. The
130 * exponent above is a default, not a request, so it cannot serve as the
131 * flag: leaving it at 20 must keep the hard min() under `matrix`.
132 */
133 bool pstar_set = false;
134 /**
135 * `options.config.fork_join`: which fork-join arm the fixed point takes,
136 * 'default'/'mmt'/'fjt' or 'ht'. Carried here so that a fluid solve of a
137 * fork-join model selects the same transform an MVA or NC solve of it
138 * would; see the `has_fork` branch of fluid_runner.h.
139 */
140 std::string fork_join = "default";
141 double timestep = 0.01; ///< 'diffusion' Euler-Maruyama step
142 unsigned long seed = 23000; ///< 'diffusion' RNG seed
143 /**
144 * `options.stiff`: integrate the closing family with the explicit stiff arm
145 * of `fluid_stiff.h` rather than with LSODA.
146 *
147 * THE DEFAULT IS FALSE WHERE THE REFERENCE'S IS TRUE, and that is not a
148 * downgrade. `options.stiff = true` selects ode15s, a variable-order BDF
149 * code; LSODA is a variable-order Adams/BDF code that switches to BDF on
150 * its own stiffness detector, so the reference's default arm is the one
151 * already taken here. Setting this selects the four-stage Rosenbrock
152 * method, which is the family ode23s belongs to -- the reference's OTHER
153 * arm -- so the flag names the integrator that is actually different.
154 */
155 bool stiff = false;
156 /**
157 * `options.config.hide_immediate`: fold the Immediate-rate transitions into
158 * the timed ones by stochastic complementation before integrating. Off in
159 * the reference too, which reaches `ode_eliminate_immediate` only when the
160 * caller asks for it.
161 */
162 bool hide_immediate = true;
163 /**
164 * `options.config.aoi_preemption`: the preemption (bufferless) or
165 * replacement (single buffer) probability of the AoI branch of `mfq`.
166 * Negative selects the value the scheduling policy implies.
167 */
168 double aoi_preemption = -1.0;
169 /**
170 * `options.config.moment_sigma2` and `options.config.moment_cov`: the second
171 * moment the drift's non-linear terms are closed with.
172 *
173 * A CALLER DOES NOT SET THIS. `solver_fluid_moments` does, once per sweep of
174 * its outer fixed point, and it is on the options because the mean solve is
175 * the ORDINARY closing integration -- the closure has to reach the drift
176 * without a second entry point that could drift from the first.
177 */
179 /**
180 * `options.config.moment_maxstate`: the largest phase-resolved state the
181 * moment-closure methods will build a covariance over. The Lyapunov solve is
182 * cubic in it, so this is a refusal threshold and not a tuning knob; it also
183 * decides whether `default` resolves to `minnormal` at all.
184 */
185 std::size_t moment_maxstate = 200;
186 /**
187 * `options.config.dae_maxstate` and `options.config.dae_maxcov`: the DAE
188 * route's own two refusal thresholds, on the simultaneous solve and on the
189 * covariance it integrates alongside the mean.
190 *
191 * ZERO MEANS NOT SET, and that is what makes them options rather than a
192 * second copy of the defaults: the route reads them off `FluidDaeOptions`,
193 * whose own values a caller may pin directly, and `fluid_dae_options` lets
194 * an explicit pin stand wherever the options are silent. A user reaching
195 * for the knob writes the option, as in the other three codebases; a test
196 * pinning one writes the struct.
197 */
198 std::size_t dae_maxstate = 0;
199 std::size_t dae_maxcov = 0;
200 /**
201 * `options.config.highvar`: which non-exponential FCFS correction the
202 * analyzer's outer refit loop applies, `interp` (the WSC 2020 diffusion
203 * interpolation) or `default`/`none`/`hvmva` (no rescaling, so the loop
204 * converges after one sweep).
205 *
206 * THE DEFAULT IS `default`, i.e. NO rescaling, because that is what
207 * `SolverOptions.m:127` sets for FLD -- NC is the solver that defaults to
208 * `interp`. The refit loop still runs: with no rescaling it refits each FCFS
209 * station to a Coxian at its own mean and SCV, which is an identity on a
210 * declared Coxian and a two-moment reduction on anything else, and it
211 * converges in two sweeps because `eta` is constant. See fluid_nonexp.h.
212 */
213 std::string highvar = "default";
214 /**
215 * `options.config.rate_traj = {tgrid, Mmat}`: a caller-supplied per-EVENT
216 * multiplier, which is what the coupled LN layer transient injects.
217 * `Mmat` must have one row per event of the closing ODE.
218 */
220 /**
221 * `options.config.nhpp_sched`: the (station, class) pairs whose SOURCE
222 * carries a non-homogeneous intensity, which the drift is to follow exactly
223 * rather than at its time average.
224 *
225 * IT IS A LIST AND NOT A FLAG, and that is the reference's design. A model
226 * can declare an NHPP and still be solved at the nominal -- that is what
227 * `solver_fluid.m` does for a steady-state request -- so the schedule enters
228 * the drift only when a caller asks for it, which in the reference is
229 * `@@SolverFLD/getTranAvg` through `local_detect_nhpp`. `fluid_detect_nhpp`
230 * below is that detector; a caller that wants the nominal simply does not
231 * call it.
232 */
233 std::vector<std::pair<std::size_t, std::size_t> > nhpp_sched; ///< 1-based (station, class)
234 /**
235 * `options.config.rate_sched`: explicit per-(station, class) rate
236 * trajectories, the third source `solver_fluid_ratemult` composes. Used by
237 * the coupled LN layer transient to inject time-varying inter-layer demand
238 * through the same station-class -> event expansion the NHPP path uses.
239 */
240 struct RateSched {
241 std::size_t station = 0; ///< 1-based
242 std::size_t cls = 0; ///< 1-based
243 std::vector<double> tgrid;
244 std::vector<double> rates;
245 /** The nominal baked into rate_base; <= 0 selects `Mu{i}{c}(1)`. */
246 double nominal = -1.0;
247 };
248 std::vector<RateSched> rate_sched;
249};
250
251/**
252 * The second-order results of the moment-closure methods, i.e. what
253 * `@@SolverFLD/getMoments` returns.
254 *
255 * EMPTY FOR EVERY FIRST-ORDER METHOD, which compute no second moment at all --
256 * `has_moments` on the solution says which. Reporting zeros instead would be a
257 * variance of zero, which is a claim and not an absence.
258 */
260 Matrix<double> Sigma; ///< state-level covariance, on range(D)
261 Matrix<double> QVar, QStd; ///< per station and class queue-length variance
262 std::vector<double> sigma2; ///< per-station population variance
263 std::vector<double> refinement; ///< the 1/N correction, `refined` only
264 std::size_t outer_iters = 0;
265 /// state coordinates of each (station,class): `Sigma` is indexed by SERVICE
266 /// PHASE, so reading a per-class population off it needs this map
267 std::vector<std::vector<std::vector<std::size_t>>> class_block;
268};
269
270/**
271 * One point of a transient trajectory: the metrics at time `t`.
272 *
273 * `getTranAvg` in the reference returns QNt/UNt/TNt as (station x class) cell
274 * arrays of time series; this carries the same information sampled at a grid,
275 * which is what a caller plots or integrates.
276 */
278 double t = 0.0;
280 /**
281 * Per-(station,class) queue-length VARIANCE at this instant, empty where the
282 * method carries no second moment along the trajectory. Only the `dae`
283 * route fills it, and only below `FluidDaeOptions::maxcov`: the moment
284 * closures evaluate their whole transient at the single stationary variance,
285 * so a per-point variance would be the same number repeated.
286 */
288};
289
290/** What the analyzer returns, in the same shape as the MVA solver's result. */
293 std::vector<double> CN, XN;
294 std::vector<double> xvec; ///< the converged fluid state
295 std::size_t iters = 0;
296 /**
297 * `iter` of `solver_fluid_analyzer.m`: the FCFS non-exponential refit sweeps.
298 * Zero when the model has no FCFS station or the method does not refit, which
299 * is the reference's own "the loop was never entered".
300 */
301 std::size_t refit_sweeps = 0;
302 std::string method = "closing";
303 /**
304 * `result.solverSpecific.aoiResults`: set only by the AoI branch of `mfq`,
305 * where the age laws, and not QN/RN, are the answer.
306 */
307 bool has_aoi = false;
309 /**
310 * `result.solverSpecific.moments`: set only by `minnormal` and `refined`.
311 * `closure` is the variance those methods converged to, kept so that a
312 * transient asked for afterwards integrates the SAME Gaussian drift the
313 * steady-state table was read from rather than the first-order one.
314 */
315 bool has_moments = false;
318};
319
320namespace detail {
321
322/**
323 * Port of `solver_fluid_initsol.m`: THE initial condition of every fluid
324 * integration, and the only one in this port.
325 *
326 * IT IS NOT THE `y0` OF `solver_fluid.m`. That vector -- the even spread of a
327 * closed population over the stations that serve its class -- is the
328 * reference's `ydefault`, reached only when the integrator rejects the real
329 * initial point. `solver_fluid_analyzer.m:25-27` fills `options.init_sol` with
330 * `solver_fluid_initsol` BEFORE the method switch, so the even spread is never
331 * what a solve starts from.
332 *
333 * WHAT `solver_fluid_initsol` ACTUALLY RETURNS, and why it is this short.
334 * It decodes `sn.state`, which for any model that did not call setState is what
335 * `Network.initDefault` wrote through `State.fromMarginalAndStarted`. That
336 * encoder puts every job it starts in PHASE ONE
337 * (`init = spaceClosedSingle(K(r),0); init(1) = si(r)`), never enumerating the
338 * phase assignment. Outside a Source the decode then gives phase one
339 * `nir(r) - sum_{k>=2} kir(r,k)`, and with every kir(r,k>=2) zero that is the
340 * whole per-station population back again. So the round trip through the
341 * encoder is an identity, and what is left of `solver_fluid_initsol` is the
342 * PLACEMENT `initDefault` computed, written into the phase-one entries.
343 *
344 * An open class instead holds the unit job pool at its Source, which is
345 * `init(1) = 1` in the encoder's EXT branch, and nothing anywhere else.
346 *
347 * THE PLACEMENT IS `initDefault`'s AND NOT "ALL OF IT AT THE REFERENCE
348 * STATION". The reference station takes as much of the population as its
349 * `classcap`/`cap` allows and SPILLS the excess onto the remaining stations in
350 * ascending order, erroring when it never fits. On a model with no explicit
351 * capacity the two coincide -- `refreshCapacity` gives every station the
352 * population of the chains that reach it -- but `setCapacity(k)` below the
353 * population makes them different vectors, and then the ODE would be started
354 * from a point the model is never in.
355 */
356template <class T>
357std::vector<std::vector<double>> fluid_initsol_placement(const qn::NetworkStruct<T>& sn,
358 const FluidLayout& L) {
359 const std::size_t M = sn.nstations, K = sn.nclasses;
360 const double inf = std::numeric_limits<double>::infinity();
361 std::vector<std::vector<double>> nplace(M, std::vector<double>(K, 0.0));
362 std::vector<double> totplace(M, 0.0);
363 // An unrefreshed struct carries no capacity table, which is the reference's
364 // Inf default and not a zero buffer.
365 const bool has_cap = sn.cap.size() == M && sn.classcap.size() == M;
366
367 for (std::size_t r = 0; r < K; ++r) {
368 const double pop = sn.classes[r].population;
369 if (!std::isfinite(pop)) continue;
370 // The reference station first, then every other station in ascending
371 // order: `[refist, setdiff(1:M, refist)]`.
372 std::vector<std::size_t> order;
373 const std::size_t rs = sn.classes[r].refstat;
374 if (rs >= 1 && rs <= M) order.push_back(rs - 1);
375 for (std::size_t i = 0; i < M; ++i)
376 if (order.empty() || i != order[0]) order.push_back(i);
377
378 // A Place takes the WHOLE population and never spills, capacity or not
379 // (`initDefault.m:24-28`). It carries no fluid coordinates, so the class
380 // then contributes nothing to the state vector -- which is also what the
381 // reference's decode does, since a Place has NaN rates and
382 // `solver_fluid_initsol.m:29,39` appends a column only for a rated class.
383 if (rs >= 1 && rs <= M && sn.stations[rs - 1].nodetype == lang::NodeType::Place) {
384 nplace[rs - 1][r] = pop;
385 totplace[rs - 1] += pop;
386 continue;
387 }
388
389 double remaining = pop;
390 for (std::size_t oi = 0; oi < order.size() && remaining > 0.0; ++oi) {
391 const std::size_t j = order[oi];
392 // A Source is the reservoir of the open classes, not a holding place
393 // for a closed population; a Place belongs to the Petri net encoding
394 // and has no fluid coordinates at all.
395 if (sn.stations[j].sched == lang::SchedStrategy::EXT) continue;
396 if (sn.stations[j].nodetype == lang::NodeType::Place) continue;
397 // A pair with no fluid block would otherwise take its mass through
398 // `qidx` into the NEXT block, since a disabled pair's index is the
399 // following pair's start.
400 if (!L.enabled[j][r]) continue;
401 const double ccap = (has_cap && sn.classcap[j].size() > r) ? sn.classcap[j][r] : inf;
402 const double scap = has_cap ? sn.cap[j] : inf;
403 const double avail = std::min(ccap - nplace[j][r], scap - totplace[j]);
404 const double take = std::min(remaining, std::max(0.0, avail));
405 nplace[j][r] += take;
406 totplace[j] += take;
407 remaining -= take;
408 }
409 if (remaining > 0.0)
410 throw InputError("solver_fluid_initsol: cannot place the population of class '" +
411 sn.classes[r].name +
412 "': the total capacity of the stations that serve it is insufficient");
413 }
414 return nplace;
415}
416
417/**
418 * The DECLARED initial condition, or false when the model declares none.
419 *
420 * `solver_fluid_initsol.m` decodes `sn.state{isf}`, which is whatever
421 * `setState` or `initFromMarginal` left there and only falls back to
422 * `initDefault`'s placement when nothing was set. This port used to recompute
423 * that placement unconditionally, so `initFromMarginal([0 0; 4 1])` integrated
424 * from the DEFAULT marking instead -- the right answer to a different model, and
425 * invisible in a long horizon because every initial condition of a closed model
426 * converges to the same stationary point.
427 *
428 * PHASE ONE IS NOT ASSUMED HERE, unlike in the default placement. A declared
429 * state carries `kir(r,k)`, jobs in service in phase k, so the reference writes
430 * `nir(r) - sum_{k>=2} kir(r,k)` into phase one and `kir(r,k)` into the rest; an
431 * `initFromMarginalAndStarted` state has jobs past phase one and folding them
432 * forward would start the integration with a different amount of work in flight.
433 *
434 * A Source is the EXT branch: it holds no fluid population of its own, and its
435 * per-phase entries are the reference's `kir` there too.
436 *
437 * WHICH ROW OF `statespace` IS THE STATE: THE FIRST ONE CARRYING PRIOR MASS.
438 * The pair on the wire is the node's WHOLE local space with a prior over its
439 * rows, not a one-row state -- a model saved after `initDefault` sends eight
440 * rows for a 3-server FCFS queue, with the prior a point mass on row 0. The
441 * reference does not read that pair at all: `solver_fluid_initsol.m` decodes
442 * `sn.state{isf}`, the single CURRENT state, and every writer emits that state
443 * as row 0 of the space it sends (the invariant `state.h` states for
444 * `default_init_state`).
445 *
446 * A PRIOR OVER SEVERAL ROWS DOES NOT CHANGE THE ANSWER, and must not. The fluid
447 * limit is not linear in the initial distribution -- the ODE from the mean of
448 * two states is not the mean of the two ODEs -- so there is nothing to average;
449 * the reference simply keeps integrating from `sn.state`, which `setStatePrior`
450 * does not touch. Declining the mixture and falling back to `initDefault`'s
451 * placement instead answered `init_state_fcfs_nonexp`'s Prior 3 with the
452 * DEFAULT marking (0.175046 for a reference 0.175821) while its Prior 2, the
453 * same state under a point-mass prior, was right.
454 *
455 * THE FALLBACK IS PER STATION, as `sn_declared_marginal`'s is. A station whose
456 * row is absent, undecodable or a mixture keeps `initDefault`'s placement while
457 * its neighbours keep their declared rows; an all-or-nothing rule zeroed the
458 * whole vector the moment ONE station declared and another did not, which on
459 * `init_state_fcfs_nonexp` emptied the network and reported QLen 0.
460 */
461template <class T>
462bool fluid_declared_initsol(const qn::NetworkStruct<T>& sn, const FluidLayout& L,
463 const std::vector<std::vector<double>>& nplace,
464 std::vector<double>& y0) {
465 const std::size_t M = sn.nstations, K = sn.nclasses;
466 bool any = false;
467 std::vector<double> out(L.nstates, 0.0);
468 for (std::size_t i = 0; i < M; ++i) {
469 const std::size_t ind = sn.node_of_station(i + 1);
470 const bool ext = sn.stations[i].sched == lang::SchedStrategy::EXT;
471 const typename std::map<std::size_t, Matrix<T>>::const_iterator ss =
472 sn.statespace.find(ind);
473 const typename std::map<std::size_t, std::vector<T>>::const_iterator sp =
474 sn.stateprior.find(ind);
475 std::size_t pick = static_cast<std::size_t>(-1);
476 if (ss != sn.statespace.end() && sp != sn.stateprior.end() && ss->second.cols() > 0 &&
477 ss->second.rows() == sp->second.size()) {
478 for (std::size_t r = 0; r < ss->second.rows() && pick == static_cast<std::size_t>(-1);
479 ++r)
480 if (num_traits<T>::to_double(sp->second[r]) > 0.0) pick = r;
481 }
482 qn::Marginal<T> m;
483 bool decoded = false;
484 if (pick != static_cast<std::size_t>(-1)) {
485 std::vector<T> row(ss->second.cols());
486 for (std::size_t c = 0; c < ss->second.cols(); ++c) row[c] = ss->second(pick, c);
487 std::vector<std::size_t> ph(K, 1), shift(K, 0);
488 std::size_t w = 0;
489 for (std::size_t r = 0; r < K; ++r) {
490 ph[r] = sn.phasessz_of(i + 1, r + 1);
491 shift[r] = w;
492 w += ph[r];
493 }
494 try {
495 m = qn::to_marginal(sn, i + 1, row, ph, shift, sn.nvars_of(ind));
496 decoded = m.nir.size() == K && m.kir.size() == K;
497 } catch (const Error&) {
498 decoded = false; // a row the encoding cannot decode is not a state
499 }
500 }
501 for (std::size_t r = 0; r < K; ++r) {
502 if (!L.enabled[i][r]) continue;
503 if (!decoded) {
504 // This station keeps `initDefault`'s placement, all of it in
505 // phase one, which is where that encoder starts every job.
506 if (!ext && nplace[i][r] > 0.0) out[L.qidx[i][r]] = nplace[i][r];
507 if (ext && !std::isfinite(sn.classes[r].population)) out[L.qidx[i][r]] = 1.0;
508 continue;
509 }
510 const std::size_t np = L.kic[i][r];
511 for (std::size_t k = 0; k < np; ++k) {
512 double v = 0.0;
513 if (k < m.kir[r].size()) v = num_traits<T>::to_double(m.kir[r][k]);
514 if (k == 0 && !ext) {
515 // Phase one absorbs the waiting buffer: `nir - sum_{k>=2} kir`.
516 double served = 0.0;
517 for (std::size_t j = 1; j < m.kir[r].size(); ++j)
518 served += num_traits<T>::to_double(m.kir[r][j]);
519 v = num_traits<T>::to_double(m.nir[r]) - served;
520 }
521 // A Source reports nir = +Inf by the EXT sentinel; only its
522 // per-phase counts are a quantity, and those are finite.
523 if (!std::isfinite(v)) v = 0.0;
524 out[L.qidx[i][r] + k] = v;
525 }
526 any = true;
527 }
528 }
529 if (!any) return false;
530 y0.swap(out);
531 return true;
532}
533
534/** The initial condition itself: the declared state, else the placement above. */
535template <class T>
536std::vector<double> fluid_default_initsol(const qn::NetworkStruct<T>& sn, const FluidLayout& L) {
537 const std::size_t M = sn.nstations, K = sn.nclasses;
538 std::vector<double> y0(L.nstates, 0.0);
539 const std::vector<std::vector<double>> nplace = fluid_initsol_placement(sn, L);
540 if (fluid_declared_initsol(sn, L, nplace, y0)) return y0;
541 for (std::size_t r = 0; r < K; ++r) {
542 if (std::isfinite(sn.classes[r].population)) {
543 // Phase one of a block gets `nir - sum_{k>=2} kir`, and every job
544 // `initDefault` starts is in phase one, so that is the whole of the
545 // station's share of the population.
546 // The enabled guard is load-bearing for a Place: it holds a
547 // placement but no fluid block, and a disabled pair's `qidx` is the
548 // NEXT pair's start, so writing it would corrupt a neighbour.
549 for (std::size_t i = 0; i < M; ++i)
550 if (L.enabled[i][r] && nplace[i][r] > 0.0) y0[L.qidx[i][r]] = nplace[i][r];
551 } else {
552 // An open class holds the unit job pool at its source.
553 for (std::size_t i = 0; i < M; ++i)
554 if (L.enabled[i][r] && sn.stations[i].sched == lang::SchedStrategy::EXT)
555 y0[L.qidx[i][r]] = 1.0;
556 }
557 }
558 return y0;
559}
560
561} // namespace detail
562
563namespace detail {
564
565/**
566 * Snap numerical dust to zero, as `filterMetric` does with
567 * `outData(outData < FineTol) = 0`.
568 *
569 * A station a class never reaches still accumulates a few 1e-15 of mass from
570 * the integrator, and printing that as a queue length claims a presence the
571 * model does not have. The reference clears it, so a fluid table can be
572 * compared with an exact one without every unvisited cell reading as a
573 * mismatch.
574 */
575inline void fluid_snap_fine(Matrix<double>& m) {
576 for (std::size_t i = 0; i < m.rows(); ++i)
577 for (std::size_t j = 0; j < m.cols(); ++j)
578 if (std::fabs(m(i, j)) < lang::GlobalConstants::FineTol) m(i, j) = 0.0;
579}
580
581/**
582 * Snap the whole result set, then clear the response time wherever the
583 * throughput went with it.
584 *
585 * RespT is a RATIO of two dusty quantities, so it does not look small even
586 * when both of its operands do: 3e-15 over 3e-15 is 1, which would report a
587 * unit response time at a station the class never visits. Zeroing it with its
588 * throughput is what keeps the row consistent.
589 */
590inline void fluid_snap_all(Matrix<double>& q, Matrix<double>& u, Matrix<double>& r,
591 Matrix<double>& t) {
592 fluid_snap_fine(q);
593 fluid_snap_fine(u);
594 fluid_snap_fine(t);
595 fluid_snap_fine(r);
596 for (std::size_t i = 0; i < r.rows(); ++i)
597 for (std::size_t j = 0; j < r.cols(); ++j)
598 if (t(i, j) == 0.0 && q(i, j) == 0.0) r(i, j) = 0.0;
599}
600
601/**
602 * Mark the (station, class) pairs the model actually routes a job into, read
603 * off the per-chain visit ratios `sn.visits`.
604 *
605 * A fluid result cannot decide that question from the SIZE of QN or TN. Both
606 * carry a decaying remnant of the initial state, spread over pairs the class
607 * never reaches, and the remnant is whatever the integrator left behind when it
608 * stopped: measured at QN = 1.3e-12 and TN = 1.3e-13 on picard05 for
609 * `test_CQN_Cox_CS_7`, i.e. ABOVE `GlobalConstants::Zero`, so a threshold on
610 * them divides one remnant by the other and reports the station's own service
611 * time, 10.0000086, as a response time. The visit ratios come from the routing
612 * solve instead, where an unrouted pair is zero to the last bits (2.7e-17
613 * there). `visits` is indexed by STATEFUL node, hence `stateful_of_station`.
614 *
615 * A struct carrying no visit information decides nothing and every pair is
616 * reported visited. Mirrors `fluid_visited_pairs.m`.
617 */
618template <class T>
619std::vector<char> fluid_visited_pairs(const qn::NetworkStruct<T>& sn, std::size_t M, std::size_t K) {
620 std::vector<char> visited(M * K, 0);
621 bool have = false;
622 std::size_t max_cols = 0;
623 for (std::size_t c = 0; c < sn.visits.size(); ++c) {
624 const Matrix<T>& Vc = sn.visits[c];
625 if (Vc.rows() == 0 || Vc.cols() == 0) continue;
626 have = true;
627 max_cols = std::max(max_cols, Vc.cols());
628 for (std::size_t i = 0; i < M; ++i) {
629 const std::size_t isf = sn.stateful_of_station(i + 1);
630 if (isf == 0 || isf > Vc.rows()) {
631 for (std::size_t r = 0; r < K; ++r) visited[i * K + r] = 1;
632 continue;
633 }
634 for (std::size_t r = 0; r < K && r < Vc.cols(); ++r)
635 if (std::fabs(num_traits<T>::to_double(Vc(isf - 1, r))) >
637 visited[i * K + r] = 1;
638 }
639 }
640 if (!have) {
641 std::fill(visited.begin(), visited.end(), static_cast<char>(1));
642 } else {
643 // A class NO visit matrix reaches is not evidence of a non-visit, only of a
644 // struct whose visits were refreshed against fewer classes.
645 for (std::size_t i = 0; i < M; ++i)
646 for (std::size_t r = max_cols; r < K; ++r) visited[i * K + r] = 1;
647 }
648 return visited;
649}
650
651/**
652 * The analyzer-level correction `solver_fluid_analyzer.m:206-262` applies to
653 * EVERY method branch, after the switch.
654 *
655 * The per-method solvers report a utilization read straight off the fluid
656 * state: `sum(Xservice/mu)/S`, the server time the drift assigns to the class.
657 * That quantity is not a utilization -- it can exceed both 1 and the class's
658 * own mean population, because the drift's share is an instantaneous rate and
659 * not an occupancy. The reference restates it as the smallest of three
660 * quantities that each bound it from above: unity, the mean population per
661 * server, and the pre-correction total rescaled by the class's share of
662 * TN/rate, the share computed from the TRUE service rates rather than from the
663 * drift's approximation of them.
664 *
665 * Omitting this was worth 111% on `cqn_scheduling_dps`: DPS Queue2/Class2 read
666 * 0.2249 (its share of a saturated server) where the reference reports 0.1066
667 * (its mean population, which is the binding bound). Both were internally
668 * consistent, which is why it survived: the uncorrected value satisfies
669 * `U = X E[S]` exactly, and only disagrees with the reference.
670 *
671 * MATLAB's `min` over a vector SKIPS NaN, so a class whose rate is zero
672 * (0/0 in the share) must not poison the minimum; the NaN term is dropped, not
673 * propagated.
674 */
675template <class T>
676void fluid_analyzer_correct(const qn::NetworkStruct<T>& sn, const Matrix<double>& Q,
677 Matrix<double>& U, Matrix<double>& R, const Matrix<double>& T_) {
678 const std::size_t M = Q.rows(), K = Q.cols();
679 // A class the model never routes here has no response time, and QN alone
680 // does not say so -- see fluid_visited_pairs.
681 const std::vector<char> visited = fluid_visited_pairs(sn, M, K);
682 const Matrix<double> U0 = U;
683 for (std::size_t i = 0; i < M; ++i) {
684 double u0sum = 0.0, share_den = 0.0;
685 for (std::size_t r = 0; r < K; ++r) {
686 if (!(Q(i, r) > 0.0) || !visited[i * K + r]) continue;
687 u0sum += U0(i, r);
688 const double rate = num_traits<T>::to_double(sn.rates(i, r));
689 if (rate != 0.0) share_den += T_(i, r) / rate;
690 }
691 // A load-dependent station clears alpha(n) times the nominal work, so the
692 // bound that divides by its capacity has to divide by the PEAK scaling:
693 // Seff = max(c_i, max_n alpha_i(n)), the same T*S/peak convention
694 // `solver_ctmc_avg_from_pi` applies. Without load dependence Seff == c and
695 // every expression below is unchanged.
696 double c = sn.stations[i].nservers;
697 for (std::size_t k = 0; k < sn.stations[i].lldscaling.size(); ++k)
698 c = std::max(c, num_traits<T>::to_double(sn.stations[i].lldscaling[k]));
699 const bool is_delay = sn.stations[i].sched == lang::SchedStrategy::INF;
700 for (std::size_t r = 0; r < K; ++r) {
701 if (!(Q(i, r) > 0.0) || !visited[i * K + r]) {
702 U(i, r) = 0.0;
703 R(i, r) = 0.0;
704 continue;
705 }
706 if (is_delay) {
707 U(i, r) = Q(i, r);
708 continue;
709 }
710 double best = 1.0;
711 if (std::isfinite(c) && c > 0.0) best = std::min(best, Q(i, r) / c);
712 const double rate = num_traits<T>::to_double(sn.rates(i, r));
713 if (rate != 0.0 && share_den != 0.0)
714 best = std::min(best, u0sum * (T_(i, r) / rate) / share_den);
715 U(i, r) = best;
716 if (T_(i, r) != 0.0) R(i, r) = Q(i, r) / T_(i, r);
717 }
718 }
719 for (std::size_t i = 0; i < M; ++i)
720 for (std::size_t r = 0; r < K; ++r) {
721 if (std::isnan(U(i, r))) U(i, r) = 0.0;
722 if (std::isnan(R(i, r))) R(i, r) = 0.0;
723 }
724}
725
726} // namespace detail
727
728
729/**
730 * Read Q/U/R/T off ONE fluid state, for the closing family.
731 *
732 * Factored out because the transient needs exactly this at every point of the
733 * trajectory, and a second copy would drift from the steady-state one. `m` is
734 * the resolved method name: only `statedep` changes the rules here, and it does
735 * so at FCFS stations (see the mean-service-time share below).
736 */
737template <class T>
739 const std::string& m, const std::vector<double>& xs, Matrix<double>& Q,
741 const std::size_t M = sn.nstations, K = sn.nclasses;
742 const FluidLayout& L = sys.layout;
743 Q = Matrix<double>(M, K, 0.0);
744 U = Matrix<double>(M, K, 0.0);
745 R = Matrix<double>(M, K, 0.0);
746 T_ = Matrix<double>(M, K, 0.0);
747 // Queue length is the mass of the (station, class) block.
748 for (std::size_t i = 0; i < M; ++i)
749 for (std::size_t r = 0; r < K; ++r) {
750 double q = 0.0;
751 for (std::size_t k = 0; k < L.kic[i][r]; ++k) q += xs[L.qidx[i][r] + k];
752 Q(i, r) = q;
753 }
754
755 // Throughput, and the per-phase service mass the utilization is read from.
756 std::vector<std::vector<std::vector<double>>> xservice(M, std::vector<std::vector<double>>(K));
757 for (std::size_t i = 0; i < M; ++i) {
758 const lang::SchedStrategy sc = sn.stations[i].sched;
759 double xi = 0.0;
760 for (std::size_t r = 0; r < K; ++r) xi += Q(i, r);
761 double wxi = 0.0;
762 for (std::size_t r = 0; r < K; ++r)
763 wxi += (r < sn.stations[i].schedparam.size()
764 ? num_traits<T>::to_double(sn.stations[i].schedparam[r])
765 : 1.0) *
766 Q(i, r);
767 const double c = sn.stations[i].nservers;
768 // The capacity term psi(n) = min(n,c)*alpha(n), and not the bare min: a
769 // load-dependent station clears alpha(n) times the nominal work, so the
770 // bare min would drop the scaling from Tput and Util while the ODE applied
771 // it. With no load dependence `sys.lld[i]` is empty and this IS min(xi,c).
772 const double served = fluid_capacity_closure(xi, c, 0.0, sys.lld[i], false).h;
773
774 // `statedep` shares an FCFS server by MEAN SERVICE TIME, not by head
775 // count: a class that occupies a server for longer draws a larger share.
776 // `solver_fluid_closing.m` applies that weighting for this method only,
777 // and additionally overrides TN with sum(Xservice) -- i.e. WITHOUT the
778 // completion probability phi that every other branch carries.
779 const bool fcfs_statedep = (m == "statedep") && (sc == lang::SchedStrategy::FCFS);
780 std::vector<double> wmean(K, 0.0);
782 if (fcfs_statedep) {
783 for (std::size_t r = 0; r < K; ++r) {
784 if (!L.enabled[i][r]) continue;
786 const std::size_t nn = sn.service[i][r].D0.rows();
787 mp.D0 = Matrix<double>(nn, nn, 0.0);
788 mp.D1 = Matrix<double>(nn, nn, 0.0);
789 for (std::size_t a = 0; a < nn; ++a)
790 for (std::size_t bb = 0; bb < nn; ++bb) {
791 mp.D0(a, bb) = num_traits<T>::to_double(sn.service[i][r].D0(a, bb));
792 mp.D1(a, bb) = num_traits<T>::to_double(sn.service[i][r].D1(a, bb));
793 }
794 wmean[r] = mam::map_mean(mp);
795 wni += wmean[r] * Q(i, r);
796 }
797 }
798
799 for (std::size_t r = 0; r < K; ++r) {
800 xservice[i][r].assign(L.kic[i][r], 0.0);
801 if (!L.enabled[i][r]) continue;
802 std::vector<double> mu, phi;
803 detail::fluid_mu_phi(sn.service[i][r], mu, phi);
804 const std::size_t b = L.qidx[i][r], n = L.kic[i][r];
805 double tn = 0.0;
806 if (fcfs_statedep) {
807 for (std::size_t k = 0; k < n; ++k)
808 xservice[i][r][k] = xs[b + k] * mu[k] * wmean[r] / wni * served;
809 double s2 = 0.0;
810 for (std::size_t k = 0; k < n; ++k) s2 += xservice[i][r][k];
811 T_(i, r) = s2; // the reference's sum(Xservice) override
812 continue;
813 }
814 for (std::size_t k = 0; k < n; ++k) {
815 double mass = xs[b + k];
816 switch (sc) {
818 // The source holds unit mass: phase one carries the rest.
819 if (k == 0) {
820 double rest = 0.0;
821 for (std::size_t p = 1; p < n; ++p) rest += xs[b + p];
822 mass = 1.0 - rest;
823 }
824 tn += mass * mu[k] * phi[k];
825 xservice[i][r][k] = mass * mu[k];
826 break;
828 tn += mass * mu[k] * phi[k];
829 xservice[i][r][k] = mass * mu[k];
830 break;
832 const double w = r < sn.stations[i].schedparam.size()
833 ? num_traits<T>::to_double(sn.stations[i].schedparam[r])
834 : 1.0;
835 if (wxi > 0.0) {
836 tn += mass * mu[k] * phi[k] * w / wxi * served;
837 xservice[i][r][k] = mass * mu[k] * w / wxi * served;
838 }
839 break;
840 }
841 default: // PS, FCFS, SIRO and the rest share the servers
842 if (xi > 0.0) {
843 tn += mass * mu[k] * phi[k] / xi * served;
844 xservice[i][r][k] = mass * mu[k] / xi * served;
845 }
846 break;
847 }
848 }
849 T_(i, r) = tn;
850 }
851 }
852
853 // Utilization: the service mass divided by the phase rate is the time a
854 // server spends on it; a delay reports the queue length itself.
855 for (std::size_t i = 0; i < M; ++i) {
856 const bool is_delay = sn.stations[i].sched == lang::SchedStrategy::INF;
857 for (std::size_t r = 0; r < K; ++r) {
858 if (!L.enabled[i][r]) continue;
859 std::vector<double> mu, phi;
860 detail::fluid_mu_phi(sn.service[i][r], mu, phi);
861 double u = 0.0;
862 for (std::size_t k = 0; k < xservice[i][r].size(); ++k)
863 if (xservice[i][r][k] > 0.0 && mu[k] > 0.0) u += xservice[i][r][k] / mu[k];
864 // Divided by the PEAK scaling, Seff = max(c, max_n alpha(n)), which is
865 // c itself without load dependence.
866 double c = sn.stations[i].nservers;
867 for (std::size_t k = 0; k < sys.lld[i].size(); ++k) c = std::max(c, sys.lld[i][k]);
868 U(i, r) = (is_delay || !std::isfinite(c) || c <= 0.0) ? u : u / c;
869 }
870 }
871
872 // Response time by Little's law, which the reference also applies here.
873 for (std::size_t i = 0; i < M; ++i)
874 for (std::size_t r = 0; r < K; ++r)
875 if (T_(i, r) > 0.0) R(i, r) = Q(i, r) / T_(i, r);
876
877 // A Source and a Sink report no queue length, utilization or response
878 // time: `getAvgHandles` disables those three metric kinds there, which is
879 // the same rule the MVA runner's `filter_metric` applies. The fluid state
880 // does carry mass at an EXT source -- the unit job pool the drift needs --
881 // and reporting it would show a queue that does not exist. Throughput is
882 // kept, since that is the arrival rate.
883 for (std::size_t i = 0; i < M; ++i) {
884 const qn::NodeType nt = sn.stations[i].nodetype;
885 if (nt != qn::NodeType::Source && nt != qn::NodeType::Sink) continue;
886 for (std::size_t r = 0; r < K; ++r) {
887 Q(i, r) = 0.0;
888 U(i, r) = 0.0;
889 R(i, r) = 0.0;
890 }
891 }
892
893 detail::fluid_snap_all(Q, U, R, T_);
894}
895
896namespace detail {
897
898// ---------------------------------------------------------------------------
899// `solver_fluid_ratemult.m`: the time-varying per-event rate multiplier.
900// ---------------------------------------------------------------------------
901/**
902 * `local_nhpp_steps`: a step-faithful (time, rate) sampling of a
903 * piecewise-constant intensity over [t0, thi].
904 *
905 * Each segment contributes TWO samples, at its start and just before its end,
906 * so that the clamped-linear `fluid_interpcols` reproduces a STEP rather than a
907 * ramp between segment values. Sampling once per segment would interpolate
908 * across the whole segment and integrate an intensity the model never has.
909 */
910inline void fluid_nhpp_steps(const std::vector<double>& bp, const std::vector<double>& seg_rate,
911 bool cyclic, double t0, double thi, std::vector<double>& seg_t,
912 std::vector<double>& seg_r) {
913 seg_t.clear();
914 seg_r.clear();
915 if (bp.size() < 2 || seg_rate.empty() || !(thi > t0)) return;
916 const double period = bp.back() - bp.front();
917 std::vector<double> bounds;
918 bounds.push_back(t0);
919 if (cyclic && period > 0.0) {
920 const long kmax = static_cast<long>(std::ceil((thi - t0) / period)) + 2;
921 for (long k = -1; k <= kmax; ++k)
922 for (std::size_t a = 0; a < bp.size(); ++a)
923 bounds.push_back(bp[a] + static_cast<double>(k) * period);
924 } else {
925 for (std::size_t a = 0; a < bp.size(); ++a) bounds.push_back(bp[a]);
926 }
927 bounds.push_back(thi);
928 std::sort(bounds.begin(), bounds.end());
929 bounds.erase(std::remove_if(bounds.begin(), bounds.end(),
930 [&](double v) { return v < t0 || v > thi; }),
931 bounds.end());
932 bounds.erase(std::unique(bounds.begin(), bounds.end()), bounds.end());
933 if (bounds.size() < 2) return;
934
935 // The rate in force on a segment, read at its MIDPOINT so a boundary never
936 // decides which segment is sampled.
937 const auto rate_at = [&](double t) -> double {
938 double offset = t - bp.front();
939 if (cyclic) {
940 if (period > 0.0) {
941 offset = std::fmod(offset, period);
942 if (offset < 0.0) offset += period;
943 } else {
944 offset = 0.0;
945 }
946 } else if (offset < 0.0 || offset >= period) {
947 return 0.0; // zero past a non-cyclic horizon, as the reference
948 }
949 const double pos = bp.front() + offset;
950 std::size_t idx = seg_rate.size() - 1;
951 for (std::size_t k = 1; k < bp.size(); ++k)
952 if (pos < bp[k]) {
953 idx = k - 1;
954 break;
955 }
956 return idx < seg_rate.size() ? seg_rate[idx] : 0.0;
957 };
958
959 const double neps = std::max(1e-9, 1e-6 * (thi - t0));
960 for (std::size_t k = 0; k + 1 < bounds.size(); ++k) {
961 const double a = bounds[k], b = bounds[k + 1];
962 const double r = rate_at(0.5 * (a + b));
963 seg_t.push_back(a);
964 seg_r.push_back(r);
965 seg_t.push_back(std::max(a + neps, b - neps));
966 seg_r.push_back(r);
967 }
968}
969
970/** `local_merge`: elementwise product of two multipliers on the union grid. */
971inline FluidRateMult fluid_ratemult_merge(const FluidRateMult& a, const FluidRateMult& b,
972 std::size_t nevents) {
973 if (a.empty()) return b;
974 if (b.empty()) return a;
975 std::vector<double> tg = a.tgrid;
976 tg.insert(tg.end(), b.tgrid.begin(), b.tgrid.end());
977 std::sort(tg.begin(), tg.end());
978 tg.erase(std::unique(tg.begin(), tg.end()), tg.end());
979 FluidRateMult out;
980 out.tgrid = tg;
981 out.Mmat = Matrix<double>(nevents, tg.size(), 1.0);
982 std::vector<double> ca, cb;
983 for (std::size_t j = 0; j < tg.size(); ++j) {
984 fluid_interpcols(a.tgrid, a.Mmat, tg[j], ca);
985 fluid_interpcols(b.tgrid, b.Mmat, tg[j], cb);
986 for (std::size_t e = 0; e < nevents; ++e) {
987 const double va = e < ca.size() ? ca[e] : 1.0;
988 const double vb = e < cb.size() ? cb[e] : 1.0;
989 out.Mmat(e, j) = va * vb;
990 }
991 }
992 return out;
993}
994
995/**
996 * Whether the OPTIONS make the drift non-autonomous.
997 *
998 * This is a property of what the caller asked for, not of the model: the same
999 * model is autonomous at its nominal and time-varying under `nhpp_sched`. It is
1000 * what `fluid_minnormal_applicable.m:99` consults to steer `default` away from
1001 * the moment closures, and what `fluid_moment_terms.m:114` raises on when one
1002 * was asked for by name.
1003 */
1004inline bool fluid_has_time_varying_rates(const FluidOptions& opt) {
1005 return !opt.rate_traj.empty() || !opt.nhpp_sched.empty() || !opt.rate_sched.empty();
1006}
1007
1008/** The events sourced at (station i, class c), both 0-based. */
1009inline std::vector<std::size_t> fluid_events_of(const FluidOdeSystem& sys, std::size_t i,
1010 std::size_t c) {
1011 std::vector<std::size_t> rows;
1012 const std::size_t lo = sys.layout.qidx[i][c];
1013 const std::size_t hi = lo + sys.layout.kic[i][c];
1014 for (std::size_t e = 0; e < sys.events.size(); ++e)
1015 if (sys.events[e].event_idx >= lo && sys.events[e].event_idx < hi) rows.push_back(e);
1016 return rows;
1017}
1018
1019/** One (station, class) trajectory expanded onto the event rows. */
1020inline FluidRateMult fluid_ratemult_rows(const FluidOdeSystem& sys, std::size_t i, std::size_t c,
1021 const std::vector<double>& seg_t,
1022 const std::vector<double>& seg_r, double nominal) {
1023 FluidRateMult out;
1024 if (seg_t.empty() || !(nominal > 0.0)) return out;
1025 const std::vector<std::size_t> rows = fluid_events_of(sys, i, c);
1026 if (rows.empty()) return out;
1027 out.tgrid = seg_t;
1028 out.Mmat = Matrix<double>(sys.events.size(), seg_t.size(), 1.0);
1029 for (std::size_t j = 0; j < seg_t.size(); ++j)
1030 for (std::size_t e : rows) out.Mmat(e, j) = seg_r[j] / nominal;
1031 return out;
1032}
1033
1034/**
1035 * Port of `solver_fluid_ratemult.m`: compose the three time-varying sources into
1036 * one per-event multiplier, or return an empty one when none is configured.
1037 *
1038 * REFERENCE SCALE MISMATCH, reproduced deliberately. The nominal the multiplier
1039 * divides by is `Mu{i}{c}(1)`, the FIRST PHASE RATE of the process, while the
1040 * numerator is `getRateAt(t)`, which for a MAPt is `map_lambda` of the segment,
1041 * i.e. a STATIONARY ARRIVAL rate. The two coincide for an NHPP, where the
1042 * process has one phase and the phase rate IS the arrival rate, and that is the
1043 * case the reference documents and uses. For a multi-phase MAPt they are
1044 * different quantities and the multiplier is off by their ratio. Ported as
1045 * written, because parity is the contract; flagged here and in
1046 * `_kb/06-solver-catalog.md` rather than silently corrected.
1047 */
1048template <class T>
1049FluidRateMult fluid_ratemult(const qn::NetworkStruct<T>& sn, const FluidOdeSystem& sys,
1050 const FluidOptions& opt) {
1051 const std::size_t nev = sys.events.size();
1052 FluidRateMult out;
1053 if (!opt.rate_traj.empty()) {
1054 if (opt.rate_traj.Mmat.rows() != nev)
1055 throw InputError("solver_fluid_ratemult: rate_traj has " +
1056 std::to_string(opt.rate_traj.Mmat.rows()) +
1057 " rows but the closing ODE has " + std::to_string(nev) + " events");
1058 out = opt.rate_traj;
1059 }
1060
1061 // The horizon a (possibly cyclic) schedule is expanded over. An unbounded
1062 // timespan takes a few periods, so a cycle is REPRESENTED rather than
1063 // clamped after its first segment.
1064 double t0 = 0.0;
1065 const double tend = opt.timespan_end;
1066
1067 FluidRateMult nh;
1068 for (std::size_t a = 0; a < opt.nhpp_sched.size(); ++a) {
1069 const std::size_t i = opt.nhpp_sched[a].first - 1, c = opt.nhpp_sched[a].second - 1;
1070 if (i >= sn.nstations || c >= sn.nclasses) continue;
1071 if (!sys.layout.enabled[i][c]) continue;
1072 const lang::Distrib<T>& d = sn.service[i][c];
1073 if (!d.has_schedule()) continue;
1074 const std::vector<T> mu = d.mu_vec();
1075 if (mu.empty()) continue;
1076 const double nominal = num_traits<T>::to_double(mu[0]);
1077 if (!(nominal > 0.0)) continue;
1078
1079 std::vector<double> bp, seg_r;
1080 for (std::size_t k = 0; k < d.sched_bp.size(); ++k)
1081 bp.push_back(num_traits<T>::to_double(d.sched_bp[k]));
1082 // `getRateAt`: the stationary arrival rate of the segment's pair, which
1083 // for a one-phase process is that phase's rate.
1084 for (std::size_t k = 0; k < d.sched_D0.size(); ++k) {
1085 mam::Map<T> m;
1086 m.D0 = d.sched_D0[k];
1087 m.D1 = d.sched_D1[k];
1088 seg_r.push_back(num_traits<T>::to_double(mam::map_lambda(m)));
1089 }
1090 const double period = bp.empty() ? 0.0 : bp.back() - bp.front();
1091 double thi = tend;
1092 if (!std::isfinite(thi))
1093 thi = (std::isfinite(period) && period > 0.0) ? t0 + 3.0 * period : t0 + 1.0;
1094 std::vector<double> seg_t, seg_v;
1095 fluid_nhpp_steps(bp, seg_r, d.sched_cyclic, t0, thi, seg_t, seg_v);
1096 nh = fluid_ratemult_merge(nh, fluid_ratemult_rows(sys, i, c, seg_t, seg_v, nominal), nev);
1097 }
1098
1099 FluidRateMult rs;
1100 for (std::size_t a = 0; a < opt.rate_sched.size(); ++a) {
1101 const FluidOptions::RateSched& e = opt.rate_sched[a];
1102 const std::size_t i = e.station - 1, c = e.cls - 1;
1103 if (i >= sn.nstations || c >= sn.nclasses) continue;
1104 if (!sys.layout.enabled[i][c]) continue;
1105 if (e.tgrid.size() != e.rates.size() || e.tgrid.empty())
1106 throw InputError("solver_fluid_ratemult: rate_sched tgrid and rates must be "
1107 "non-empty and of equal length");
1108 double nominal = e.nominal;
1109 if (!(nominal > 0.0)) {
1110 const std::vector<T> mu = sn.service[i][c].mu_vec();
1111 if (mu.empty()) continue;
1112 nominal = num_traits<T>::to_double(mu[0]);
1113 }
1114 if (!(nominal > 0.0)) continue;
1115 rs = fluid_ratemult_merge(rs, fluid_ratemult_rows(sys, i, c, e.tgrid, e.rates, nominal),
1116 nev);
1117 }
1118
1119 out = fluid_ratemult_merge(out, nh, nev);
1120 out = fluid_ratemult_merge(out, rs, nev);
1121 return out;
1122}
1123
1124/**
1125 * `slowrate` of the reference: the smallest phase rate among the service
1126 * processes the layout enables, which is what sets every integration horizon
1127 * here. Finite rates above `tol` only, falling back to 1 when the model has
1128 * none, exactly as `solver_fluid.m` does.
1129 */
1130template <class T>
1131double fluid_slow_rate(const qn::NetworkStruct<T>& sn, const FluidLayout& L, double tol) {
1132 double min_rate = std::numeric_limits<double>::infinity();
1133 for (std::size_t i = 0; i < sn.nstations; ++i)
1134 for (std::size_t r = 0; r < sn.nclasses; ++r) {
1135 if (!L.enabled[i][r]) continue;
1136 const lang::Distrib<T>& d = sn.service[i][r];
1137 for (std::size_t k = 0; k < d.D0.rows(); ++k) {
1138 const double mu = -num_traits<T>::to_double(d.D0(k, k));
1139 if (mu > tol && std::isfinite(mu)) min_rate = std::min(min_rate, mu);
1140 }
1141 }
1142 return std::isfinite(min_rate) ? min_rate : 1.0;
1143}
1144
1145/**
1146 * The method switch of `solver_fluid_analyzer.m`, without its trailing
1147 * correction. Kept separate so the correction below runs on EVERY branch, as
1148 * it does in the reference; folding it into each branch's exit would repeat it
1149 * six times and let one path drift.
1150 *
1151 * Refuses by name anything the port does not cover, rather than returning a
1152 * number computed by the wrong model.
1153 */
1154template <class T>
1155FluidSolution fluid_dispatch(const qn::NetworkStruct<T>& sn, const FluidOptions& opt) {
1156 if (!std::is_same<T, double>::value)
1157 throw UnsupportedError(
1158 "solver_fluid: the fluid solver integrates its drift with LSODA, whose coefficients "
1159 "assume double precision; rerun with --arith double");
1160 // `fluid.<name>` is the same method under its qualified spelling.
1161 std::string m = opt.method;
1162 if (m.compare(0, 6, "fluid.") == 0) m = m.substr(6);
1164 bool statedep_family = false;
1165 // `solver_fluid_analyzer.m` routes default, matrix AND pnorm to
1166 // solver_fluid_matrix -- `ode_pnorm.m` is never reached under the name
1167 // `pnorm`, so pnorm here means "the matrix method with p-norm smoothing".
1168 // `@@SolverFLD/runAnalyzer.m` resolves the method BEFORE the analyzer sees
1169 // it, and the resolution depends on the model, not just the name:
1170 // a Cache -> rmf
1171 // a DPS station -> closing (the matrix method cannot express DPS)
1172 // otherwise -> matrix
1173 // and `matrix`/`pnorm` asked for EXPLICITLY on a DPS model is an error, not
1174 // a silent downgrade. Without this gate the C++ ran matrix on a DPS model
1175 // and redistributed the population differently from every other codebase.
1176 bool has_dps = false, has_cache = false;
1177 for (const auto& st : sn.stations)
1178 if (st.sched == lang::SchedStrategy::DPS) has_dps = true;
1179 for (const qn::NodeDef& nd : sn.nodes)
1180 if (nd.nodetype == qn::NodeType::Cache) has_cache = true;
1181 if ((m == "matrix" || m == "pnorm") && has_dps)
1182 throw UnsupportedError(
1183 "solver_fluid: the matrix method does not support DPS scheduling; use method "
1184 "'closing' (which is what 'default' selects on a DPS model)");
1185 if (m == "default") {
1186 if (has_cache)
1187 throw UnsupportedError(
1188 "solver_fluid: a Cache model resolves to the 'rmf' fluid method, which is ported "
1189 "in fluid_cacheqn.h and reached through solver_fluid_run_analyzer (fluid_runner.h); this "
1190 "function is solver_fluid_analyzer alone and cannot call it without a cyclic "
1191 "include");
1192 if (has_dps) m = "closing";
1193 }
1194 const bool matrix_family = (m == "default" || m == "matrix" || m == "pnorm");
1195 if (m == "statedep") {
1196 statedep_family = true;
1197 sd_kind = StateDepKind::StateDep;
1198 } else if (m == "softmin") {
1199 statedep_family = true;
1200 sd_kind = StateDepKind::SoftMin;
1201 } else if (!(matrix_family || m == "closing" || m == "tbi" || m == "diffusion" || m == "mfq")) {
1202 throw UnsupportedError("solver_fluid: the '" + opt.method +
1203 "' fluid method is not solved here; available are 'closing', "
1204 "'statedep', 'softmin', 'pnorm', 'matrix', 'tbi', 'diffusion' and "
1205 "'mfq', while 'rmf', 'minnormal', 'refined', 'dae' and 'kp' are "
1206 "reached through solver_fluid_run_analyzer (fluid_runner.h), which is the "
1207 "port of runAnalyzer's resolution");
1208 }
1209
1210 const std::size_t M = sn.nstations, K = sn.nclasses;
1211 FluidOdeSystem sys = fluid_ode_system(sn);
1212 // The moment closure travels on the options, so it reaches the drift through
1213 // the SAME system the metrics are read from; `solver_fluid_moments` is the
1214 // only caller that fills it and an empty one is the first-order drift.
1215 sys.closure = opt.closure;
1216 // `solver_fluid_odes.m:127-134`: the time-varying rate multiplier is built
1217 // once, beside the drift it scales, and an empty one leaves the autonomous
1218 // closure exactly as it was.
1219 sys.ratemult = detail::fluid_ratemult(sn, sys, opt);
1220 const FluidLayout& L = sys.layout;
1221 if (L.nstates == 0)
1222 throw InputError("solver_fluid: no station serves any class, so the drift is empty");
1223
1224 // ---- the slowest rate sets the integration horizon --------------------
1225 const double min_rate = fluid_slow_rate(sn, L, opt.tol);
1226
1227 // ---- integrate, restarting from the previous end state ----------------
1228 std::vector<double> x = opt.init_sol.empty() ? detail::fluid_default_initsol(sn, L) : opt.init_sol;
1229 if (x.size() != L.nstates)
1230 throw InputError("solver_fluid: init_sol has " + std::to_string(x.size()) +
1231 " entries but the fluid state has " + std::to_string(L.nstates));
1232
1233 // ---- the exact single Markov-modulated fluid queue --------------------
1234 if (m == "mfq") {
1235 // `solver_fluid_analyzer.m` tries the AoI topology FIRST, because it is
1236 // the more specific one: a capacity-1 or capacity-2 single queue is
1237 // also a single queue, and the age laws are what that model is for.
1238 const AoiTopology atop = aoi_is_aoi(sn);
1239 if (atop.ok) {
1240 const FluidAoiResult ar = fluid_aoi(sn, atop, opt.aoi_preemption);
1241 FluidSolution out;
1242 out.iters = 1;
1243 out.method = "mfq";
1244 out.has_aoi = true;
1245 out.aoi = ar.age;
1246 out.QN = Matrix<double>(M, K, 0.0);
1247 out.UN = Matrix<double>(M, K, 0.0);
1248 out.RN = Matrix<double>(M, K, 0.0);
1249 out.TN = Matrix<double>(M, K, 0.0);
1250 out.XN.assign(K, 0.0);
1251 out.CN.assign(K, 0.0);
1252 for (std::size_t r = 0; r < K; ++r) {
1253 out.QN(atop.queue, r) = ar.QN[r];
1254 out.UN(atop.queue, r) = ar.UN[r];
1255 out.RN(atop.queue, r) = ar.RN[r];
1256 out.TN(atop.queue, r) = ar.TN[r];
1257 out.TN(atop.source, r) = ar.TN[r];
1258 out.XN[r] = ar.TN[r];
1259 out.CN[r] = ar.RN[r];
1260 }
1261 return out;
1262 }
1263
1264 const MfqTopology top = mfq_is_single_queue(sn);
1265 // Distinct priorities among the open classes send the model to the
1266 // fluid PRIORITY queue instead of the single fluid-fluid queue.
1267 bool mixed_prio = false;
1268 if (top.ok)
1269 for (std::size_t j = 1; j < top.open_classes.size(); ++j)
1270 if (sn.classes[top.open_classes[j]].prio != sn.classes[top.open_classes[0]].prio)
1271 mixed_prio = true;
1272 if (mixed_prio) {
1273 const MfqPrioResult pr = fluid_mfq_prio(sn, top, opt.tol);
1274 if (pr.fallback) {
1275 // The reference warns and runs the matrix method instead.
1276 FluidOptions fb = opt;
1277 fb.method = "matrix";
1278 return solver_fluid(sn, fb);
1279 }
1280 FluidSolution out;
1281 out.iters = 1;
1282 out.method = "mfq";
1283 out.QN = Matrix<double>(M, K, 0.0);
1284 out.UN = Matrix<double>(M, K, 0.0);
1285 out.RN = Matrix<double>(M, K, 0.0);
1286 out.TN = Matrix<double>(M, K, 0.0);
1287 out.XN.assign(K, 0.0);
1288 out.CN.assign(K, 0.0);
1289 for (std::size_t r = 0; r < K; ++r) {
1290 out.QN(top.queue, r) = pr.QN[r];
1291 out.TN(top.queue, r) = pr.TN[r];
1292 out.TN(top.source, r) = pr.TN[r];
1293 out.XN[r] = pr.TN[r];
1294 }
1295 // The analyzer's post-processing, which is what getAvg reports: a
1296 // class holding no fluid has neither utilization nor response time,
1297 // and the utilization of the rest is capped by its own fluid level.
1298 double ufull = 0.0, tsum = 0.0;
1299 for (std::size_t r = 0; r < K; ++r)
1300 if (pr.QN[r] > 0.0) {
1301 ufull += pr.UN[r];
1302 tsum += pr.TN[r] / num_traits<T>::to_double(sn.rates(top.queue, r));
1303 }
1304 const double servers = sn.stations[top.queue].nservers;
1305 for (std::size_t r = 0; r < K; ++r) {
1306 if (!(pr.QN[r] > 0.0)) continue;
1307 const double share =
1308 ufull * (pr.TN[r] / num_traits<T>::to_double(sn.rates(top.queue, r))) / tsum;
1309 out.UN(top.queue, r) = std::min(1.0, std::min(pr.QN[r] / servers, share));
1310 out.RN(top.queue, r) = pr.QN[r] / pr.TN[r];
1311 out.CN[r] = out.RN(top.queue, r);
1312 }
1313 return out;
1314 }
1315 if (top.ok && top.open_classes.size() > 1)
1316 throw UnsupportedError(
1317 "fluid mfq: the single fluid-fluid queue analyzes ONE open class, and this model "
1318 "has several at equal priority; the reference silently reports class 1 only");
1319 // MFQ IS A SINGLE-QUEUE METHOD AND FALLS BACK, which is what the
1320 // reference does: solver_fluid_analyzer.m warns "MFQ not applicable:
1321 // ... Falling back to matrix method" and re-enters solver_fluid_matrix.
1322 // Refusing instead made 'mfq' -- and therefore its aliases 'butools' and
1323 // 'aoi' -- reject every multi-station model that MATLAB and native
1324 // python both answer. This port has no line_warning channel (see
1325 // mva_dispatch.h), so the substitution is visible in `method` instead,
1326 // which reports "matrix" exactly as the reference's does.
1327 if (!top.ok) {
1328 FluidOptions mopt = opt;
1329 mopt.method = "matrix";
1330 return fluid_dispatch(sn, mopt);
1331 }
1332 const MfqResult r = fluid_mfq(sn, top, opt.tol);
1333 FluidSolution out;
1334 out.iters = 1; // solved, not iterated
1335 out.method = "mfq";
1336 out.QN = Matrix<double>(M, K, 0.0);
1337 out.UN = Matrix<double>(M, K, 0.0);
1338 out.RN = Matrix<double>(M, K, 0.0);
1339 out.TN = Matrix<double>(M, K, 0.0);
1340 out.QN(top.queue, top.cls) = r.QN;
1341 out.UN(top.queue, top.cls) = r.UN;
1342 out.RN(top.queue, top.cls) = r.RN;
1343 out.TN(top.queue, top.cls) = r.TN;
1344 out.TN(top.source, top.cls) = r.TN; // the Source reports its arrivals
1345 out.XN.assign(K, 0.0);
1346 out.CN.assign(K, 0.0);
1347 out.XN[top.cls] = r.TN;
1348 out.CN[top.cls] = r.RN;
1349 return out;
1350 }
1351
1352 // ---- the diffusion approximation: a stochastic trajectory -------------
1353 if (m == "diffusion") {
1354 DiffusionOptions dopt;
1355 dopt.steps = opt.iter_max > 2 ? opt.iter_max : 10000;
1356 dopt.dt = opt.timestep;
1357 dopt.seed = opt.seed;
1358 const DiffusionResult dr = fluid_diffusion(sn, dopt);
1359 FluidSolution out;
1360 out.iters = 1; // a single trajectory
1361 out.method = "diffusion";
1362 out.QN = dr.QN;
1363 out.UN = Matrix<double>(M, K, 0.0);
1364 out.RN = Matrix<double>(M, K, 0.0);
1365 out.TN = Matrix<double>(M, K, 0.0);
1366 for (std::size_t i = 0; i < M; ++i) {
1367 const double c = sn.stations[i].nservers;
1368 const bool inf_server = !std::isfinite(c);
1369 for (std::size_t r = 0; r < K; ++r) {
1370 const double rate = num_traits<T>::to_double(sn.rates(i, r));
1371 if (rate > 0.0 && std::isfinite(rate)) {
1372 // An infinite server clears the whole queue; a single
1373 // server clears at most one job's worth at a time.
1374 out.TN(i, r) = inf_server ? out.QN(i, r) * rate
1375 : std::min(out.QN(i, r), 1.0) * rate;
1376 }
1377 out.UN(i, r) = inf_server ? out.QN(i, r) : std::min(out.QN(i, r) / c, 1.0);
1378 // TN is zero only to the integrator's accuracy: a class that never visits leaves
1379 // a ~1e-20 residue in TN too, and a strict > 0 test then divides residue by residue.
1380 if (out.TN(i, r) > lang::GlobalConstants::Zero)
1381 out.RN(i, r) = out.QN(i, r) / out.TN(i, r);
1382 }
1383 }
1384 detail::fluid_snap_all(out.QN, out.UN, out.RN, out.TN);
1385 out.XN.assign(K, 0.0);
1386 out.CN.assign(K, 0.0);
1387 for (std::size_t r = 0; r < K; ++r) {
1388 const std::size_t rs = sn.classes[r].refstat;
1389 if (rs >= 1 && rs <= M) out.XN[r] = out.TN(rs - 1, r);
1390 double q = 0.0;
1391 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, r);
1392 if (out.XN[r] > 0.0) out.CN[r] = q / out.XN[r];
1393 }
1394 return out;
1395 }
1396
1397 LsodaOptions lopt;
1398 lopt.rtol = opt.tol;
1399 lopt.atol = opt.tol;
1400
1401 // ---- the matrix method: one generator, one integration ----------------
1402 if (matrix_family) {
1403 // `pnorm` is the matrix method with the smoothing switched on; plain
1404 // `matrix`/`default` leave pstar at zero, which selects the hard min,
1405 // unless the caller asked for the smoothing explicitly.
1406 const double ps = (m == "pnorm" || opt.pstar_set) ? opt.pstar : 0.0;
1407 const FluidMatrixSystem ms = fluid_matrix_system(sn, x, ps);
1408 const std::function<void(double, const double*, double*)> mdrift = fluid_matrix_drift(ms);
1409 const double t1 =
1410 std::min(opt.timespan_end,
1411 10.0 * static_cast<double>(opt.iter_max) / ms.min_rate);
1412 std::vector<double> xm = fluid_integrate_leg(mdrift, 0.0, t1, ms.x0, lopt);
1413 for (double& v : xm)
1414 if (v < 0.0) v = 0.0;
1415
1416 // DEGENERATE DRIFT: re-integrate with a closed saturation term, do not
1417 // touch the answer that came back. min(E[n], c) is FLAT above the server
1418 // count, so a network of saturated stations has a CONTINUUM of fixed
1419 // points and this method returns whichever one the integrator stopped at
1420 // -- [9 1] against an exact [5 5] on two identical saturated stations in
1421 // a closed cycle, and [8 2] with two servers each. The repair is applied
1422 // to the DRIFT, not to the point: the same trajectory is integrated again
1423 // with E[min(n, c)] in place of min(E[n], c), which is strictly
1424 // increasing and so isolates one fixed point.
1425 //
1426 // WHY A CLOSURE AND NOT A SMOOTHED min: any smoothing sharp enough to
1427 // stay faithful to min away from the kink is numerically FLAT far from
1428 // it. The Boltzmann softmin at alpha = 20 carries a restoring force of
1429 // exp(-160) at the [9 1] point, and the p-norm trades the two off
1430 // directly (pstar = 2 recovers [5 5], pstar = 128 gives [8.94 1.06]).
1431 // The closure escapes the trade-off because its slope comes from the
1432 // VARIANCE of the marginal rather than from a smoothing width.
1433 //
1434 // Only a model that is ACTUALLY degenerate pays for it, so a well-posed
1435 // model integrates once and is unchanged.
1436 FluidMatrixSystem msr = ms;
1437 if (ps <= 0.0 && fluid_matrix_degenerate(ms, mdrift, xm, K)) {
1438 msr.var_closure = true;
1439 const std::function<void(double, const double*, double*)> cdrift =
1440 fluid_matrix_drift(msr);
1441 std::vector<double> xc = fluid_integrate_leg(cdrift, 0.0, t1, ms.x0, lopt);
1442 bool finite = xc.size() == xm.size();
1443 for (std::size_t a = 0; finite && a < xc.size(); ++a)
1444 if (!std::isfinite(xc[a])) finite = false;
1445 if (finite) {
1446 for (double& v : xc)
1447 if (v < 0.0) v = 0.0;
1448 xm = xc;
1449 } else {
1450 // A failed repair leaves the unrepaired answer standing rather
1451 // than turning a wrong number into no number.
1452 msr.var_closure = false;
1453 }
1454 }
1455
1456 // The same share the drift used, smoothed, closed or neither
1457 std::vector<double> theta(ms.nstates, 0.0);
1458 detail::fluid_matrix_theta(msr, xm.data(), theta);
1459
1460 FluidSolution out;
1461 out.iters = 1; // a single integration, unlike the closing iteration
1462 out.method = (m == "pnorm") ? "pnorm" : "matrix";
1463 out.xvec = xm;
1464 out.QN = Matrix<double>(M, K, 0.0);
1465 out.UN = Matrix<double>(M, K, 0.0);
1466 out.RN = Matrix<double>(M, K, 0.0);
1467 out.TN = Matrix<double>(M, K, 0.0);
1468 for (std::size_t i = 0; i < M; ++i)
1469 for (std::size_t r = 0; r < K; ++r) {
1470 double q = 0.0, u = 0.0, t = 0.0;
1471 for (std::size_t a = 0; a < ms.nstates; ++a) {
1472 q += ms.sqc(i * K + r, a) * xm[a];
1473 u += ms.suc(i * K + r, a) * theta[a];
1474 t += ms.stc(i * K + r, a) * theta[a];
1475 }
1476 out.QN(i, r) = q;
1477 // An infinite server reports the queue length itself as its
1478 // utilization -- there is no capacity to divide by. The SUC map
1479 // carries 1/S with S substituted by the closed population, so
1480 // the delay rows have to be restated here, exactly as
1481 // `solver_fluid.m` does for its UNt.
1482 out.UN(i, r) =
1483 (sn.stations[i].sched == lang::SchedStrategy::INF ||
1484 !std::isfinite(sn.stations[i].nservers))
1485 ? q
1486 : u;
1487 out.TN(i, r) = t;
1488 // Little's law, as the reference -- but TN is zero only to the
1489 // integrator's accuracy, so a class that never visits leaves a
1490 // residue in q and t alike and a strict > 0 test divides one by
1491 // the other. See solver_fluid_analyzer.m.
1492 if (t > lang::GlobalConstants::Zero) out.RN(i, r) = q / t;
1493 }
1494 // A Source reports arrivals only. Its states are held at zero with
1495 // theta = 0, so STC*theta gives it no throughput at all; the reference
1496 // still shows the arrival rate there, so it is restated from the rates
1497 // that were injected into the downstream queues.
1498 for (std::size_t i = 0; i < M; ++i) {
1499 const qn::NodeType nt = sn.stations[i].nodetype;
1500 if (nt != qn::NodeType::Source && nt != qn::NodeType::Sink) continue;
1501 for (std::size_t r = 0; r < K; ++r) {
1502 out.QN(i, r) = 0.0;
1503 out.UN(i, r) = 0.0;
1504 out.RN(i, r) = 0.0;
1505 if (nt == qn::NodeType::Source && ms.src_arrival.rows() == M)
1506 out.TN(i, r) = ms.src_arrival(i, r);
1507 }
1508 }
1509 detail::fluid_snap_all(out.QN, out.UN, out.RN, out.TN);
1510 out.XN.assign(K, 0.0);
1511 out.CN.assign(K, 0.0);
1512 for (std::size_t r = 0; r < K; ++r) {
1513 const std::size_t rs = sn.classes[r].refstat;
1514 if (rs >= 1 && rs <= M) out.XN[r] = out.TN(rs - 1, r);
1515 double q = 0.0;
1516 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, r);
1517 if (out.XN[r] > 0.0) out.CN[r] = q / out.XN[r];
1518 }
1519 return out;
1520 }
1521
1522 // THE PASS LOOP BELOW RESTARTS THE INTEGRATOR ONCE PER PASS, so its local
1523 // error is paid iter_max times over and lands on a state that is already at
1524 // the fixed point. At the nominal tol = 1e-4 that accumulated to 3.3e-4 on
1525 // oqn_basic -- Queue1 QLen 0.10023962 where MATLAB, the JAR and native
1526 // Python all return 0.10020646880565 -- which reads as a DIFFERENT fluid
1527 // fixed point and is nothing of the kind: the same run at 1e-6 reproduces
1528 // the reference to the last digit. So the integrator runs at tol/iter_max
1529 // while `opt.tol` stays what the caller asked of the FIXED POINT, which is
1530 // the quantity that tolerance names. The single-integration matrix family
1531 // above is exempt because nothing is restarted there, and so is
1532 // `solver_fluid_transient` below, which integrates one grid in one call.
1533 lopt.rtol = lopt.atol =
1534 opt.tol / std::max<double>(1.0, static_cast<double>(opt.iter_max));
1535
1536 // `ode_eliminate_immediate` is applied to the CLOSING drift only, which is
1537 // the `otherwise` arm of `solver_fluid_odes.m` where the reference applies
1538 // it: the state-dependent drifts are not a jump/rate system and `tbi`
1539 // partitions the unreduced one.
1540 FluidOdeSystem dsys = sys;
1541 FluidImmediateResult imm_result;
1542 if (fluid_hide_immediate(sn, opt) && !statedep_family) {
1543 imm_result = fluid_eliminate_immediate(sn, sys);
1544 if (imm_result.eliminated) {
1545 dsys = imm_result.sys;
1546 // A complemented coordinate receives no transitions at all, so mass left
1547 // there would sit stranded rather than be integrated. It is PROJECTED, not
1548 // zeroed: those are jobs, and a cold start puts none there but a warm start
1549 // from an earlier LN iterate does.
1550 std::vector<double> xp(x.size(), 0.0);
1551 for (std::size_t f = 0; f < x.size() && f < imm_result.absorb.rows(); ++f)
1552 for (std::size_t sidx = 0; sidx < x.size() && sidx < imm_result.absorb.cols();
1553 ++sidx)
1554 xp[sidx] += x[f] * imm_result.absorb(f, sidx);
1555 for (std::size_t a = 0; a < x.size(); ++a) x[a] = xp[a];
1556 }
1557 }
1558
1559 const std::function<void(double, const double*, double*)> drift =
1560 statedep_family ? fluid_drift_statedep(fluid_statedep_system(sn, sd_kind, opt.softmin_alpha,
1561 opt.pstar))
1562 : fluid_drift(dsys);
1563
1564 const std::vector<std::vector<std::size_t>> tbi_cells =
1565 (m == "tbi") ? tbi_partition(sn, TbiOptions().cellsize)
1566 : std::vector<std::vector<std::size_t>>();
1567 // Early stop on the GEOMETRIC TAIL of the window iteration; see the header.
1568 // The residual cannot go below the integrator's own error, so a request for
1569 // less than `tol` asks for something unobservable.
1570 const double drift_tol = std::max(opt.iter_tol, opt.tol);
1571 const double drift_safety = 0.01; // headroom, since rho is estimated
1572 const double min_horizon = 10.0 / min_rate;
1573 double moved_prev = std::numeric_limits<double>::infinity();
1574 std::vector<double> rho_hist(3, std::numeric_limits<double>::quiet_NaN());
1575 int drift_below = 0;
1576 std::vector<double> drift_buf(x.size(), 0.0);
1577
1578 double t0 = 0.0;
1579 std::size_t iter = 0;
1580 for (; iter < opt.iter_max; ++iter) {
1581 const double horizon = 10.0 * static_cast<double>(iter + 1) / min_rate;
1582 const double t1 = std::min(opt.timespan_end, horizon);
1583 if (!(t1 > t0)) break;
1584 const std::vector<double> prev = x;
1585 // A FIXED POINT ENDS THE WINDOW IN CLOSED FORM, and this is what keeps a
1586 // window that has already converged from becoming a window that never
1587 // returns. Armed only for an AUTONOMOUS drift, so f(x*) = 0 means
1588 // x(t) = x* for every later t and the rest of the span is known exactly
1589 // rather than integrated.
1590 //
1591 // THE THRESHOLD IS ROUND-OFF, NOT `drift_tol`. That tolerance (1e-4 by
1592 // default) says "converged to what the caller asked for", and a state
1593 // that merely satisfies it is still moving -- cutting the window there
1594 // was measured to shift results by 1.8e-5 in the Python twin. A
1595 // normalized residual below GlobalConstants::Zero is the stronger claim
1596 // that the drift is zero to double precision, which is what makes
1597 // skipping the remaining span exact instead of approximate.
1598 //
1599 // WHY THE WINDOW DOES NOT END ON ITS OWN. A stiff step controller handed
1600 // a state it is already at cannot pick a step: on the LN layer of
1601 // test_LQN_13 the Python twin advanced t by 0.011 in 20000 steps from a
1602 // state with |f| = 1.5e-16, and covered the whole 1000-unit span in 60
1603 // steps once that state was nudged 1e-6 off the equilibrium. That layer
1604 // carries an Immediate() coordinate -- an eigenvalue of exactly
1605 // -GlobalConstants::Immediate = -1e8 that the immediate elimination did
1606 // not fold out -- so the controller is pinned near 1/1e8 while the
1607 // window runs to 10*iter/min_rate. 272 windows took 3.0 s between them
1608 // and the 273rd had not returned after 143 s.
1609 // A FINITE timespan is a transient request, which must reach its end time
1610 // rather than stop at the fixed point -- the same gate the geometric-tail
1611 // test below carries.
1612 const bool fp_armed = opt.earlystop && !std::isfinite(opt.timespan_end)
1613 && !fluid_has_time_varying_rates(opt);
1614 if (fp_armed) {
1615 drift(t0, x.data(), drift_buf.data());
1616 double dn = 0.0, dtot = 0.0;
1617 for (std::size_t i = 0; i < x.size(); ++i) {
1618 dn += std::fabs(drift_buf[i]);
1619 dtot += x[i];
1620 }
1621 if (dtot > 0.0 && dn / 2.0 / dtot / min_rate < lang::GlobalConstants::Zero) {
1622 t0 = t1;
1623 ++iter;
1624 break;
1625 }
1626 }
1627 // THE TEST ABOVE IS TAKEN ONCE, at the window's first instant. A window
1628 // that reaches the fixed point AFTER its first step is the same stall
1629 // entered one step later, and nothing above catches it, so the SAME test
1630 // rides on the integrator as a per-accepted-step stop. That is where
1631 // MATLAB's OutputFcn chain and the native-Python step loop take it, so
1632 // the four codebases stop on one condition at one place. `lsoda.h`
1633 // reproduces the output grid through `LsodaStepper` when this is set and
1634 // is untouched when it is not.
1635 // NOT ON THE TBI ARM. `tbi_advance` integrates one CELL at a time, on a
1636 // state vector holding only that cell's entries, while `drift` is the
1637 // WHOLE model's: handing it a cell-local vector reads and writes past
1638 // the end of both buffers. MATLAB arms the guard in
1639 // `solver_fluid_iteration.m` and Java in
1640 // `ClosingAndStateDepMethodsAnalyzer`, neither of which is the tbi arm,
1641 // so leaving tbi unarmed is also what keeps the four codebases aligned.
1642 // A stop test for tbi would have to be built from the CELL's drift,
1643 // inside `tbi_advance`, where the two state spaces agree.
1644 lopt.step_stop = {};
1645 if (fp_armed && m != "tbi") {
1646 const double fp_rate = min_rate;
1647 lopt.step_stop = [&drift, fp_rate](double tt, const std::vector<double>& yy) {
1648 if (yy.empty() || !(fp_rate > 0.0)) return false;
1649 std::vector<double> dy(yy.size(), 0.0);
1650 drift(tt, yy.data(), dy.data());
1651 double dn = 0.0, dtot = 0.0;
1652 for (std::size_t i = 0; i < yy.size(); ++i) {
1653 dn += std::fabs(dy[i]);
1654 dtot += yy[i];
1655 }
1656 return dtot > 0.0 && dn / 2.0 / dtot / fp_rate < lang::GlobalConstants::Zero;
1657 };
1658 }
1659 if (m == "tbi") {
1660 // Same drift, decomposed over cells; see fluid_tbi.h.
1661 x = tbi_advance(sys, tbi_cells, x, t0, t1, TbiOptions(), lopt);
1662 } else if (opt.stiff) {
1663 // `ode_solve_stiff.m`: same leg, same tolerances, implicit method
1664 // -- and the same per-restart tightening, since this arm is one leg
1665 // of the very loop that pays the local error iter_max times.
1666 FluidStiffOptions sopt;
1667 sopt.rtol = lopt.rtol;
1668 sopt.atol = lopt.atol;
1669 // The stiff arm is the Rosenbrock method, not LSODA, so it carries
1670 // the stop itself: this is the arm the settling windows run on.
1671 if (lopt.step_stop) {
1672 const std::function<bool(double, const std::vector<double>&)> ss_stop =
1673 lopt.step_stop;
1674 sopt.step_stop = [ss_stop](const double& tt, const std::vector<double>& yy) {
1675 return ss_stop(tt, yy);
1676 };
1677 }
1678 const OdeSolution<double> ss = fluid_ode_solve_stiff(drift, t0, t1, x, sopt);
1679 x = ss.final_state();
1680 } else {
1681 x = fluid_integrate_leg(drift, t0, t1, x, lopt);
1682 }
1683 // The drift conserves mass but the integrator need not, to the last
1684 // digit; a small negative mass is noise, so it is clamped rather than
1685 // allowed to feed back as a negative rate.
1686 for (double& v : x)
1687 if (v < 0.0) v = 0.0;
1688 // THAT CLAMP IS ALSO WHERE A DIVERGENCE HIDES. Under the moment closure
1689 // the drift can leave the simplex, and clamping the result turns a state
1690 // that is not a solution into one that merely looks like it settled --
1691 // every later window then integrates from it. The population of a closed
1692 // class is conserved EXACTLY by the drift, so a deviation is a
1693 // divergence and nothing else; raise the error the fallback ladder in
1694 // fluid_runner.h already catches, so 'dae' and then 'matrix'/'closing'
1695 // answer the model. Gated on gaussian() so the first-order pass, which
1696 // IS the ladder's own fallback, keeps identical behaviour.
1697 if (opt.closure.gaussian()) {
1698 const int bad = fluid_conservation_violation(sn, sys.layout, x);
1699 if (bad >= 0) {
1701 "The moment-closure drift left the model: closed chain " +
1702 std::to_string(bad) + " moved more than " +
1703 std::to_string(static_cast<int>(100 * kFluidConservationTol)) +
1704 "% of a population the drift conserves exactly, by t = " +
1705 std::to_string(t1) +
1706 ", so the excursion is a divergence rather than a solution. "
1707 "Falling back to a first-order closure.");
1708 }
1709 }
1710 t0 = t1;
1711
1712 double moved = 0.0, total = 0.0;
1713 for (std::size_t i = 0; i < x.size(); ++i) {
1714 moved += std::fabs(x[i] - prev[i]);
1715 total += prev[i];
1716 }
1717 const double ratio = (total > 0.0) ? moved / 2.0 / total : 0.0;
1718 // A FINITE timespan is a transient request, which must reach its end
1719 // time rather than stop at the fixed point; see the header.
1720 //
1721 // iter_tol = 0, the default, never fires here: the reference runs every
1722 // one of its iter_max passes and this port now does too.
1723 if (opt.iter_tol > 0.0 && ratio < opt.iter_tol && !std::isfinite(opt.timespan_end)) {
1724 ++iter;
1725 break;
1726 }
1727 // THE TERMINATION TEST. `ratio` alone is the mass moved over ONE window
1728 // and drops the geometric tail behind it; summing that tail,
1729 // ratio*rho/(1-rho), is what the header says it is missing. rho comes
1730 // from the iteration itself, so no rate has to stand in for the slowest
1731 // system mode -- when one does, the stop lands 3% short. The drift, zero
1732 // AT a fixed point, is an independent second bound. Both must hold on
1733 // two consecutive windows, past the slowest relaxation time.
1734 if (opt.earlystop && iter > 0 && !std::isfinite(opt.timespan_end) && t1 >= min_horizon) {
1735 rho_hist[iter % rho_hist.size()] =
1736 ratio / std::max(moved_prev, lang::GlobalConstants::Zero);
1737 double rho = 0.0;
1738 for (double v : rho_hist)
1739 if (std::isfinite(v) && v > rho) rho = v;
1740 drift(t1, x.data(), drift_buf.data());
1741 double dn = 0.0, dtot = 0.0;
1742 for (std::size_t i = 0; i < x.size(); ++i) {
1743 dn += std::fabs(drift_buf[i]);
1744 dtot += x[i];
1745 }
1746 const double drift_displ = (dtot > 0.0) ? dn / 2.0 / dtot / min_rate : 0.0;
1747 // a non-contracting iteration has no tail to sum: it is not converging
1748 //
1749 // THE 1e-6 GATE IS NOT AN OVERSIGHT, even though it sits below the
1750 // integrator's own tol. Relaxing it to "the moved mass reached the
1751 // integrator floor, so trust the drift residual alone" was TRIED and
1752 // REVERTED: it stops the M/M/1 rho = 0.9 minnormal solve at 7.018088
1753 // against the 7.021524680 all four codebases agree on, and it truncates
1754 // the statedep response-time trajectory to t = 60 instead of its 2000.
1755 // The accuracy of this loop comes from running the windows, so the stop
1756 // has to stay conservative. It is also not what makes a solve hang: see
1757 // _kb/06-solver-catalog.md, where the minnormal closure diverges outright
1758 // on a bounded multiserver station.
1759 if (rho < 1.0 && ratio * rho / (1.0 - rho) < drift_safety * drift_tol
1760 && drift_displ < drift_tol) {
1761 if (++drift_below >= 2) {
1762 ++iter;
1763 break;
1764 }
1765 } else {
1766 drift_below = 0;
1767 }
1768 }
1769 moved_prev = ratio;
1770 if (t1 >= opt.timespan_end) {
1771 ++iter;
1772 break;
1773 }
1774 }
1775
1776 // ---- read the metrics off the converged state -------------------------
1777 FluidSolution out;
1778 out.iters = iter;
1779 out.method = (statedep_family || m == "tbi") ? m : std::string("closing");
1780 out.xvec = x;
1781 out.QN = Matrix<double>(M, K, 0.0);
1782 out.UN = Matrix<double>(M, K, 0.0);
1783 out.RN = Matrix<double>(M, K, 0.0);
1784 out.TN = Matrix<double>(M, K, 0.0);
1785
1786 fluid_closing_metrics(sn, sys, m, x, out.QN, out.UN, out.RN, out.TN);
1787
1788 // THE COMPLETIONS AN ELIMINATED COORDINATE MAKES ARE NOT LOST. The metrics
1789 // above read throughputs off the STATE, as x_f * mu_f * phi_f summed over
1790 // phases, and an eliminated coordinate holds no mass there -- so its
1791 // completions, which are FINITE because mu_f is InfRate, would silently
1792 // vanish and the station would stop balancing against its neighbours. Their
1793 // total rate is exactly what `emap` carries: the composed event that replaced
1794 // the inflow stands for the original completion too.
1795 if (imm_result.eliminated) {
1796 const FluidLayout& LL = sys.layout;
1797 std::vector<std::size_t> cs(LL.nstates, 0), cc(LL.nstates, 0);
1798 for (std::size_t i = 0; i < M; ++i)
1799 for (std::size_t r = 0; r < K; ++r)
1800 for (std::size_t k = 0; k < LL.kic[i][r]; ++k) {
1801 cs[LL.qidx[i][r] + k] = i;
1802 cc[LL.qidx[i][r] + k] = r;
1803 }
1804 std::vector<bool> kept(LL.nstates, false);
1805 for (std::size_t a = 0; a < imm_result.state_map.size(); ++a)
1806 kept[imm_result.state_map[a]] = true;
1807 std::vector<double> rr(x.begin(), x.end());
1808 fluid_rates_closing(dsys, x.data(), rr);
1809 for (std::size_t o = 0; o < sys.n_departures && o < sys.events.size(); ++o) {
1810 const std::size_t c = sys.events[o].event_idx;
1811 if (c >= LL.nstates || kept[c]) continue;
1812 double extra = 0.0;
1813 for (std::size_t e = 0; e < rr.size() && e < imm_result.emap.rows(); ++e)
1814 extra += imm_result.emap(e, o) * rr[e];
1815 out.TN(cs[c], cc[c]) += extra;
1816 }
1817 for (std::size_t i = 0; i < M; ++i)
1818 for (std::size_t r = 0; r < K; ++r)
1819 if (out.TN(i, r) > lang::GlobalConstants::Zero)
1820 out.RN(i, r) = out.QN(i, r) / out.TN(i, r);
1821 }
1822 detail::fluid_snap_all(out.QN, out.UN, out.RN, out.TN);
1823
1824 // System throughput and response time, per chain reference station.
1825 out.XN.assign(K, 0.0);
1826 out.CN.assign(K, 0.0);
1827 for (std::size_t r = 0; r < K; ++r) {
1828 const std::size_t rs = sn.classes[r].refstat;
1829 if (rs >= 1 && rs <= M) out.XN[r] = out.TN(rs - 1, r);
1830 double q = 0.0;
1831 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, r);
1832 if (out.XN[r] > 0.0) out.CN[r] = q / out.XN[r];
1833 }
1834 return out;
1835}
1836
1837// ---------------------------------------------------------------------------
1838// The FCFS non-exponential refit loop of `solver_fluid_analyzer.m:100-197`.
1839// ---------------------------------------------------------------------------
1840/**
1841 * WHAT IT CORRECTS. The fluid drift of an FCFS station is the drift of a
1842 * PROCESSOR-SHARING station: a continuous mass has no queueing order to
1843 * respect, so every class in the buffer is served in proportion to its mass.
1844 * That is exact for exponential service, where the residual is memoryless and
1845 * the order does not matter, and wrong for anything else -- the whole point of
1846 * FCFS is that a long job blocks the ones behind it. The reference therefore
1847 * does not integrate an FCFS station at its declared service process. It runs
1848 * the mean-field solve, reads the resulting utilizations back into
1849 * `npfqn_nonexp_approx`, which rescales each FCFS station's service time by the
1850 * WSC 2020 diffusion interpolation, refits a COXIAN to that rescaled mean at the
1851 * station's ORIGINAL SCV, and integrates again. The loop closes on `eta`, the
1852 * M/G/1 decay rate the interpolation is built from.
1853 *
1854 * WHY THE FIT IS A COXIAN AND NOT A RATE CHANGE. `npfqn_nonexp_approx` returns a
1855 * scaled MEAN only. Writing that mean back as a one-phase exponential would
1856 * discard the SCV, which is the quantity that made the station non-product-form
1857 * in the first place; `Coxian.fitMeanAndSCV(1/rate, SCV)` keeps both moments and
1858 * changes the station's PHASE COUNT, which is why the layout, the initial
1859 * condition and the drift are all rebuilt inside the loop.
1860 *
1861 * THE SCV AND THE RATES ARE THE DECLARED ONES, EVERY SWEEP. `SCV = sn.scv` and
1862 * `rates0 = sn.rates` are read once, before the loop. Re-reading the SCV from
1863 * the refitted process would feed the fit its own output -- the Coxian written
1864 * in sweep n has, by construction, the SCV that was asked for -- so the sequence
1865 * would freeze at the first fit instead of converging to the interpolation's
1866 * fixed point. For the same reason the utilization fed back is `TN ./ rates0`
1867 * and not `TN ./ rates`, and `sn.rates` is never reassigned inside the loop.
1868 *
1869 * WHERE THE STATE HANDLING WENT. The reference re-encodes `sn.state` through
1870 * `State.toMarginal` / `State.fromMarginalAndStarted` whenever the phase count
1871 * changes, because `solver_fluid_initsol.m` DECODES that state to build the
1872 * initial condition. `fluid_default_initsol` is the closed form of that round
1873 * trip (see fluid_closing.h): it writes the `initDefault` placement into the
1874 * phase-one entries directly and never reads `sn.state`. The re-encoding is
1875 * therefore an identity here, and rebuilding the initial condition after a phase
1876 * change is the whole of its observable effect -- which is done.
1877 */
1878/**
1879 * The methods whose FCFS drift is the PS drift, and which the reference
1880 * therefore refits.
1881 *
1882 * The reference's second switch lists `matrix`, `closing`, `tbi`, `minnormal`,
1883 * `refined` and `dae`, and NOT `statedep`, `softmin`, `pnorm`, `diffusion`,
1884 * `mfq`, `rmf` or `kp`. The state-dependent family already carries a min()-based
1885 * capacity term, so its FCFS drift is not the PS one and refitting it would
1886 * correct a correction; `statedep` is commented in the reference as needing a
1887 * single iteration, and that comment is the contract. The rest are different
1888 * solvers, not different closures of the same drift.
1889 *
1890 * `dae` refits because it IS the min-normal closure -- same drift, same rate
1891 * factors, solved simultaneously instead of by substitution -- so the phase
1892 * count its answer depends on is refitted on the same schedule `minnormal` uses.
1893 */
1894inline bool fluid_method_refits_fcfs(const std::string& method) {
1895 std::string m = method;
1896 if (m.size() > 6 && m.compare(0, 6, "fluid.") == 0) m = m.substr(6);
1897 return m == "matrix" || m == "closing" || m == "tbi" || m == "minnormal" || m == "refined" ||
1898 m == "dae";
1899}
1900
1901/** `cellsum(sn.visits)` at station level; the reference's `V` argument. */
1902template <class T>
1903Matrix<T> fluid_station_visits(const qn::NetworkStruct<T>& sn) {
1904 const T zero = num_traits<T>::from_int(0);
1905 Matrix<T> V(sn.nstations, sn.nclasses, zero);
1906 for (std::size_t c = 0; c < sn.nchains; ++c)
1907 for (std::size_t i = 0; i < sn.nstations; ++i) {
1908 const std::size_t sf = sn.stateful_of_station(i + 1);
1909 for (std::size_t k = 0; k < sn.nclasses; ++k)
1910 V(i, k) = T(V(i, k) + sn.visits[c](sf - 1, k));
1911 }
1912 return V;
1913}
1914
1915/**
1916 * MATLAB's max(abs(1 - eta ./ eta_1)) over a vector that MAY contain NaN.
1917 *
1918 * `max` SKIPS NaN in MATLAB, and an all-NaN vector makes the comparison
1919 * `[] > tol` false, i.e. the loop stops. A 0/0 entry -- a station whose decay
1920 * rate was zero on both sweeps -- must therefore not read as "not converged",
1921 * which is what a straight `std::max` over NaN would produce.
1922 */
1923inline double fluid_eta_gap(const std::vector<double>& eta, const std::vector<double>& eta_1) {
1924 double best = -std::numeric_limits<double>::infinity();
1925 bool any = false;
1926 for (std::size_t i = 0; i < eta.size(); ++i) {
1927 const double g = std::fabs(1.0 - eta[i] / eta_1[i]);
1928 if (std::isnan(g)) continue;
1929 any = true;
1930 best = std::max(best, g);
1931 }
1932 return any ? best : 0.0;
1933}
1934
1935/** Elementwise reciprocal of A, with the reference's two sentinels for the
1936 * degenerate entries. */
1937template <class T>
1938Matrix<T> fluid_reciprocal_guarded(const Matrix<T>& A) {
1939 const T one = num_traits<T>::from_int(1);
1940 Matrix<T> B(A.rows(), A.cols(), num_traits<T>::from_int(0));
1941 for (std::size_t i = 0; i < A.rows(); ++i)
1942 for (std::size_t r = 0; r < A.cols(); ++r) {
1943 const double a = num_traits<T>::to_double(A(i, r));
1944 if (std::isnan(a))
1945 B(i, r) = num_traits<T>::from_double(lang::GlobalConstants::FineTol);
1946 else if (a == 0.0 || std::isinf(1.0 / a))
1947 // A zero rate gives Inf, which the reference replaces by
1948 // GlobalConstants.Immediate = 1e8 -- a very LARGE service time,
1949 // not a vanishing one. That reads backwards and is what
1950 // `solver_fluid_analyzer.m:107-109` does; the pairs it applies to
1951 // carry no utilization, so the interpolation then leaves them alone.
1952 B(i, r) = num_traits<T>::from_double(lang::GlobalConstants::Immediate);
1953 else
1954 B(i, r) = T(one / A(i, r));
1955 }
1956 return B;
1957}
1958
1959/**
1960 * Refit every FCFS station of `sn` to the rescaled service time at its declared
1961 * SCV, returning true when any station's PHASE COUNT changed.
1962 *
1963 * A phase change invalidates the fluid layout, so the caller must rebuild the
1964 * initial condition rather than carry the previous state vector across.
1965 */
1966template <class T>
1967bool fluid_refit_fcfs_stations(qn::NetworkStruct<T>& sn, const Matrix<T>& rates,
1968 const Matrix<T>& SCV,
1969 std::vector<std::vector<std::size_t>>& phases) {
1970 const T zero = num_traits<T>::from_int(0);
1971 const T one = num_traits<T>::from_int(1);
1972 const std::size_t M = sn.nstations, K = sn.nclasses;
1973 bool changed = false;
1974 for (std::size_t i = 0; i < M; ++i) {
1975 if (sn.stations[i].sched != lang::SchedStrategy::FCFS) continue;
1976 for (std::size_t r = 0; r < K; ++r) {
1977 if (!(rates(i, r) > zero) || !(SCV(i, r) > zero)) continue;
1978 const pfqn::MarieCoxFit<T> cx = pfqn::marie_cox_fit(T(one / rates(i, r)), SCV(i, r));
1979 // `refresh_rates` is deliberately NOT called: the reference never
1980 // assigns `sn.rates` inside the loop, and every consumer below the
1981 // switch (the correction, the next sweep's rho) reads the DECLARED
1982 // rate. Only the process representation the drift is built from moves.
1983 sn.service[i][r] = lang::Distrib<T>::coxian(cx.mu, cx.phi);
1984 if (cx.mu.size() != phases[i][r]) changed = true;
1985 phases[i][r] = cx.mu.size();
1986 }
1987 }
1988 return changed;
1989}
1990
1991/**
1992 * The loop. `seed` is the first integration, which the caller has already run at
1993 * the model's declared service processes, and `solve` re-integrates the refitted
1994 * struct by the SAME method -- UNCORRECTED, because the analyzer applies its
1995 * correction once, after the loop.
1996 *
1997 * Returns the uncorrected table of the final integration, and `iters` is that
1998 * integration's own pass count.
1999 *
2000 * `iters` IS NOT ACCUMULATED ACROSS THE SWEEPS, and that is the reference's
2001 * split rather than a simplification. `solver_fluid_analyzer.m` returns `iter`,
2002 * the number of REFIT sweeps, and keeps the summed integration count in
2003 * `outer_iters`, which it uses for runtime accounting only. This port's `iters`
2004 * has always meant "passes of the integration the reported table came from", so
2005 * summing the discarded sweeps into it would change what the field means for
2006 * every model, refitting or not. The sweep count is reported separately below.
2007 */
2008template <class T, class Solve>
2009FluidSolution fluid_fcfs_nonexp_refit(const qn::NetworkStruct<T>& sn0, const FluidOptions& opt,
2010 const FluidSolution& seed, Solve solve,
2011 qn::NetworkStruct<T>* sn_out = nullptr) {
2012 const std::size_t M = sn0.nstations, K = sn0.nclasses;
2013 // `result.solverSpecific.sn` of the reference: the struct the reported
2014 // solution was integrated on. It is the input one until the loop refits a
2015 // service process, and a caller that reads the state vector afterwards --
2016 // the passage time does -- needs the refitted one, whose phase counts the
2017 // vector is laid out by.
2018 if (sn_out) *sn_out = sn0;
2019 if (!fluid_method_refits_fcfs(opt.method)) return seed;
2020 bool any_fcfs = false;
2021 for (std::size_t i = 0; i < M; ++i)
2022 if (sn0.stations[i].sched == lang::SchedStrategy::FCFS) any_fcfs = true;
2023 if (!any_fcfs) return seed;
2024
2025 const T zero = num_traits<T>::from_int(0);
2026 const T one = num_traits<T>::from_int(1);
2027 const Matrix<T>& rates0 = sn0.rates;
2028 const Matrix<T>& SCV = sn0.scv;
2029 const Matrix<T> V = fluid_station_visits(sn0);
2030 const Matrix<T> ST0 = fluid_reciprocal_guarded(rates0);
2031
2032 std::vector<bool> isFCFS(M, false);
2033 std::vector<T> nservers(M, one), gamma(M, zero);
2034 for (std::size_t i = 0; i < M; ++i) {
2035 isFCFS[i] = sn0.stations[i].sched == lang::SchedStrategy::FCFS;
2036 nservers[i] = num_traits<T>::from_double(sn0.stations[i].nservers);
2037 }
2038
2039 qn::NetworkStruct<T> sn = sn0;
2040 std::vector<std::vector<std::size_t>> phases(M, std::vector<std::size_t>(K, 0));
2041 for (std::size_t i = 0; i < M; ++i)
2042 for (std::size_t r = 0; r < K; ++r) phases[i][r] = sn0.service[i][r].D0.rows();
2043
2044 FluidSolution cur = seed;
2045 std::vector<double> eta(M, std::numeric_limits<double>::infinity()), eta_1(M, 0.0);
2046 std::size_t iter = 0;
2047
2048 while (fluid_eta_gap(eta, eta_1) > lang::GlobalConstants::CoarseTol && iter <= opt.iter_max) {
2049 ++iter;
2050 eta_1 = eta;
2051
2052 Matrix<T> U(M, K, zero), TN(M, K, zero);
2053 for (std::size_t i = 0; i < M; ++i)
2054 for (std::size_t r = 0; r < K; ++r) {
2055 TN(i, r) = num_traits<T>::from_double(cur.TN(i, r));
2056 if (rates0(i, r) > zero) U(i, r) = T(TN(i, r) / rates0(i, r));
2057 }
2058
2059 const npfqn::NonexpApproxResult<T> na = npfqn::npfqn_nonexp_approx(
2060 opt.highvar, isFCFS, rates0, ST0, V, SCV, TN, U, gamma, nservers);
2061 gamma = na.gamma;
2062 for (std::size_t i = 0; i < M; ++i) eta[i] = num_traits<T>::to_double(na.eta[i]);
2063
2064 const Matrix<T> rates = fluid_reciprocal_guarded(na.ST);
2065 const bool phase_change = fluid_refit_fcfs_stations(sn, rates, SCV, phases);
2066
2067 FluidOptions o = opt;
2068 const std::vector<double> fresh = fluid_default_initsol(sn, fluid_layout(sn));
2069 o.init_sol = (!phase_change && cur.xvec.size() == fresh.size()) ? cur.xvec : fresh;
2070 cur = solve(sn, o);
2071 }
2072
2073 // The reference re-solves once more from the CLEAN initial condition, so the
2074 // reported table is the drift of the converged service processes started
2075 // where the model says the system starts, not where the last sweep left off.
2076 FluidOptions o = opt;
2077 o.init_sol = fluid_default_initsol(sn, fluid_layout(sn));
2078 FluidSolution out = solve(sn, o);
2079 out.refit_sweeps = iter;
2080 if (sn_out) *sn_out = sn;
2081 return out;
2082}
2083
2084} // namespace detail
2085
2086
2087/**
2088 * Port of `solver_fluid_analyzer.m`: dispatch on the method, refit the
2089 * non-exponential FCFS stations the reference refits, then apply the
2090 * utilization and response-time correction it applies to whatever the branch
2091 * returned.
2092 */
2093template <class T>
2095 qn::NetworkStruct<T>* sn_out = nullptr) {
2096 // `@@SolverFLD/runAnalyzer.m:25` converts the non-Markovian service laws
2097 // first, and FORCES phfit = 'ph': the ODEs read mu*phi as a flow between
2098 // phases, and a matrix exponential has no such flow -- its off-diagonal
2099 // entries are not rates. The default two-moment CME would give a better
2100 // moment match and a meaningless drift.
2101 qn::NetworkStruct<T> converted;
2102 const qn::NetworkStruct<T>* snp = &sn_in;
2103 if constexpr (num_traits<T>::has_transcendental) {
2104 if (api::sn_has_nonmarkov(sn_in, false)) {
2105 converted = sn_in;
2107 no.order = opt.nonmkv_order;
2108 no.phfit = api::PhFit::Ph;
2109 api::sn_nonmarkov_toph(converted, no);
2110 snp = &converted;
2111 }
2112 }
2113 const qn::NetworkStruct<T>& sn = *snp;
2114
2115 FluidSolution out = detail::fluid_dispatch(sn, opt);
2116 out = detail::fluid_fcfs_nonexp_refit(
2117 sn, opt, out,
2118 [](const qn::NetworkStruct<T>& s, const FluidOptions& o) { return detail::fluid_dispatch(s, o); },
2119 sn_out);
2120 detail::fluid_analyzer_correct(sn, out.QN, out.UN, out.RN, out.TN);
2121 detail::fluid_snap_all(out.QN, out.UN, out.RN, out.TN);
2122 return out;
2123}
2124
2125
2126/**
2127 * Port of `local_detect_nhpp` in `@@SolverFLD/getTranAvg.m`: the (station, class)
2128 * pairs whose SOURCE carries a rate schedule, 1-based.
2129 *
2130 * A CALLER OPTS IN, and that is the reference's split rather than a convenience.
2131 * `getTranAvg` calls this and puts the result on the options; a steady-state
2132 * request does not, and is answered at the time-averaged nominal. Both are
2133 * legitimate readings of the same model, so the decision belongs to the entry
2134 * point and not to the drift builder.
2135 *
2136 * The reference tests `ismethod(proc,'getRateSchedule')`, which NHPP, MAPt and
2137 * PHt all answer; here that is `Distrib::has_schedule()`, the same three.
2138 */
2139template <class T>
2140std::vector<std::pair<std::size_t, std::size_t> > fluid_detect_nhpp(
2141 const qn::NetworkStruct<T>& sn) {
2142 std::vector<std::pair<std::size_t, std::size_t> > out;
2143 for (std::size_t i = 0; i < sn.nstations; ++i) {
2144 if (sn.stations[i].sched != lang::SchedStrategy::EXT) continue;
2145 for (std::size_t r = 0; r < sn.nclasses; ++r)
2146 if (!sn.disabled[i][r] && sn.service[i][r].has_schedule())
2147 out.push_back(std::make_pair(i + 1, r + 1));
2148 }
2149 return out;
2150}
2151
2152/**
2153 * Port of `@@SolverFLD/getTranAvg`: the metrics along the trajectory, not just
2154 * at the fixed point.
2155 *
2156 * The reference forces the method to `closing` for a transient (matrix and the
2157 * smoothed variants are steady-state devices), and so does this. The drift is
2158 * integrated once over [0, t_end] with the output grid handed to LSODA, and
2159 * every point is passed through the SAME extraction the steady state uses, so
2160 * the last point of a long enough run reproduces `solver_fluid` exactly.
2161 *
2162 * A transient is only meaningful from a KNOWN starting state, so the default
2163 * initial condition is used unless the caller supplies `init_sol`.
2164 *
2165 * THE RATE SCHEDULE IS DETECTED HERE, as `getTranAvg.m:76` detects it: a
2166 * transient of a model with a non-homogeneous source follows the intensity
2167 * exactly rather than its time average. A caller that has already filled
2168 * `opt.nhpp_sched` keeps its own list, so the nominal can still be asked for.
2169 *
2170 * `out_grid` REPLACES the uniform grid when a caller needs the trajectory at
2171 * points of its own choosing. It exists because interpolating a trajectory
2172 * cannot recover resolution it never had: SolverENV sums an exit average
2173 * against a sojourn CDF, and over a horizon of 1e3 read through an Exp(1) clock
2174 * a uniform 1001-point grid carries six samples where the whole weight lives.
2175 * LSODA takes an arbitrary increasing output vector, so asking for the points
2176 * that matter costs nothing and removes the interpolation entirely.
2177 */
2178template <class T>
2179std::vector<FluidTranPoint> solver_fluid_transient(const qn::NetworkStruct<T>& sn,
2180 const FluidOptions& opt, double t_end,
2181 std::size_t points = 101,
2182 const std::vector<double>& out_grid =
2183 std::vector<double>()) {
2184 if (!std::is_same<T, double>::value)
2185 throw UnsupportedError(
2186 "solver_fluid_transient: the fluid drift is integrated by LSODA, which is double "
2187 "precision by construction; rerun with --arith double");
2188 if (!(t_end > 0.0)) throw InputError("solver_fluid_transient: t_end must be positive");
2189 if (points < 2) throw InputError("solver_fluid_transient: need at least two output points");
2190
2191 FluidOptions o = opt;
2192 if (o.nhpp_sched.empty()) o.nhpp_sched = fluid_detect_nhpp(sn);
2194 sys.ratemult = detail::fluid_ratemult(sn, sys, o);
2195 const FluidLayout& L = sys.layout;
2196 if (L.nstates == 0)
2197 throw InputError("solver_fluid_transient: no station serves any class");
2198
2199 std::vector<double> y0 =
2200 o.init_sol.empty() ? detail::fluid_default_initsol(sn, L) : o.init_sol;
2201 if (y0.size() != L.nstates)
2202 throw InputError("solver_fluid_transient: init_sol has the wrong length");
2203
2204 std::vector<double> grid = out_grid;
2205 if (grid.empty()) {
2206 grid.resize(points);
2207 for (std::size_t j = 0; j < points; ++j)
2208 grid[j] = t_end * static_cast<double>(j) / static_cast<double>(points - 1);
2209 }
2210
2211 LsodaOptions lopt;
2212 lopt.rtol = o.tol;
2213 lopt.atol = o.tol;
2214 const std::function<void(double, const double*, double*)> tdrift = fluid_drift(sys);
2215 const LsodaSolution sol = fluid_integrate_grid(tdrift, y0, grid, lopt);
2216
2217 std::vector<FluidTranPoint> out;
2218 out.reserve(sol.y.size());
2219 for (std::size_t j = 0; j < sol.y.size(); ++j) {
2220 std::vector<double> xs = sol.y[j];
2221 for (double& v : xs)
2222 if (v < 0.0) v = 0.0;
2223 FluidTranPoint pt;
2224 pt.t = sol.t[j];
2226 fluid_closing_metrics(sn, sys, std::string("closing"), xs, pt.QN, pt.UN, R, pt.TN);
2227 detail::fluid_snap_all(pt.QN, pt.UN, R, pt.TN);
2228 out.push_back(pt);
2229 }
2230 return out;
2231}
2232
2233/**
2234 * The horizon a transient runs to when the caller gives none.
2235 *
2236 * FACTORED OUT OF `solver_fluid_tran_avg` because `dae` has a transient of its
2237 * own (fluid_dae.h) and must reach it by the SAME rule: a horizon rule that two
2238 * methods each computed for themselves is a horizon rule that can differ
2239 * between them, and then two trajectories of one model are read at different
2240 * times for no stated reason.
2241 *
2242 * `options.timespan` defaults to [0, Inf] and the reference does NOT integrate
2243 * to infinity for it. The rule lives in `@NetworkSolver/getTranAvg.m`, not in
2244 * the fluid analyzer: an unspecified end time becomes `30/minrate` with
2245 * `minrate = min(sn.rates(isfinite(sn.rates)))`, i.e. thirty mean events of the
2246 * SLOWEST RATE IN sn.rates -- arrival rates included, since the source's rate
2247 * sits in that same table. That is not the analyzer's own horizon-extension
2248 * rule (ten mean events of the slowest transition per pass), which governs how
2249 * far `solver_fluid` integrates while hunting the fixed point and is invisible
2250 * to the getter. A caller that sets `timespan_end` is integrated over exactly
2251 * that instead.
2252 *
2253 * MATLAB drops NaN (its disabled marker) through `isfinite`; the port carries a
2254 * separate `disabled` flag and stores zero, so the zero is skipped by the flag.
2255 */
2256template <class T>
2258 if (std::isfinite(opt.timespan_end) && opt.timespan_end > 0.0) return opt.timespan_end;
2259 double min_rate = std::numeric_limits<double>::infinity();
2260 for (std::size_t i = 0; i < sn.nstations; ++i)
2261 for (std::size_t r = 0; r < sn.nclasses; ++r) {
2262 if (sn.disabled[i][r]) continue;
2263 const double rate = num_traits<T>::to_double(sn.rates(i, r));
2264 if (std::isfinite(rate) && rate > opt.tol) min_rate = std::min(min_rate, rate);
2265 }
2266 if (!std::isfinite(min_rate)) min_rate = 1.0;
2267 return 30.0 / min_rate;
2268}
2269
2270/**
2271 * `getTranAvg` on the first-order closing drift, over that horizon.
2272 *
2273 * THE METHOD IS NOT CONSULTED, as it is not in the reference: matrix, pnorm and
2274 * the smoothed variants are steady-state devices with no trajectory of their
2275 * own, so `getTranAvg.m` substitutes `closing` for them and warns. `dae` is the
2276 * exception the reference itself makes, and `solver_fluid_run_transient`
2277 * (fluid_runner.h) is where that routing lives -- it cannot live here, because
2278 * this header is below fluid_dae.h in the include order.
2279 */
2280template <class T>
2281std::vector<FluidTranPoint> solver_fluid_tran_avg(const qn::NetworkStruct<T>& sn,
2282 const FluidOptions& opt,
2283 std::size_t points = 101) {
2285}
2286
2287/**
2288 * The Jacobian of the fluid drift at a state, by central differences.
2289 *
2290 * The reference's `getJacobian` builds this SYMBOLICALLY and hands the
2291 * expression to a SAGE backend over HTTP; there is no symbolic engine here, so
2292 * this is the numerical counterpart. It is what the symbolic form is used for
2293 * in practice -- local stability of the fixed point, through the eigenvalues of
2294 * J -- and it needs no external service.
2295 */
2296template <class T>
2297Matrix<double> fluid_jacobian(const qn::NetworkStruct<T>& sn, const std::vector<double>& x) {
2298 const FluidOdeSystem sys = fluid_ode_system(sn);
2299 const std::size_t n = sys.layout.nstates;
2300 if (x.size() != n) throw InputError("fluid_jacobian: the state has the wrong length");
2301 const std::function<void(double, const double*, double*)> f = fluid_drift(sys);
2302 Matrix<double> J(n, n, 0.0);
2303 std::vector<double> xp(x), xm(x), fp(n, 0.0), fm(n, 0.0);
2304 for (std::size_t j = 0; j < n; ++j) {
2305 // A step scaled to the component, floored so a zero entry still moves.
2306 const double h = 1e-6 * std::max(1.0, std::fabs(x[j]));
2307 xp = x;
2308 xm = x;
2309 xp[j] += h;
2310 xm[j] -= h;
2311 f(0.0, xp.data(), fp.data());
2312 f(0.0, xm.data(), fm.data());
2313 for (std::size_t i = 0; i < n; ++i) J(i, j) = (fp[i] - fm[i]) / (2.0 * h);
2314 }
2315 return J;
2316}
2317
2318
2319/**
2320 * The joint probability of the per-class populations at station `i` (0-based)
2321 * under the linear noise approximation solved by the moment closure, the
2322 * `local_gaussian_cell` of the reference `@@SolverFLD/getProbAggr`.
2323 *
2324 * The state coordinates of class r at the station are `moments.class_block[i][r]`
2325 * (one per service phase), so the class population is their sum: its mean is the
2326 * reported `QN(i,r)` and the class-to-class covariance is the sum of the
2327 * corresponding block of `moments.Sigma`. The integer count n is then read off
2328 * the continuous law as the unit cell [n-1/2, n+1/2], with the two ends extended
2329 * to infinity at the boundaries of the state space, so that the mass the normal
2330 * puts on negative populations lands on the empty station and the mass above a
2331 * closed population lands on the full one. Those cells tile the state space, so
2332 * the probabilities sum to one over the reachable states.
2333 */
2334template <class T>
2336 std::size_t i, const std::vector<double>& nir,
2337 double* logp_out = nullptr) {
2338 const std::size_t K = sn.nclasses;
2339 const std::vector<std::vector<std::size_t>>& cb = sol.moments.class_block[i];
2340
2341 std::vector<std::size_t> idx;
2342 std::vector<double> m, a, b;
2343 for (std::size_t r = 0; r < K; ++r) {
2344 if (cb[r].empty()) {
2345 // the class has no service process here, so it has no coordinate:
2346 // any positive count is impossible rather than improbable
2347 if (nir[r] > 0.0) {
2348 if (logp_out) *logp_out = -std::numeric_limits<double>::infinity();
2349 return 0.0;
2350 }
2351 continue;
2352 }
2353 idx.push_back(r);
2354 m.push_back(sol.QN(i, r));
2355 a.push_back(nir[r] <= 0.0 ? -std::numeric_limits<double>::infinity() : nir[r] - 0.5);
2356 const double pop = sn.classes[r].population;
2357 b.push_back((std::isfinite(pop) && nir[r] >= pop) ? std::numeric_limits<double>::infinity()
2358 : nir[r] + 0.5);
2359 }
2360
2361 if (idx.empty()) {
2362 if (logp_out) *logp_out = 0.0;
2363 return 1.0;
2364 }
2365
2366 const std::size_t nr = idx.size();
2367 Matrix<double> C(nr, nr, 0.0);
2368 for (std::size_t u = 0; u < nr; ++u)
2369 for (std::size_t v = u; v < nr; ++v) {
2370 double acc = 0.0;
2371 for (std::size_t p = 0; p < cb[idx[u]].size(); ++p)
2372 for (std::size_t q = 0; q < cb[idx[v]].size(); ++q)
2373 acc += sol.moments.Sigma(cb[idx[u]][p], cb[idx[v]][q]);
2374 C(u, v) = acc;
2375 C(v, u) = acc;
2376 }
2377
2378 const double p = fluid_mvn_rectangle(m, C, a, b);
2379 if (logp_out) *logp_out = p > 0.0 ? std::log(p) : -std::numeric_limits<double>::infinity();
2380 return p;
2381}
2382
2383/**
2384 * Port of `@@SolverFLD/getProbAggr`: the probability that station `ist` holds
2385 * the marginal population of the model's default state.
2386 *
2387 * The fluid solver has no state space, so the probability is FITTED to the
2388 * mean queue lengths it does produce: a binomial for each closed class
2389 * (Schmidt) and, for the open ones, the BCMP marginal -- Poisson at an
2390 * infinite server, multinomial-geometric at a queue. The two contributions are
2391 * ADDED in log space, which is what lets a mixed model be evaluated at all;
2392 * the MVA port's version picks one branch because its callers are never mixed.
2393 *
2394 * `ist` is 1-based, as in the reference.
2395 */
2396template <class T>
2397double fluid_prob_aggr(const qn::NetworkStruct<T>& sn, const FluidSolution& sol, std::size_t ist,
2398 double* logp_out = nullptr) {
2399 const std::size_t M = sn.nstations, K = sn.nclasses;
2400 if (ist == 0 || ist > M)
2401 throw InputError("fluid_prob_aggr: station number exceeds the number of stations");
2402 const std::size_t i = ist - 1;
2403
2404 // The marginal of the DEFAULT state: a closed class sits at its reference
2405 // station, an open one holds nothing. This is what `State.toMarginal`
2406 // returns for the state the model starts in.
2407 std::vector<double> nir(K, 0.0);
2408 for (std::size_t r = 0; r < K; ++r) {
2409 const double pop = sn.classes[r].population;
2410 if (std::isfinite(pop) && sn.classes[r].refstat == ist) nir[r] = pop;
2411 }
2412
2413 double logp = 0.0;
2414 bool minus_inf = false;
2415 const lang::SchedStrategy sc = sn.stations[i].sched;
2416
2417 // The moment closure supplies the JOINT law of the per-class populations, so
2418 // the answer is the probability its multivariate normal assigns to the unit
2419 // cell around the state, correlation between the classes included. Three
2420 // exclusions, each structural rather than defensive:
2421 // - a Source coordinate is a normalisation constant, not a population, and
2422 // `fluid_moment_terms` projects it out of the covariance;
2423 // - an OPEN class already has an EXACT first-order answer here (the BCMP
2424 // marginal below), and a normal approximation of it would only lose: on
2425 // M/M/1 at rho = 0.5 the cell returns 0.391 for the empty queue against
2426 // an exact 0.500;
2427 // - without moments there is no second moment anywhere in FLD.
2428 if (sol.has_moments && !sol.moments.class_block.empty() && sc != lang::SchedStrategy::EXT) {
2429 bool open_here = false;
2430 for (std::size_t r = 0; r < K; ++r)
2431 if (!sol.moments.class_block[i][r].empty() && !std::isfinite(sn.classes[r].population))
2432 open_here = true;
2433 if (!open_here)
2434 return fluid_prob_aggr_gaussian(sn, sol, i, nir, logp_out);
2435 }
2436
2437 // ---- open classes ------------------------------------------------------
2438 bool any_open = false;
2439 for (std::size_t r = 0; r < K; ++r)
2440 if (!std::isfinite(sn.classes[r].population)) any_open = true;
2441 if (any_open && sc == lang::SchedStrategy::INF) {
2442 for (std::size_t r = 0; r < K; ++r) {
2443 if (std::isfinite(sn.classes[r].population)) continue;
2444 const double q = sol.QN(i, r);
2445 if (q > 0.0)
2446 logp += nir[r] * std::log(q) - q - std::lgamma(nir[r] + 1.0);
2447 else if (nir[r] > 0.0)
2448 minus_inf = true;
2449 }
2450 } else if (any_open && sc != lang::SchedStrategy::EXT) {
2451 double rho_total = 0.0, n_total = 0.0;
2452 for (std::size_t r = 0; r < K; ++r) {
2453 if (std::isfinite(sn.classes[r].population)) continue;
2454 rho_total += sol.UN(i, r);
2455 n_total += nir[r];
2456 }
2457 if (rho_total < 1.0) {
2458 logp += std::log(1.0 - rho_total) + std::lgamma(n_total + 1.0);
2459 for (std::size_t r = 0; r < K; ++r) {
2460 if (std::isfinite(sn.classes[r].population) || !(nir[r] > 0.0)) continue;
2461 const double rho_r = sol.UN(i, r);
2462 if (rho_r > 0.0)
2463 logp += nir[r] * std::log(rho_r) - std::lgamma(nir[r] + 1.0);
2464 else
2465 minus_inf = true;
2466 }
2467 } else {
2468 minus_inf = true; // a saturated station has no stationary marginal
2469 }
2470 }
2471
2472 // ---- closed classes: the Schmidt binomial ------------------------------
2473 for (std::size_t r = 0; r < K; ++r) {
2474 const double N = sn.classes[r].population;
2475 if (!std::isfinite(N)) continue;
2476 const double q = sol.QN(i, r);
2477 const double p = (N > 0.0) ? q / N : 0.0;
2478 // nchoosekln(N, nir)
2479 logp += std::lgamma(N + 1.0) - std::lgamma(nir[r] + 1.0) - std::lgamma(N - nir[r] + 1.0);
2480 if (p > 0.0) {
2481 logp += nir[r] * std::log(p);
2482 } else if (nir[r] > 0.0) {
2483 minus_inf = true;
2484 }
2485 if (p < 1.0) {
2486 logp += (N - nir[r]) * std::log(1.0 - p);
2487 } else if (N - nir[r] > 0.0) {
2488 minus_inf = true;
2489 }
2490 }
2491
2492 if (minus_inf) {
2493 if (logp_out) *logp_out = -std::numeric_limits<double>::infinity();
2494 return 0.0;
2495 }
2496 if (logp_out) *logp_out = logp;
2497 return std::exp(logp);
2498}
2499
2500} // namespace fluid
2501} // namespace line
2502
2503#endif // LINE_SOLVERS_FLUID_SOLVER_FLUID_H
2504
InputError(const std::string &what)
Definition error.h:39
UnsupportedError(const std::string &what)
Definition error.h:51
FluidNonHyperbolicError(const std::string &what)
A network plus its refreshed NetworkStruct.
std::size_t nvars_of(std::size_t ind) const
Total local-variable width of node ind (1-based).
std::vector< JobClass > classes
std::size_t phasessz_of(std::size_t ist, std::size_t r) const
sn.phasessz(i,r) = max(sn.phases(i,r),1): THE WIDTH of class r's phase block in a state row,...
The exception types the port throws.
Age of Information by Markovian fluid queues: a port of solver_mfq_aoi.m (identical to solver_fluid_a...
Detects a moment-closure trajectory that has left the model.
The diffusion method: a port of solver_fluid_diffusion.m.
The matrix fluid method: a port of solver_fluid_matrix.m, the formulation of Ruuskanen,...
The mfq method: a port of solver_mfq.m and the single-queue gate fluid_is_single_queue....
The priority branch of the mfq method: a port of solver_mfq_prio.m.
Port of fluid_mvn_rectangle.m: the rectangle probability P(a <= Y <= b) for Y ~ Normal(m,...
The one exception the fluid fallback ladder catches.
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
The state-dependent fluid drifts: ports of ode_statedep.m, ode_softmin.m and ode_pnorm....
Port of ode_eliminate_immediate.m, eliminate_immediate_matrix.m and ode_solve_stiff....
The tbi method: a port of solver_fluid_tbi_iteration.m and tbi_partition.m.
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
bool sn_has_nonmarkov(const qn::NetworkStruct< T > &sn, bool preserve_det=false)
Whether any law in the struct would be replaced, so a caller can skip copying the struct when there i...
@ Ph
Bernstein density fit: a genuine phase-type, shape-carrying.
void sn_nonmarkov_toph(qn::NetworkStruct< T > &sn, const NonmarkovOptions &opts=NonmarkovOptions())
Replace every non-Markovian service and firing law by a Markovian surrogate.
double fluid_default_horizon(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
The horizon a transient runs to when the caller gives none.
std::vector< std::pair< std::size_t, std::size_t > > fluid_detect_nhpp(const qn::NetworkStruct< T > &sn)
Port of local_detect_nhpp in @@SolverFLD/getTranAvg.m: the (station, class) pairs whose SOURCE carrie...
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
constexpr double kFluidConservationTol
Relative population drift that counts as having left the model.
std::vector< double > fluid_integrate_leg(const std::function< void(double, const double *, double *)> &f, double t0, double t1, const std::vector< double > &y0, const LsodaOptions &lopt)
One integration leg, with the reference's retry on a failed solve.
bool fluid_matrix_degenerate(const FluidMatrixSystem &s, const std::function< void(double, const double *, double *)> &drift, const std::vector< double > &x, std::size_t K)
Is the returned point one of a CONTINUUM of fixed points?
FluidMatrixSystem fluid_matrix_system(const qn::NetworkStruct< T > &sn, const std::vector< double > &init_sol, double pstar)
Assemble the matrix-form drift of sn.
void fluid_closing_metrics(const qn::NetworkStruct< T > &sn, const FluidOdeSystem &sys, const std::string &m, const std::vector< double > &xs, Matrix< double > &Q, Matrix< double > &U, Matrix< double > &R, Matrix< double > &T_)
Read Q/U/R/T off ONE fluid state, for the closing family.
ClosureValue fluid_capacity_closure(double n, double c, double s2, const std::vector< double > &lldrow, bool is_inf)
Port of fluid_capacity_closure.m: E[psi(X)] and its derivative, where psi(n) = min(n,...
double fluid_prob_aggr(const qn::NetworkStruct< T > &sn, const FluidSolution &sol, std::size_t ist, double *logp_out=nullptr)
Port of @@SolverFLD/getProbAggr: the probability that station ist holds the marginal population of th...
AoiTopology aoi_is_aoi(const qn::NetworkStruct< T > &sn)
Port of aoi_is_aoi.m.
Definition fluid_aoi.h:102
StateDepKind
Which smoothing the drift applies at a saturated station.
double fluid_mvn_rectangle(const std::vector< double > &m, const Matrix< double > &C, const std::vector< double > &a, const std::vector< double > &b, std::size_t npoints=FLUID_MVN_POINTS)
P(a <= Y <= b) for Y ~ Normal(m, C).
DiffusionResult fluid_diffusion(const qn::NetworkStruct< T > &sn, const DiffusionOptions &opt)
Run the diffusion approximation of sn.
std::function< void(double, const double *, double *)> fluid_drift_statedep(const FluidStateDepSystem &s)
The drift dx/dt for the state-dependent family.
std::vector< FluidTranPoint > solver_fluid_transient(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, double t_end, std::size_t points=101, const std::vector< double > &out_grid=std::vector< double >())
Port of @@SolverFLD/getTranAvg: the metrics along the trajectory, not just at the fixed point.
FluidStateDepSystem fluid_statedep_system(const qn::NetworkStruct< T > &sn, StateDepKind kind, double alpha=20.0, double pstar=20.0)
Assemble what the state-dependent drifts need from sn.
FluidSolution solver_fluid(const qn::NetworkStruct< T > &sn_in, const FluidOptions &opt, qn::NetworkStruct< T > *sn_out=nullptr)
Port of solver_fluid_analyzer.m: dispatch on the method, refit the non-exponential FCFS stations the ...
MfqTopology mfq_is_single_queue(const qn::NetworkStruct< T > &sn)
Port of fluid_is_single_queue.m: the model must be one open class flowing Source -> Queue -> Sink and...
Definition fluid_mfq.h:67
std::vector< FluidTranPoint > solver_fluid_tran_avg(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, std::size_t points=101)
getTranAvg on the first-order closing drift, over that horizon.
LsodaSolution fluid_integrate_grid(const std::function< void(double, const double *, double *)> &f, const std::vector< double > &y0, const std::vector< double > &grid, const LsodaOptions &lopt)
The same retry over a whole output grid, for the callers that ask LSODA for a trajectory rather than ...
FluidJacobian fluid_jacobian(const FluidSymSystem &sys, const FluidSymbolicOptions &opt=FluidSymbolicOptions())
Jacobian, drift and equilibria of the mean-field vector field.
int fluid_conservation_violation(const qn::NetworkStruct< T > &sn, const FluidLayout &L, const std::vector< double > &x, double tol=kFluidConservationTol)
The closed chain whose conserved population has drifted past tol, or -1.
MfqResult fluid_mfq(const qn::NetworkStruct< T > &sn, const MfqTopology &top, double tol)
Solve the single fluid queue of sn.
Definition fluid_mfq.h:144
double fluid_prob_aggr_gaussian(const qn::NetworkStruct< T > &sn, const FluidSolution &sol, std::size_t i, const std::vector< double > &nir, double *logp_out=nullptr)
The joint probability of the per-class populations at station i (0-based) under the linear noise appr...
FluidOdeSystem fluid_ode_system(const qn::NetworkStruct< T > &sn)
Build the drift of sn: the port of ode_jumps_new and ode_rate_base fused into one pass.
Definition fluid_odes.h:322
std::function< void(double, const double *, double *)> fluid_drift(const FluidOdeSystem &sys)
The drift dx/dt, ready to hand to the integrator.
Definition fluid_odes.h:657
std::vector< std::vector< std::size_t > > tbi_partition(const qn::NetworkStruct< T > &sn, std::size_t cellsize=5)
Port of tbi_partition.m: stations grouped by routing coupling.
Definition fluid_tbi.h:80
std::vector< double > tbi_advance(const FluidOdeSystem &sys, const std::vector< std::vector< std::size_t > > &cells, const std::vector< double > &y0, double t0, double t1, const TbiOptions &topt, const LsodaOptions &lopt)
Advance the state over [t0, t1] by time-based iteration.
Definition fluid_tbi.h:166
void fluid_interpcols(const std::vector< double > &tg, const Matrix< double > &B, double tt, std::vector< double > &out)
Port of fluid_interpcols.m: clamped piecewise-linear interpolation of the columns of B at a scalar ti...
Definition fluid_odes.h:171
void fluid_rates_closing(const FluidOdeSystem &sys, const double *x, std::vector< double > &g)
The reference's ode_rates_closing name, kept for the first-order callers.
Definition fluid_odes.h:646
std::function< void(double, const double *, double *)> fluid_matrix_drift(const FluidMatrixSystem &s)
The drift dx/dt = W' theta(x) + A_lambda.
MfqPrioResult fluid_mfq_prio(const qn::NetworkStruct< T > &sn, const MfqTopology &top, double tol)
Solve the single priority fluid queue of sn.
FluidImmediateResult fluid_eliminate_immediate(const FluidOdeSystem &sys, double imm_tol=fluid_immediate_transition_tol())
bool fluid_hide_immediate(const qn::NetworkStruct< T > &sn, const Opt &opt)
Stochastic complementation of the INSTANTANEOUS coordinates of a fluid drift, the twin of ode_elimina...
OdeSolution< double > fluid_ode_solve_stiff(const std::function< void(double, const double *, double *)> &f, double t0, double t1, const std::vector< double > &y0, const FluidStiffOptions &opt=FluidStiffOptions())
Port of ode_solve_stiff.m.
FluidAoiResult fluid_aoi(const qn::NetworkStruct< T > &sn, const AoiTopology &top, double preempt_override)
Port of solver_mfq_aoi.m.
Definition fluid_aoi.h:699
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
T map_mean(const Map< T > &m)
Mean inter-arrival time, 1/lambda.
Definition map_moment.h:101
T map_lambda(const Map< T > &m)
Stationary arrival rate, lambda = pi D1 e.
Definition map_moment.h:79
NonexpApproxResult< T > npfqn_nonexp_approx(const std::string &method, const std::vector< bool > &isFCFS, const Matrix< T > &rates, const Matrix< T > &ST, const Matrix< T > &V, const Matrix< T > &SCV, const Matrix< T > &Tput, const Matrix< T > &U, const std::vector< T > &gamma, const std::vector< T > &nservers)
Handler for non-exponential service and arrival processes in AMVA and NC.
MarieCoxFit< T > marie_cox_fit(const T &mean, const T &scv)
Closed-form Coxian fit of a mean and an SCV (matlab/src/lang/processes/Coxian.m, fitMeanAndSCV),...
Definition pfqn_marie.h:120
Marginal< T > to_marginal(const NetworkStruct< T > &sn, std::size_t ist, const std::vector< T > &state_i, const std::vector< std::size_t > &phasesz, const std::vector< std::size_t > &phaseshift, std::size_t nvar=0)
Port of State.toMarginal for a STATION, one state row at a time.
Definition state.h:130
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
A queueing network and its refreshed NetworkStruct.
Handler for non-exponential service and arrival processes in AMVA and NC.
Marie's iterative aggregation-decomposition for closed networks with FCFS general (Coxian) service.
Replace every non-Markovian service and firing law by a Markovian surrogate.
Port of the MATLAB +State package: the encoding that turns a station's state row into marginal job co...
Integration controls.
Definition lsoda.h:64
double atol
absolute tolerance, applied to every component
Definition lsoda.h:66
double rtol
relative tolerance, applied to every component
Definition lsoda.h:65
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
std::vector< std::vector< double > > y
y[i] is the state at t[i]
Definition lsoda.h:128
std::vector< double > t
output times, t[0] = t_eval[0]
Definition lsoda.h:127
options.config.nonmkv and friends.
std::size_t order
nonmkvorder, the phase budget
PhFit phfit
which surrogate family
Both age laws of one system, with the policy parameter that produced them.
Definition fluid_aoi.h:263
The second moment the drift closes its non-linear terms with, i.e.
Definition fluid_odes.h:123
Where each (station, class) block sits in the state vector.
Definition fluid_odes.h:86
std::vector< std::vector< std::size_t > > qidx
0-based first index of (i,r)
Definition fluid_odes.h:88
std::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::vector< std::vector< bool > > enabled
whether (i,r) is served at all
Definition fluid_odes.h:90
std::vector< std::vector< std::size_t > > kic
phases held by (i,r); 0 when disabled
Definition fluid_odes.h:89
The second-order results of the moment-closure methods, i.e.
std::vector< std::vector< std::vector< std::size_t > > > class_block
state coordinates of each (station,class): Sigma is indexed by SERVICE PHASE, so reading a per-class ...
Matrix< double > Sigma
state-level covariance, on range(D)
Matrix< double > QStd
per station and class queue-length variance
std::vector< double > refinement
the 1/N correction, refined only
std::vector< double > sigma2
per-station population variance
FluidRateMult ratemult
solver_fluid_ratemult's multiplier; empty is the autonomous drift.
Definition fluid_odes.h:220
std::vector< std::vector< double > > lld
sn.lldscaling(i,:) per station, EMPTY when the station has none or when every entry is one – the refe...
Definition fluid_odes.h:216
options.config.rate_sched: explicit per-(station, class) rate trajectories, the third source solver_f...
double nominal
The nominal baked into rate_base; <= 0 selects Mu{i}{c}(1).
Controls, defaulting to SolverOptions('Fluid') in the reference.
FluidClosure closure
options.config.moment_sigma2 and options.config.moment_cov: the second moment the drift's non-linear ...
std::vector< std::pair< std::size_t, std::size_t > > nhpp_sched
options.config.nhpp_sched: the (station, class) pairs whose SOURCE carries a non-homogeneous intensit...
std::vector< double > init_sol
initial state; empty selects the default below
double softmin_alpha
sharpness of the 'softmin' smoothing
std::string fork_join
options.config.fork_join: which fork-join arm the fixed point takes, 'default'/'mmt'/'fjt' or 'ht'.
std::vector< RateSched > rate_sched
std::string highvar
options.config.highvar: which non-exponential FCFS correction the analyzer's outer refit loop applies...
std::size_t nonmkv_order
options.config.nonmkvorder: the phase budget sn_nonmarkov_toph spends on a non-Markovian service law.
std::size_t dae_maxstate
options.config.dae_maxstate and options.config.dae_maxcov: the DAE route's own two refusal thresholds...
std::vector< double > kp_init_sol
options.config.kp_init_sol: the kp method's initial state, in the KO-PENDER layout – one offset count...
double pstar
exponent of the 'pnorm' smoothing
FluidRateMult rate_traj
options.config.rate_traj = {tgrid, Mmat}: a caller-supplied per-EVENT multiplier, which is what the c...
unsigned long seed
'diffusion' RNG seed
bool earlystop
options.config.fluid_earlystop: stop on the geometric tail of the window iteration
double iter_tol
>0 stops early when the moved-mass ratio falls below it; 0 runs to iter_max, as the reference does
double timestep
'diffusion' Euler-Maruyama step
double tol
absolute and relative tolerance handed to the integrator
bool hide_immediate
options.config.hide_immediate: fold the Immediate-rate transitions into the timed ones by stochastic ...
double aoi_preemption
options.config.aoi_preemption: the preemption (bufferless) or replacement (single buffer) probability...
std::size_t iter_max
cap on outer integrations
bool pstar_set
Opt in to the p-norm under matrix/default too, which is what options.config.pstar does in MATLAB,...
bool stiff
options.stiff: integrate the closing family with the explicit stiff arm of fluid_stiff....
Matrix< double > init_cov
options.config.init_cov: the kp method's initial covariance Sigma(0), dim-by-dim in the same layout a...
std::size_t moment_maxstate
options.config.moment_maxstate: the largest phase-resolved state the moment-closure methods will buil...
The assembled drift: the layout, the events, and the per-station schedule.
Definition fluid_odes.h:156
What the analyzer returns, in the same shape as the MVA solver's result.
bool has_moments
result.solverSpecific.moments: set only by minnormal and refined.
std::vector< double > XN
bool has_aoi
result.solverSpecific.aoiResults: set only by the AoI branch of mfq, where the age laws,...
FluidMomentReport moments
std::vector< double > xvec
the converged fluid state
std::vector< double > CN
std::size_t refit_sweeps
iter of solver_fluid_analyzer.m: the FCFS non-exponential refit sweeps.
One point of a transient trajectory: the metrics at time t.
Matrix< double > QVar
Per-(station,class) queue-length VARIANCE at this instant, empty where the method carries no second m...
static Distrib coxian(const std::vector< T > &mu, const std::vector< T > &phi)
Coxian(mu, phi): phase i completes with probability phi(i) and otherwise moves to phase i+1.
Definition lang_types.h:987
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:674
static constexpr double FineTol
Definition lang_types.h:668
static constexpr double Zero
Definition lang_types.h:670
static constexpr double CoarseTol
Definition lang_types.h:669
A MAP as the pair of matrices (D0, D1).
Definition map_moment.h:53
Matrix< T > D1
Definition map_moment.h:55
Matrix< T > D0
Definition map_moment.h:54