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