LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_moments.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_FLUID_MOMENTS_H
6#define LINE_SOLVERS_FLUID_FLUID_MOMENTS_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The second-order fluid methods: `fluid_moment_terms.m`, `fluid_lyapunov.m`,
12 * `fluid_drift_jacobian.m`, `fluid_refine_meanfield.m` and
13 * `solver_fluid_moments.m`, which back `options.method` `minnormal` and
14 * `refined`.
15 *
16 * WHY THE PORT NEEDED THESE AT ALL, given that the first-order methods already
17 * answered every model. `minnormal` is what the REFERENCE'S `default` resolves to
18 * wherever it applies (`fluid_resolve_default_method.m`), so without it the port
19 * answered a different method than the reference under the same name -- and
20 * answered it less accurately, since the first-order closure replaces
21 * E[min(X,c)] by min(E[X],c) and is worst exactly at rho ~ 1. On the reference's
22 * own sweep (Delay(Z=1) -> Queue(PS, c=2), N=6) the exact CTMC queue length is
23 * 1.95137, `closing` returns 2.00000 and `minnormal` 1.96063.
24 *
25 * WHAT THE SECOND MOMENT IS, and why a fluid solver has one. The closing ODEs are
26 * a density-dependent Markov population process
27 *
28 * dx/dt = F(x) = D r(x), r_e(x) = rateBase_e g_e(x)
29 *
30 * whose fluctuation process Z = X - x* obeys, to leading order,
31 * dZ = A Z dt + sqrt(D diag(r) D') dW with A = dF/dx. That linear noise
32 * approximation has a stationary covariance, the solution of the Lyapunov
33 * equation A Sigma + Sigma A' + D diag(r) D' = 0, and THAT is the second moment
34 * reported through `getMoments`. `solver_fluid_odes.m` throws D and r away once
35 * it has composed F, which is why `fluid_moment_terms` rebuilds them: the
36 * diffusion matrix cannot be recovered from F alone.
37 *
38 * THE TWO METHODS DIFFER IN WHICH FIXED POINT THEY EXPAND ABOUT, and mixing them
39 * would count the same term twice. `minnormal` solves mean and covariance
40 * self-consistently, so its fixed point already RESUMS the O(1/N) correction --
41 * expanding E[F(X)] to second order and setting it to zero reproduces the Gast
42 * correction equation exactly. `refined` therefore recomputes the base point with
43 * the FIRST-order closure and adds the correction to that.
44 */
45
47#include <algorithm>
48#include <cmath>
49#include <cstddef>
50#include <limits>
51#include <string>
52#include <vector>
53
59#include "line/util/eig.h"
60#include "line/util/error.h"
61#include "line/util/linalg.h"
62#include "line/util/matrix.h"
63#include "line/util/svd.h"
64#include "line/util/sylvester.h"
65
66namespace line {
67namespace fluid {
68
69/**
70 * The reference's `MException('LINE:FluidNonHyperbolic')`, as a type.
71 *
72 * IT HAS TO BE DISTINGUISHABLE FROM EVERY OTHER FAILURE. A non-hyperbolic fluid
73 * fixed point cannot be detected before the mean is solved, so
74 * `fluid_minnormal_applicable` cannot decline it in advance; the runner catches
75 * THIS exception, and only this one, to fall back to a first-order method when
76 * the moment closure was RESOLVED from `default` rather than asked for by name.
77 * Catching a plain NumericError there would also swallow a singular Jacobian or a
78 * failed integration, which are defects and not model properties.
79 */
80// FluidNonHyperbolicError now lives in fluid_nonhyperbolic.h, so that
81// solver_fluid.h can raise it without including this header (which includes it).
82
83/** What `fluid_lyapunov` reports about the fixed point it linearized at. */
85 std::size_t rank = 0;
86 double max_real_eig = -std::numeric_limits<double>::infinity();
87 bool stable = true;
88};
89
90namespace detail {
91
92/**
93 * MATLAB's `orth`: an orthonormal basis of the column space, from the SVD, over
94 * the singular values above `max(size(A))*eps*sigma_1`.
95 */
96inline Matrix<double> fluid_orth(const Matrix<double>& A) {
97 if (A.rows() == 0 || A.cols() == 0) return Matrix<double>(A.rows(), 0, 0.0);
98 const SvdFactors f = svd_full(A);
99 const double eps = std::numeric_limits<double>::epsilon();
100 const double s1 = f.s.empty() ? 0.0 : f.s[0];
101 const double tol = static_cast<double>(std::max(A.rows(), A.cols())) * eps * s1;
102 std::size_t keep = 0;
103 for (std::size_t i = 0; i < f.s.size(); ++i)
104 if (f.s[i] > tol) ++keep;
105 Matrix<double> V(A.rows(), keep, 0.0);
106 for (std::size_t j = 0; j < keep; ++j)
107 for (std::size_t i = 0; i < A.rows(); ++i) V(i, j) = f.U(i, j);
108 return V;
109}
110
111/** Symmetrize in place: (M + M')/2. */
112inline void fluid_symmetrize(Matrix<double>& M) {
113 for (std::size_t i = 0; i < M.rows(); ++i)
114 for (std::size_t j = i + 1; j < M.cols(); ++j) {
115 const double v = 0.5 * (M(i, j) + M(j, i));
116 M(i, j) = v;
117 M(j, i) = v;
118 }
119}
120
121} // namespace detail
122
123/**
124 * `a + step*(b - a)` for a closure, entry by entry.
125 *
126 * A 0x0 covariance block is read as the zero matrix and stays 0x0 when both
127 * sides are. The matrix half of the damped variance step; the twin of the
128 * scalar `sigma2` blend, so the two stay consistent.
129 */
130inline FluidClosure fluid_blend_closure(const FluidClosure& a, const FluidClosure& b, double step) {
131 FluidClosure out;
132 out.sigma2.assign(b.sigma2.size(), 0.0);
133 for (std::size_t i = 0; i < b.sigma2.size(); ++i) {
134 const double ai = i < a.sigma2.size() ? a.sigma2[i] : 0.0;
135 out.sigma2[i] = ai + step * (b.sigma2[i] - ai);
136 }
137 out.cov.assign(b.cov.size(), Matrix<double>(0, 0, 0.0));
138 for (std::size_t i = 0; i < b.cov.size(); ++i) {
139 const Matrix<double> zero(0, 0, 0.0);
140 const Matrix<double>& ai = i < a.cov.size() ? a.cov[i] : zero;
141 const Matrix<double>& bi = b.cov[i];
142 if (ai.rows() == 0 && bi.rows() == 0) continue;
143 if (ai.rows() == 0) {
144 Matrix<double> m(bi.rows(), bi.cols(), 0.0);
145 for (std::size_t r = 0; r < bi.rows(); ++r)
146 for (std::size_t c = 0; c < bi.cols(); ++c) m(r, c) = step * bi(r, c);
147 out.cov[i] = m;
148 } else if (bi.rows() == 0) {
149 Matrix<double> m(ai.rows(), ai.cols(), 0.0);
150 for (std::size_t r = 0; r < ai.rows(); ++r)
151 for (std::size_t c = 0; c < ai.cols(); ++c) m(r, c) = (1.0 - step) * ai(r, c);
152 out.cov[i] = m;
153 } else {
154 Matrix<double> m(bi.rows(), bi.cols(), 0.0);
155 for (std::size_t r = 0; r < bi.rows(); ++r)
156 for (std::size_t c = 0; c < bi.cols(); ++c)
157 m(r, c) = ai(r, c) + step * (bi(r, c) - ai(r, c));
158 out.cov[i] = m;
159 }
160 }
161 return out;
162}
163
164/**
165 * Port of `fluid_lyapunov.m`: the stationary covariance of the linear noise
166 * approximation.
167 *
168 * A IS SINGULAR WHENEVER THE MODEL CONSERVES POPULATION -- every closed class
169 * contributes a left null vector -- so the equation has no unique solution on the
170 * full state space. It has one on the reachable subspace, which is exactly
171 * range(D): the state moves only along jump directions, so the fluctuation lives
172 * there and nowhere else. A = D diag(rateBase) G and Qdiff = D diag(r) D' both map
173 * into range(D) as well, so restricting to an orthonormal basis of it is an EXACT
174 * reduction and not an approximation, and the reduced equation is nonsingular
175 * whenever the fixed point is stable.
176 */
178 const Matrix<double>& D, FluidLyapunovInfo& info,
179 double tol = -1.0) {
180 if (tol < 0.0) tol = std::sqrt(std::numeric_limits<double>::epsilon());
181 const std::size_t n = A.rows();
182 const Matrix<double> V = detail::fluid_orth(D);
183 if (V.cols() == 0) {
184 info.rank = 0;
185 info.max_real_eig = -std::numeric_limits<double>::infinity();
186 info.stable = true;
187 return Matrix<double>(n, n, 0.0);
188 }
189
190 const Matrix<double> Vt = V.transpose();
191 Matrix<double> Ar = matmul(matmul(Vt, A), V);
192 Matrix<double> Qr = matmul(matmul(Vt, Qdiff), V);
193 detail::fluid_symmetrize(Qr);
194
195 const std::vector<std::complex<double>> ev = eig_values(Ar);
196 double max_re = -std::numeric_limits<double>::infinity();
197 for (std::size_t i = 0; i < ev.size(); ++i) max_re = std::max(max_re, ev[i].real());
198 info.rank = V.cols();
199 info.max_real_eig = max_re;
200 info.stable = max_re < -tol;
201 if (!info.stable)
203 "fluid_lyapunov: the fluid fixed point is not exponentially stable on the reachable "
204 "subspace (largest Jacobian eigenvalue has real part " +
205 std::to_string(max_re) +
206 "), so the linear noise approximation has no stationary covariance. This happens at an "
207 "unstable model or at a drift kink; use method 'closing' for the mean only");
208
209 // Ar W + W Ar' = -Qr, i.e. the Sylvester equation with B = Ar'.
210 Matrix<double> negQr(Qr.rows(), Qr.cols(), 0.0);
211 for (std::size_t i = 0; i < Qr.rows(); ++i)
212 for (std::size_t j = 0; j < Qr.cols(); ++j) negQr(i, j) = -Qr(i, j);
213 Matrix<double> W = sylvester_solve(Ar, Ar.transpose(), negQr);
214 detail::fluid_symmetrize(W);
215 Matrix<double> Sigma = matmul(matmul(V, W), Vt);
216 detail::fluid_symmetrize(Sigma);
217 return Sigma;
218}
219
220/**
221 * Port of `fluid_moment_terms.m`: the event representation of the fluid
222 * population process, plus the drift, rate and Jacobian handles the covariance
223 * equation needs.
224 *
225 * The state layout, the events and the rate factors are already
226 * `fluid_ode_system`'s; what this adds is the dense jump matrix, the event
227 * classification the throughput is read from, the per-station and per-class index
228 * blocks, and the projection that makes an OPEN model solvable.
229 *
230 * OPEN AND MIXED MODELS: THE COVARIANCE LIVES ON THE QUEUE COORDINATES ONLY. The
231 * closing representation models a Source as an EXT pseudo-station holding unit
232 * mass, so its coordinate is a normalisation constant and not a job count;
233 * building D diag(r) D' over it would invent noise for a direction that carries no
234 * population. Projecting those coordinates away leaves exactly the right open
235 * event set, because the closing form already emits the correct events: with a
236 * single-phase source the EXT rate factor is 1 - sum(of nothing) = 1 identically,
237 * so an arrival is a CONSTANT-rate event whose jump, once the source row is
238 * dropped, is a lone +1 into the destination queue -- the canonical exogenous
239 * Poisson arrival with diffusion intensity lambda -- and the return leg LINE
240 * routes Sink -> Source becomes a lone -1. The EXT row of the Jacobian is
241 * identically zero for a single-phase source, so A restricted to the kept
242 * coordinates IS the Jacobian of the projected drift.
243 *
244 * A MULTI-PHASE SOURCE IS REFUSED: those coordinates track the phase of ONE
245 * arrival process, a single Markov chain rather than a population, so their
246 * fluctuations are O(1) and no linear noise approximation applies to them at any
247 * scale.
248 */
251 Matrix<double> D; ///< (nstate x nevents)
252 std::size_t nstate = 0;
253 std::vector<bool> ev_is_departure; ///< the leading n_departures events
254 std::vector<std::size_t> ev_station, ev_class; ///< 0-based, from the event's coordinate
255 /**
256 * `emap(e, o)`: expected firings of the ORIGINAL event o per firing of the
257 * reduced event e; the identity when no immediate coordinate was eliminated.
258 * The classification above is indexed by ORIGINAL event, so a throughput is
259 * read as `r' * (emap * indicator_over_original_events)`.
260 */
262 /// Projector taking an initial condition onto the surviving coordinates.
264 std::vector<std::vector<std::size_t>> station_block;
265 std::vector<std::vector<std::vector<std::size_t>>> class_block;
266 std::vector<std::size_t> cov_idx; ///< coordinates carrying a real population
267 std::vector<double> S; ///< servers, INF substituted, lld peak folded
268 std::vector<bool> is_ext;
269 /// stations whose occupancy cannot reach their server count, where min(n,c) is
270 /// the identity and the closure must stay first order
271 std::vector<bool> min_exact;
272};
273
274/**
275 * True when the immediate reduction folded coordinate `s` away, so the reduced
276 * drift holds no mass there and no event lands on it.
277 *
278 * `absorb` is the projector the reduction returns: the identity on a surviving
279 * coordinate and the absorption distribution on an eliminated one, so a zero
280 * diagonal is exactly the eliminated case. It is empty when nothing was
281 * eliminated, where every coordinate survives.
282 */
283inline bool fluid_coord_eliminated(const FluidMomentTerms& t, std::size_t s) {
284 if (t.absorb.rows() == 0 || s >= t.absorb.rows()) return false;
285 return t.absorb(s, s) == 0.0;
286}
287
288template <class T>
290 const std::size_t M = sn.nstations, K = sn.nclasses;
293
294 // THE MOMENT CLOSURE READS THE SAME REDUCED EVENT SET AS EVERY OTHER ROUTE.
295 // It used to refuse the reduction, on the grounds that it needs the
296 // untransformed event set; what it actually needs is to be able to say which
297 // (station,class) each event is a completion of, and `emap` carries exactly
298 // that across the composition -- an event folded through an immediate
299 // coordinate keeps a row with weight on every original event it stands for,
300 // including the two completions a pass-through realises at once. The
301 // diffusion D*diag(r)*D' is then the diffusion of the reduced process, which
302 // is the right one: the eliminated coordinate holds O(1/InfRate) mass and
303 // contributes noise of the same order.
304 const FluidOdeSystem sys0 = t.sys;
307 if (ir.eliminated) {
308 t.sys = ir.sys;
309 t.emap = ir.emap;
310 t.absorb = ir.absorb;
311 }
312 }
313 const FluidLayout& L = t.sys.layout;
314 t.nstate = L.nstates;
315 t.D = fluid_jump_matrix(t.sys);
316
317 // A delay serves every job at once, so the reference substitutes the closed
318 // population for its server count -- and floors it at one, since a pure open
319 // model has no closed population and the utilization divisor would vanish.
320 double npop = 0.0;
321 for (std::size_t r = 0; r < K; ++r)
322 if (std::isfinite(sn.classes[r].population)) npop += sn.classes[r].population;
323 t.S.assign(M, 0.0);
324 t.is_ext.assign(M, false);
325 for (std::size_t i = 0; i < M; ++i) {
326 const double c = sn.stations[i].nservers;
327 t.S[i] = std::isfinite(c) ? c : std::max(npop, 1.0);
328 t.is_ext[i] = sn.stations[i].sched == lang::SchedStrategy::EXT;
329 }
330
331 // Index blocks, and the projection of the EXT source pool.
332 t.station_block.assign(M, std::vector<std::size_t>());
333 t.class_block.assign(M, std::vector<std::vector<std::size_t>>(K));
334 std::vector<bool> keep(t.nstate, true);
335 for (std::size_t i = 0; i < M; ++i) {
336 for (std::size_t r = 0; r < K; ++r) {
337 for (std::size_t k = 0; k < L.kic[i][r]; ++k) {
338 t.class_block[i][r].push_back(L.qidx[i][r] + k);
339 t.station_block[i].push_back(L.qidx[i][r] + k);
340 }
341 if (!t.is_ext[i] || L.kic[i][r] == 0) continue;
342 if (L.kic[i][r] > 1)
343 throw UnsupportedError(
344 "fluid_moment_terms: the moment-closure methods need a Poisson arrival stream, "
345 "but the source of class " +
346 std::to_string(r + 1) + " is a " + std::to_string(L.kic[i][r]) +
347 "-phase process. Those coordinates track the phase of a single arrival process "
348 "rather than a population, so they carry no linear noise approximation. Use an "
349 "exponential inter-arrival time, or method 'matrix'");
350 for (std::size_t k = 0; k < L.kic[i][r]; ++k) keep[L.qidx[i][r] + k] = false;
351 }
352 }
353 for (std::size_t a = 0; a < t.nstate; ++a)
354 if (keep[a]) t.cov_idx.push_back(a);
355
356 // A STATION THAT CANNOT FILL ITS SERVERS HAS NOTHING TO CLOSE. min(n_i,c_i) is
357 // the identity on the whole support whenever the occupancy of station i is
358 // bounded above by its server count, and there the Gaussian closure is not an
359 // improvement on the first-order one, it is an ERROR: it spreads a normal
360 // marginal over n_i > c_i, mass the station can never hold, and returns
361 // E[min(n_i,c_i)] < n_i. On a closed model with one job per chain the exact
362 // answer is R = D at every queue (a job cannot queue behind itself), which the
363 // first-order closure reproduces to machine precision while the closure reads
364 // 0.4758 against 0.5 on the queue length. The bound is the total population of
365 // every chain that VISITS the station -- a station may declare a service time
366 // for every class while the routing never sends most of them there -- and an
367 // open chain contributes an infinite population and never qualifies.
368 // `solver_fluid_moments` holds the drift variance of these stations at zero,
369 // exactly as it does for the delay stations, whose min() is likewise absent.
370 t.min_exact.assign(M, false);
371 if (!sn.chains.empty()) {
372 for (std::size_t i = 0; i < M; ++i) {
373 const lang::SchedStrategy s = sn.stations[i].sched;
374 if (t.is_ext[i] || s == lang::SchedStrategy::INF ||
375 !std::isfinite(sn.stations[i].nservers))
376 continue;
377 const std::size_t isf = sn.stateful_of_station(i + 1) - 1;
378 double bound = 0.0;
379 bool infinite = false;
380 std::vector<bool> covered(K, false);
381 for (std::size_t ch = 0; ch < sn.chains.size(); ++ch) {
382 bool here = false;
383 double pop = 0.0;
384 bool pop_inf = false;
385 const bool has_vis = ch < sn.visits.size() && sn.visits[ch].rows() > isf;
386 for (std::size_t r = 0; r < K; ++r) {
387 if (!sn.chains[ch][r]) continue;
388 covered[r] = true;
389 const double n = sn.classes[r].population;
390 if (std::isfinite(n)) pop += n; else pop_inf = true;
391 if (has_vis)
392 here = here || num_traits<T>::to_double(sn.visits[ch](isf, r)) > 0.0;
393 else
394 here = here || t.sys.layout.kic[i][r] > 0;
395 }
396 if (here) {
397 if (pop_inf) infinite = true;
398 bound += pop;
399 }
400 }
401 bool uncovered = false;
402 for (std::size_t r = 0; r < K; ++r)
403 if (t.sys.layout.kic[i][r] > 0 && !covered[r]) uncovered = true;
404 if (uncovered) continue; // a class outside every chain carries no bound
405 t.min_exact[i] = !infinite && bound <= sn.stations[i].nservers +
407 }
408 }
409
410 // Event classification: `ode_rate_base` emits every service completion first
411 // and then every intra-PH phase change, so the leading `n_departures` events
412 // are the departures. Summing their rates at (i,c) gives the class-c
413 // throughput at station i EXACTLY, because the routing probabilities and the
414 // entry-phase vector each sum to one over the destinations enumerated there.
415 std::vector<std::size_t> coord_station(t.nstate, 0), coord_class(t.nstate, 0);
416 for (std::size_t i = 0; i < M; ++i)
417 for (std::size_t r = 0; r < K; ++r)
418 for (std::size_t k = 0; k < L.kic[i][r]; ++k) {
419 coord_station[L.qidx[i][r] + k] = i;
420 coord_class[L.qidx[i][r] + k] = r;
421 }
422 // Classified on the ORIGINAL events, which is what `emap` maps onto. Without a
423 // reduction `emap` is the identity and the two indexings coincide.
424 const std::size_t nev0 = sys0.events.size();
425 t.ev_is_departure.assign(nev0, false);
426 t.ev_station.assign(nev0, 0);
427 t.ev_class.assign(nev0, 0);
428 for (std::size_t e = 0; e < nev0; ++e) {
429 t.ev_is_departure[e] = e < sys0.n_departures;
430 t.ev_station[e] = coord_station[sys0.events[e].event_idx];
431 t.ev_class[e] = coord_class[sys0.events[e].event_idx];
432 }
433 if (t.emap.rows() == 0) {
434 t.emap = Matrix<double>(t.sys.events.size(), nev0, 0.0);
435 for (std::size_t e = 0; e < t.sys.events.size() && e < nev0; ++e) t.emap(e, e) = 1.0;
436 }
437 return t;
438}
439
440/** The rate factors g(x) under a closure: `terms.factorFcn`. */
441inline std::vector<double> fluid_moment_factors(const FluidMomentTerms& t,
442 const std::vector<double>& x,
443 const FluidClosure& cl) {
444 FluidOdeSystem sys = t.sys;
445 sys.closure = cl;
446 std::vector<double> g = x;
447 fluid_rates_closing_factors(sys, x.data(), g);
448 return g;
449}
450
451/** The event rates r(x) under a closure: `terms.ratesFcn`. */
452inline std::vector<double> fluid_moment_rates(const FluidMomentTerms& t,
453 const std::vector<double>& x,
454 const FluidClosure& cl) {
455 const std::vector<double> g = fluid_moment_factors(t, x, cl);
456 std::vector<double> r(t.sys.events.size(), 0.0);
457 for (std::size_t e = 0; e < r.size(); ++e)
458 r[e] = t.sys.events[e].rate_base * g[t.sys.events[e].event_idx];
459 return r;
460}
461
462/** The drift F(x) = D r(x) under a closure: `terms.driftFcn`. */
463inline std::vector<double> fluid_moment_drift(const FluidMomentTerms& t,
464 const std::vector<double>& x,
465 const FluidClosure& cl) {
466 const std::vector<double> r = fluid_moment_rates(t, x, cl);
467 std::vector<double> f(t.nstate, 0.0);
468 for (std::size_t e = 0; e < r.size(); ++e) {
469 if (r[e] == 0.0) continue;
470 f[t.sys.events[e].minus] -= r[e];
471 f[t.sys.events[e].plus] += r[e];
472 }
473 return f;
474}
475
476/**
477 * Port of `fluid_drift_jacobian.m`: the analytic Jacobian of the fluid drift.
478 *
479 * IT MUST MIRROR `ode_rates_closing_factors` BRANCH BY BRANCH. The Jacobian
480 * drives the Lyapunov equation and the 1/N refinement, so a branch that exists
481 * there and not here linearizes a drift that was never integrated -- and any
482 * policy with no case there keeps g = x and so contributes the identity here.
483 * With sigma2 = 0 the derivative of the occupancy factor is the indicator of the
484 * unsaturated region, the a.e. derivative of the first-order closure; with
485 * sigma2 > 0 it is the smooth derivative the closure returns.
486 */
487inline Matrix<double> fluid_drift_jacobian(const FluidMomentTerms& t, const std::vector<double>& x,
488 const FluidClosure& cl) {
489 const FluidOdeSystem& sys = t.sys;
490 const FluidLayout& L = sys.layout;
491 const std::size_t M = L.qidx.size();
492 const std::size_t K = M ? L.qidx[0].size() : 0;
493 const std::size_t n = t.nstate;
494 const bool gaussian = cl.gaussian();
495
496 Matrix<double> G = eye<double>(n); // INF, EXT phases 2.., and every policy without a case
497 const auto zero_rows = [&](const std::vector<std::size_t>& rows) {
498 for (std::size_t a = 0; a < rows.size(); ++a)
499 for (std::size_t j = 0; j < n; ++j) G(rows[a], j) = 0.0;
500 };
501
502 for (std::size_t i = 0; i < M; ++i) {
503 const std::vector<double>& lld = sys.lld[i];
504 const double s2 = cl.sigma2_of(i);
505 const Matrix<double>* Ci = cl.cov_of(i);
506 const std::vector<std::size_t>& blk = t.station_block[i];
507 const std::size_t nb = blk.size();
508 if (nb == 0) continue;
509 double ni = 0.0;
510 for (std::size_t a = 0; a < nb; ++a) ni += x[blk[a]];
511
512 switch (sys.sched[i]) {
514 if (lld.empty() || !(ni > 0.0)) break;
515 const ClosureValue h = fluid_capacity_closure(ni, t.S[i], s2, lld, true);
516 const double f = h.h / ni, fp = (ni * h.dh - h.h) / (ni * ni);
517 zero_rows(blk);
518 for (std::size_t a = 0; a < nb; ++a)
519 for (std::size_t b = 0; b < nb; ++b)
520 G(blk[a], blk[b]) = (a == b ? f : 0.0) + x[blk[a]] * fp;
521 break;
522 }
524 for (std::size_t r = 0; r < K; ++r) {
525 if (!L.enabled[i][r]) continue;
526 const std::size_t b = L.qidx[i][r], nn = L.kic[i][r];
527 for (std::size_t j = 0; j < n; ++j) G(b, j) = 0.0;
528 for (std::size_t p = 1; p < nn; ++p) G(b, b + p) = -1.0;
529 }
530 break;
531 }
534 if (!(ni > 0.0)) break; // g = x on an empty station
535 double h = 0.0, dh = 0.0;
536 if (gaussian || !lld.empty()) {
537 const ClosureValue cv = fluid_capacity_closure(ni, t.S[i], s2, lld, false);
538 h = cv.h;
539 dh = cv.dh;
540 if (Ci != nullptr) {
541 // g = s(x_blk)*h(ni) + h'(ni)*cn(x_blk), the joint closure
542 // of the share and the capacity; reached only from this
543 // branch, exactly as in the drift. Differentiating it with
544 // C held fixed adds h'*dcn and h''*cn to the product rule.
545 const double d2h = fluid_capacity_closure(ni, t.S[i], s2, lld, false).d2h;
546 std::vector<double> xb(nb, 0.0), wv(nb, 1.0);
547 for (std::size_t a = 0; a < nb; ++a) xb[a] = x[blk[a]];
548 const ShareValue sh = fluid_share_closure(xb, wv, *Ci, true, true);
549 zero_rows(blk);
550 for (std::size_t a = 0; a < nb; ++a)
551 for (std::size_t b = 0; b < nb; ++b)
552 G(blk[a], blk[b]) = sh.ds(a, b) * h + sh.s[a] * dh +
553 dh * sh.dcn(a, b) + sh.cn[a] * d2h;
554 break;
555 }
556 } else if (ni > t.S[i] - lang::GlobalConstants::FineTol * std::max(1.0, ni)) {
557 // THE SATURATION TEST CARRIES A BAND, and it is a cross-codebase
558 // requirement: a saturated fixed point sits exactly at ni = c, and
559 // each engine's ODE stops on its own residual (MATLAB 1.0004, this
560 // port 1 - 1.8e-13 on the same model). A strict ni > c reads
561 // saturated in one and unsaturated in the other, which flips this
562 // whole station block between a zero row and the identity, and with
563 // it the hyperbolicity verdict `fluid_lyapunov` returns and the
564 // method SolverFluid ends up answering with. See
565 // `fluid_min_closure`, whose degenerate branch carries the band too.
566 h = t.S[i];
567 dh = 0.0;
568 } else {
569 break; // g = x, the identity is already in place
570 }
571 // g_j = x_j h(ni)/ni -> dg_j/dx_m = delta_jm f + x_j f',
572 // f = h/ni, f' = (ni dh - h)/ni^2
573 const double f = h / ni, fp = (ni * dh - h) / (ni * ni);
574 zero_rows(blk);
575 for (std::size_t a = 0; a < nb; ++a)
576 for (std::size_t b = 0; b < nb; ++b)
577 G(blk[a], blk[b]) = (a == b ? f : 0.0) + x[blk[a]] * fp;
578 break;
579 }
581 double wsum = 0.0;
582 for (std::size_t r = 0; r < K; ++r) wsum += sys.weight[i][r];
583 if (wsum <= 0.0) break;
584 std::vector<double> wv(nb, 0.0), xb(nb, 0.0);
585 for (std::size_t r = 0; r < K; ++r) {
586 if (!L.enabled[i][r]) continue;
587 const std::size_t b = L.qidx[i][r] - blk[0];
588 for (std::size_t p = 0; p < L.kic[i][r]; ++p)
589 wv[b + p] = sys.weight[i][r] / wsum;
590 }
591 double wx = 0.0;
592 for (std::size_t a = 0; a < nb; ++a) {
593 xb[a] = x[blk[a]];
594 wx += wv[a] * xb[a];
595 }
596 if (!(ni > 0.0) || !(wx > 0.0)) break; // g = x on an empty station
597 const ClosureValue psi = fluid_capacity_closure(ni, t.S[i], s2, lld, false);
599 xb, wv, Ci ? *Ci : Matrix<double>(0, 0, 0.0), true, true);
600 zero_rows(blk);
601 for (std::size_t a = 0; a < nb; ++a)
602 for (std::size_t b = 0; b < nb; ++b)
603 G(blk[a], blk[b]) = sh.ds(a, b) * psi.h + sh.s[a] * psi.dh +
604 psi.dh * sh.dcn(a, b) + sh.cn[a] * psi.d2h;
605 break;
606 }
608 if (sys.nservers[i] > 1.0)
609 throw UnsupportedError(
610 "fluid_drift_jacobian: multi-server GPS stations are not supported, as in "
611 "the reference");
612 std::vector<double> xk(K, 0.0), vk(K, 0.0), wk(K, 0.0);
613 for (std::size_t r = 0; r < K; ++r) {
614 wk[r] = sys.weight[i][r];
615 const std::vector<std::size_t>& bk = t.class_block[i][r];
616 for (std::size_t a = 0; a < bk.size(); ++a) xk[r] += x[bk[a]];
617 if (Ci == nullptr) continue;
618 double v = 0.0;
619 for (std::size_t a = 0; a < bk.size(); ++a)
620 for (std::size_t b = 0; b < bk.size(); ++b)
621 v += (*Ci)(bk[a] - blk[0], bk[b] - blk[0]);
622 vk[r] = std::max(0.0, v);
623 }
624 const ShareValue sk = fluid_gps_share(xk, wk, vk, true);
625 ClosureValue a1;
626 a1.h = 1.0;
627 a1.dh = 0.0;
628 if (!lld.empty()) a1 = fluid_lld_scaling(lld, ni);
629 zero_rows(blk);
630 // g_j = (x_j/x_k) s_k a for coordinate j of class k, so
631 // dg_j/dx_l = [delta_jl/x_k - x_j/x_k^2] s_k a (l in class k)
632 // + (x_j/x_k) ds_k/dx_m a (l in class m)
633 // + (x_j/x_k) s_k da/dxi (l in the station)
634 for (std::size_t r = 0; r < K; ++r) {
635 const std::vector<std::size_t>& bk = t.class_block[i][r];
636 if (bk.empty() || !(xk[r] > 0.0)) continue;
637 for (std::size_t a = 0; a < bk.size(); ++a) {
638 for (std::size_t b = 0; b < bk.size(); ++b)
639 G(bk[a], bk[b]) += (a == b ? sk.s[r] * a1.h / xk[r] : 0.0) -
640 (sk.s[r] * a1.h / (xk[r] * xk[r])) * x[bk[a]];
641 for (std::size_t m = 0; m < K; ++m) {
642 const std::vector<std::size_t>& bm = t.class_block[i][m];
643 for (std::size_t b = 0; b < bm.size(); ++b)
644 G(bk[a], bm[b]) += (a1.h * sk.ds(r, m) / xk[r]) * x[bk[a]];
645 }
646 if (a1.dh != 0.0)
647 for (std::size_t b = 0; b < nb; ++b)
648 G(bk[a], blk[b]) += (sk.s[r] * a1.dh / xk[r]) * x[bk[a]];
649 }
650 }
651 break;
652 }
653 default:
654 break;
655 }
656 }
657
658 // A = D (rateBase .* G(eventIdx,:)), assembled through the two-index event
659 // form: every column of D has one -1 and one +1, so this is the same product
660 // without building the (nstate x nevents) intermediate.
661 Matrix<double> A(n, n, 0.0);
662 for (std::size_t e = 0; e < sys.events.size(); ++e) {
663 const FluidEvent& ev = sys.events[e];
664 if (ev.rate_base == 0.0) continue;
665 for (std::size_t j = 0; j < n; ++j) {
666 const double v = ev.rate_base * G(ev.event_idx, j);
667 if (v == 0.0) continue;
668 A(ev.minus, j) -= v;
669 A(ev.plus, j) += v;
670 }
671 }
672 return A;
673}
674
675/**
676 * Every station whose population sits ON the saturation kink n_i = c_i of the
677 * first-order rate factor, in increasing order, empty when none does.
678 *
679 * With sigma2 = 0 the occupancy factor is min(n_i, c_i), whose derivative is the
680 * indicator of the unsaturated region: slope 1 below c_i, slope 0 above, and NO
681 * derivative at c_i itself. `fluid_drift_jacobian` resolves the tie onto the
682 * saturated side, as its MATLAB, python and JAR twins do, so it silently returns
683 * one one-sided value there; which side a fixed point lands on is decided by the
684 * integrator's rounding residue rather than by the model. Callers that need a
685 * differentiable drift consult this instead of trusting the tie-break.
686 *
687 * Only the branches that take the indicator derivative can sit on a kink: a
688 * positive sigma2 or a load-dependent row makes the closure smooth, and an
689 * infinite server never saturates. Twin of
690 * `FluidRateFactors.driftKinkStations` in the JAR.
691 */
692inline std::vector<std::size_t> fluid_kink_stations(const FluidMomentTerms& t,
693 const std::vector<double>& x,
694 const FluidClosure& cl) {
695 std::vector<std::size_t> out;
696 for (std::size_t i = 0; i < cl.sigma2.size(); ++i)
697 if (cl.sigma2[i] > 0.0) return out; // the Gaussian closure has no kink
698 const double tol = std::sqrt(std::numeric_limits<double>::epsilon());
699 for (std::size_t i = 0; i < t.station_block.size(); ++i) {
700 const lang::SchedStrategy sc = t.sys.sched[i];
701 if (sc == lang::SchedStrategy::INF || sc == lang::SchedStrategy::EXT) continue;
702 if (!t.sys.lld[i].empty()) continue; // psi is piecewise quadratic and smooth
703 const double c = t.S[i];
704 if (!std::isfinite(c) || c <= 0.0) continue;
705 const std::vector<std::size_t>& blk = t.station_block[i];
706 if (blk.empty()) continue;
707 double ni = 0.0;
708 for (std::size_t a = 0; a < blk.size(); ++a) ni += x[blk[a]];
709 if (!(ni > 0.0)) continue; // g = x on an empty station
710 if (std::fabs(ni - c) <= tol * std::max(1.0, c)) out.push_back(i);
711 }
712 return out;
713}
714
715/**
716 * A copy of `x` with every station in `kink` moved to `c_i*(1 + rel)`, i.e.
717 * strictly onto one side of its kink. The station's coordinates are scaled
718 * together, so the phase mix and every other station are untouched.
719 */
720inline std::vector<double> fluid_nudge_off_kink(const FluidMomentTerms& t,
721 const std::vector<double>& x,
722 const std::vector<std::size_t>& kink, double rel) {
723 std::vector<double> y = x;
724 for (std::size_t k = 0; k < kink.size(); ++k) {
725 const std::vector<std::size_t>& blk = t.station_block[kink[k]];
726 double ni = 0.0;
727 for (std::size_t a = 0; a < blk.size(); ++a) ni += y[blk[a]];
728 if (!(ni > 0.0)) continue;
729 const double scale = t.S[kink[k]] * (1.0 + rel) / ni;
730 for (std::size_t a = 0; a < blk.size(); ++a) y[blk[a]] *= scale;
731 }
732 return y;
733}
734
735/**
736 * `local_lyapunov` of `solver_fluid_moments.m`: the covariance on the coordinates
737 * that carry a real population, scattered back to full size.
738 *
739 * For a closed model `cov_idx` is every coordinate and this is the plain solve.
740 * For an open or mixed model it drops the EXT source pool; the zeros left on the
741 * dropped rows keep the station and class block indexing unchanged downstream.
742 */
744 const Matrix<double>& A,
745 const std::vector<double>& r,
746 const Matrix<double>* clampT = nullptr) {
747 const std::size_t nc = t.cov_idx.size(), nev = r.size();
748 Matrix<double> Dc(nc, nev, 0.0);
749 for (std::size_t a = 0; a < nc; ++a)
750 for (std::size_t e = 0; e < nev; ++e) Dc(a, e) = t.D(t.cov_idx[a], e);
751 // CLAMPT, when given, is the tangent space of the caps that CLAMP: a cap that
752 // holds the job upstream or loses it fixes its own combination of the state
753 // while it binds, so that combination does not fluctuate. Projecting the jump
754 // directions is enough to state the reduced problem, because FLUID_LYAPUNOV
755 // restricts everything to range(D) already. See the DAE route's clamp tangent.
756 if (clampT && clampT->rows() == nc && clampT->cols() == nc) {
757 Matrix<double> Dp(nc, nev, 0.0);
758 for (std::size_t a = 0; a < nc; ++a)
759 for (std::size_t e = 0; e < nev; ++e) {
760 double acc = 0.0;
761 for (std::size_t b = 0; b < nc; ++b) acc += (*clampT)(a, b) * Dc(b, e);
762 Dp(a, e) = acc;
763 }
764 Dc = Dp;
765 }
766 Matrix<double> Qc(nc, nc, 0.0);
767 for (std::size_t a = 0; a < nc; ++a)
768 for (std::size_t b = 0; b < nc; ++b) {
769 double acc = 0.0;
770 for (std::size_t e = 0; e < nev; ++e) acc += Dc(a, e) * r[e] * Dc(b, e);
771 Qc(a, b) = acc;
772 }
773 Matrix<double> Ac(nc, nc, 0.0);
774 for (std::size_t a = 0; a < nc; ++a)
775 for (std::size_t b = 0; b < nc; ++b) Ac(a, b) = A(t.cov_idx[a], t.cov_idx[b]);
776
778 const Matrix<double> Sc = fluid_lyapunov(Ac, Qc, Dc, info);
779 Matrix<double> Sigma(t.nstate, t.nstate, 0.0);
780 for (std::size_t a = 0; a < nc; ++a)
781 for (std::size_t b = 0; b < nc; ++b) Sigma(t.cov_idx[a], t.cov_idx[b]) = Sc(a, b);
782 return Sigma;
783}
784
785/** What `fluid_refine_meanfield` reports about the correction it computed. */
787 std::size_t rank = 0;
788 double stepsize = 0.0;
789 double residual = 0.0;
790 double condition = 0.0;
791};
792
793/**
794 * Port of `fluid_refine_meanfield.m`: the O(1/N) refined mean field correction of
795 * Gast (POMACS 2017).
796 *
797 * The correction V solves A V + (1/2) sum_{jk} Sigma_jk d2F/dx_j dx_k = 0. The
798 * Hessian contraction is evaluated WITHOUT forming the tensor: with
799 * Sigma = sum_m lam_m v_m v_m', the contraction is sum_m lam_m d2F/dv_m^2 and each
800 * directional second derivative is one central second difference, so the cost is
801 * O(rank(Sigma)) drift evaluations rather than O(n^2). Because Sigma scales with
802 * the population, V is the O(1/N) term written directly in job counts and no
803 * explicit density rescaling is needed.
804 *
805 * THE DRIFT MUST BE TWICE DIFFERENTIABLE. The first-order closure is only
806 * piecewise linear -- second derivative zero away from the kink and a delta at it
807 * -- so a zero variance is REFUSED rather than silently returning a null
808 * correction.
809 */
810inline std::vector<double> fluid_refine_meanfield(const FluidMomentTerms& t,
811 const std::vector<double>& x,
812 const FluidClosure& cl,
813 const Matrix<double>& Sigma,
814 FluidRefineInfo& info, double epsrel = 1e-4) {
815 if (!cl.gaussian())
816 throw InputError(
817 "fluid_refine_meanfield: the refined mean field expansion needs a twice-differentiable "
818 "drift, but the first-order closure is only piecewise linear. Reach this function "
819 "through method 'refined', which converges the Gaussian closure first");
820
821 const std::size_t n = x.size();
822 Matrix<double> Sig = Sigma;
823 detail::fluid_symmetrize(Sig);
824 // Sigma is symmetric positive semidefinite, so its SVD IS its
825 // eigendecomposition: the singular values are the eigenvalues and the left
826 // singular vectors the eigenvectors. Using it avoids a second, symmetric
827 // eigensolver for a matrix that already has one.
828 const SvdFactors f = svd_full(Sig);
829 const double eps = std::numeric_limits<double>::epsilon();
830 const double lmax = f.s.empty() ? 0.0 : f.s[0];
831 std::vector<std::size_t> keep;
832 for (std::size_t m = 0; m < f.s.size(); ++m)
833 if (f.s[m] > lmax * std::sqrt(eps) && f.s[m] > 0.0) keep.push_back(m);
834
835 double xnorm = 0.0;
836 for (std::size_t a = 0; a < n; ++a) xnorm += x[a] * x[a];
837 xnorm = std::sqrt(xnorm);
838 const double scale = std::max(1.0, xnorm);
839 const double step = epsrel * scale;
840
841 const std::vector<double> F0 = fluid_moment_drift(t, x, cl);
842 std::vector<double> b(n, 0.0);
843 for (std::size_t idx = 0; idx < keep.size(); ++idx) {
844 const std::size_t m = keep[idx];
845 std::vector<double> xp = x, xm = x;
846 for (std::size_t a = 0; a < n; ++a) {
847 xp[a] += step * f.U(a, m);
848 xm[a] -= step * f.U(a, m);
849 }
850 const std::vector<double> Fp = fluid_moment_drift(t, xp, cl);
851 const std::vector<double> Fm = fluid_moment_drift(t, xm, cl);
852 for (std::size_t a = 0; a < n; ++a)
853 b[a] += f.s[m] * (Fp[a] - 2.0 * F0[a] + Fm[a]) / (step * step);
854 }
855 for (std::size_t a = 0; a < n; ++a) b[a] *= 0.5;
856
857 // Solve A V = -b on the reachable subspace, where A is invertible.
858 const Matrix<double> A = fluid_drift_jacobian(t, x, cl);
859 const Matrix<double> Vb = detail::fluid_orth(t.D);
860 const Matrix<double> Vbt = Vb.transpose();
861 const Matrix<double> Ar = matmul(matmul(Vbt, A), Vb);
862 const std::vector<double> sv = svd_values(Ar);
863 const double cond = (sv.empty() || sv.back() == 0.0)
864 ? std::numeric_limits<double>::infinity()
865 : sv.front() / sv.back();
866 if (!std::isfinite(cond) || cond > 1.0 / std::sqrt(eps))
868 "fluid_refine_meanfield: the fluid Jacobian is numerically singular on the reachable "
869 "subspace (condition number " +
870 std::to_string(cond) +
871 "), so the refinement equation A V = -b has no meaningful solution. The fixed point "
872 "sits at a drift kink or the model is marginally stable; use method 'minnormal', which "
873 "resums the same correction without inverting A");
874
875 std::vector<double> rhs(Vb.cols(), 0.0);
876 for (std::size_t j = 0; j < Vb.cols(); ++j) {
877 double acc = 0.0;
878 for (std::size_t a = 0; a < n; ++a) acc += Vb(a, j) * b[a];
879 rhs[j] = -acc;
880 }
881 const Matrix<double> Arinv = inverse(Ar);
882 std::vector<double> vr(Vb.cols(), 0.0);
883 for (std::size_t j = 0; j < Vb.cols(); ++j) {
884 double acc = 0.0;
885 for (std::size_t k = 0; k < Vb.cols(); ++k) acc += Arinv(j, k) * rhs[k];
886 vr[j] = acc;
887 }
888 std::vector<double> V(n, 0.0);
889 for (std::size_t a = 0; a < n; ++a) {
890 double acc = 0.0;
891 for (std::size_t j = 0; j < Vb.cols(); ++j) acc += Vb(a, j) * vr[j];
892 V[a] = acc;
893 }
894
895 // The refinement is the next term of an asymptotic expansion, so it is only
896 // meaningful while it stays small against the leading term; a correction the
897 // size of the fixed point means the expansion has not kicked in at this
898 // population, and returning it would be worse than refusing.
899 double vnorm = 0.0, resid = 0.0;
900 for (std::size_t a = 0; a < n; ++a) vnorm += V[a] * V[a];
901 vnorm = std::sqrt(vnorm);
902 for (std::size_t a = 0; a < n; ++a) {
903 double acc = b[a];
904 for (std::size_t j = 0; j < n; ++j) acc += A(a, j) * V[j];
905 resid += acc * acc;
906 }
907 info.rank = keep.size();
908 info.stepsize = step;
909 info.residual = std::sqrt(resid);
910 info.condition = cond;
911 if (vnorm > 0.5 * std::max(xnorm, std::sqrt(eps)))
913 "fluid_refine_meanfield: the 1/N refinement (norm " + std::to_string(vnorm) +
914 ") is not small against the mean-field fixed point (norm " + std::to_string(xnorm) +
915 "), so the asymptotic expansion is outside its range of validity at this population. "
916 "Use method 'minnormal'");
917 return V;
918}
919
920/**
921 * Port of `solver_fluid_moments.m`: the second-order fluid analysis backing
922 * `minnormal` and `refined`.
923 *
924 * THE OUTER FIXED POINT IS OVER THE VARIANCE, not over the mean. Each sweep
925 * solves the mean at the current closure variance -- through the ordinary closing
926 * integration, which is why the closure travels on `FluidOptions` -- then solves
927 * the Lyapunov equation at that mean and reads a new variance off the covariance
928 * blocks. `sigma2` alone is not enough to iterate on: the DPS and PS capacity
929 * share is a RATIO of coordinates, so closing it needs the covariance BETWEEN
930 * them, and the blocks are carried through the same fixed point and compared in
931 * the same convergence test -- a block sum can converge while the off-diagonals
932 * the share closure reads are still moving.
933 *
934 * THE METRICS ARE READ AT THE VARIANCE THE MEAN SOLVE USED, not at the variance
935 * that solve produced. Using the latter evaluates the rate functions away from
936 * their own fixed point and throughput stops balancing: with the variance held at
937 * zero for the mean solve, Tput came back 2.000000 at the delay against 1.949745
938 * at the queue on Delay -> Queue(PS,c=2), N=6, a 2.5% gap in a closed cycle where
939 * the two must be equal. The two differ only within the outer tolerance once the
940 * fixed point has converged.
941 *
942 * A DELAY'S VARIANCE IS KEPT FOR REPORTING AND EXCLUDED FROM THE DRIFT: there is
943 * no min() to close at an infinite server, so letting it in would perturb a term
944 * that is exactly linear.
945 */
946template <class T>
948 if (!std::is_same<T, double>::value)
949 throw UnsupportedError(
950 "solver_fluid_moments: the fluid solver integrates its drift with LSODA, whose "
951 "coefficients assume double precision; rerun with --arith double");
952
953 const std::size_t M = sn.nstations, K = sn.nclasses;
954 std::string m = opt.method;
955 if (m.size() > 6 && m.compare(0, 6, "fluid.") == 0) m = m.substr(6);
956 if (!(m == "minnormal" || m == "refined"))
957 throw UnsupportedError("solver_fluid_moments: '" + opt.method +
958 "' is not a moment-closure method; only 'minnormal' and 'refined' "
959 "are solved here");
960 // `fluid_moment_terms.m:114`. The closure solves a STATIONARY Lyapunov
961 // equation, so it needs an autonomous drift; a time-varying rate multiplier
962 // leaves no fixed point for a stationary covariance to sit at.
963 if (detail::fluid_has_time_varying_rates(opt))
964 throw UnsupportedError(
965 "solver_fluid_moments: the moment closures require an AUTONOMOUS drift, but "
966 "options.config.rate_traj / nhpp_sched / rate_sched make the rates time-varying. Use "
967 "method 'closing' or 'matrix'");
968 // The EXT projection covers `minnormal` only. `fluid_refine_meanfield` solves
969 // its correction on orth(D) over the FULL state and would add a perturbation to
970 // the source pool mass, which is a normalisation constant rather than a
971 // population; only minnormal was validated open, so refined keeps the
972 // closed-model restriction instead of being declared on an untested path.
973 if (m == "refined")
974 for (std::size_t r = 0; r < K; ++r)
975 if (!std::isfinite(sn.classes[r].population))
976 throw UnsupportedError(
977 "solver_fluid_moments: the 'refined' method supports closed models only: its "
978 "1/N correction is solved over the full state, including the source pool. Use "
979 "method 'minnormal' for open or mixed models");
980
982
983 // The covariance is a dense nstate-by-nstate object and the Lyapunov solve is
984 // cubic in it, so refuse rather than silently crawl. The same cap decides
985 // whether `default` resolves here at all (`fluid_minnormal_applicable`).
986 const std::size_t maxstate = opt.moment_maxstate;
987 if (terms.nstate > maxstate)
988 throw UnsupportedError(
989 "solver_fluid_moments: the moment-closure methods solve a " +
990 std::to_string(terms.nstate) + "x" + std::to_string(terms.nstate) +
991 " Lyapunov equation, above the limit of " + std::to_string(maxstate) +
992 " set by moment_maxstate. Raise that limit or use method 'closing'");
993
994 // A station whose share is a ratio needs its covariance BLOCK, not only the
995 // block sum: PS, FCFS, DPS and GPS all read one.
996 std::vector<bool> share_sched(M, false);
997 for (std::size_t i = 0; i < M; ++i) {
998 const lang::SchedStrategy s = terms.sys.sched[i];
999 share_sched[i] = s == lang::SchedStrategy::PS || s == lang::SchedStrategy::FCFS ||
1001 }
1002
1003 std::size_t outer_max = 20;
1004 if (opt.iter_max > 0) outer_max = std::min<std::size_t>(outer_max, std::max<std::size_t>(2, opt.iter_max));
1005 // THE CLOSURE IS JUDGED FAR TIGHTER THAN CoarseTol, so it must not stop there.
1006 // Converged only to 1e-3 this alternation is not a fixed point to two machines:
1007 // on mqn_singleserver_ps the MATLAB twin answered 42.3962 on two hosts and
1008 // 42.8207 on a third, 1e-2 relative apart, because the transient iterate below
1009 // fell on opposite sides. 1e-6 is the loosest that reproduces; min(), not
1010 // assignment, so a caller may still ask tighter.
1011 //
1012 // The INNER mean solve is deliberately left alone here, unlike in the MATLAB
1013 // and Java twins. FluidOptions::iter_tol carries the OPPOSITE sense in this
1014 // codebase -- 0, the default, runs to iter_max and is the TIGHTEST setting,
1015 // while a positive value stops early -- so handing it mom_tol would loosen the
1016 // solve the other ports tighten.
1017 double mom_tol = 1e-6;
1018 if (opt.iter_tol > 0.0) mom_tol = std::min(mom_tol, opt.iter_tol);
1019 const double outer_tol = mom_tol;
1020
1021 FluidClosure cl; // the variance the NEXT mean solve will use
1022 cl.sigma2.assign(M, 0.0);
1023 cl.cov.assign(M, Matrix<double>(0, 0, 0.0));
1024 FluidClosure used = cl; // the variance the LAST mean solve actually used
1025 // The last closure whose Lyapunov solve SUCCEEDED, and the floor on the step
1026 // taken toward the next one. See the damping in the loop below.
1027 FluidClosure okcl;
1028 okcl.sigma2.assign(M, 0.0);
1029 okcl.cov.assign(M, Matrix<double>(0, 0, 0.0));
1030 const double damp_min = 1.0 / 64.0;
1031 Matrix<double> Sigma(terms.nstate, terms.nstate, 0.0);
1032 std::vector<double> x;
1033 std::size_t iters = 0, outer = 0;
1034
1035 // A DELAY STATION HAS NO min() TO CLOSE, so its variance must never reach the
1036 // drift -- only the report. This mask used to be applied to `drift_cl` after the
1037 // loop and nowhere inside it, so every mean solve of the fixed point ran with the
1038 // delay variance switched on. The rate factor there is mu*n, which the Gaussian
1039 // correction turns into something that does not vanish with n: the coordinate is
1040 // driven NEGATIVE, the drift is conservative so another coordinate grows to match,
1041 // and the trajectory leaves the simplex for good. On CQN_Cox_CS_9 (Delay + PS +
1042 // PS(c=5), N=6) the first window past sigma2 = 0 moved 8.7e3 of mass and the drift
1043 // norm reached 5.4e9.
1044 std::vector<bool> no_drift_var(M, false);
1045 for (std::size_t i = 0; i < M; ++i)
1046 no_drift_var[i] = terms.sys.sched[i] == lang::SchedStrategy::INF ||
1048
1049 line::util::LineConsole::loop("iterating the moment closure (at most %zu passes)", outer_max);
1050 for (outer = 1; outer <= outer_max; ++outer) {
1051 line::util::LineConsole::iter(static_cast<long>(outer),
1052 "closure pass %zu: %zu ODE iterations so far", outer, iters);
1053 // A TRANSIENT ITERATE MUST NOT VETO THE METHOD. The Lyapunov gate asks
1054 // whether the linear noise approximation has a stationary covariance at the
1055 // point THIS iterate landed on; a fixed point that fails it is a model the
1056 // closure cannot answer, but an intermediate iterate that fails it is only a
1057 // variance step that overshot. On mqn_singleserver_ps iterate 1 was stable at
1058 // -4.93e-03, iterate 2 declined at +1.07e+01, and the fixed point the fallback
1059 // then found was stable at -4.92e-03. So a failing iterate RETREATS toward the
1060 // last closure that succeeded, halving until the LNA is defined again; only a
1061 // step below damp_min, or a failure at the seed where there is nothing to
1062 // retreat toward, is the model's own non-hyperbolicity and still throws.
1063 double step = 1.0;
1064 FluidClosure trycl;
1065 for (;;) {
1066 trycl = fluid_blend_closure(okcl, cl, step);
1067 used = trycl;
1068 // A station whose occupancy cannot reach its server count has min(n,c) = n on
1069 // the whole support, so the closure there must stay first order: see
1070 // `fluid_moment_terms`, which decides it from the chain populations. Its share
1071 // closure follows, because mu_r*(n_r/n)*min(n,c) collapses to mu_r*n_r once the
1072 // min is the identity. The covariance is still solved for these stations and
1073 // still reported, it just does not enter the drift, exactly as at the delay
1074 // stations below.
1075 for (std::size_t i = 0; i < M; ++i) {
1076 if (!terms.min_exact[i] && !no_drift_var[i]) continue;
1077 if (i < used.sigma2.size()) used.sigma2[i] = 0.0;
1078 if (i < used.cov.size()) used.cov[i] = Matrix<double>(0, 0, 0.0);
1079 }
1080 FluidOptions mo = opt;
1081 mo.method = "closing"; // the closure enters through the drift, not the name
1082 mo.closure = used;
1083 const FluidSolution mean = detail::fluid_dispatch(sn, mo);
1084 iters += mean.iters;
1085 x = mean.xvec;
1086
1087 const std::vector<double> r = fluid_moment_rates(terms, x, used);
1088
1089 // A POINT ON A SATURATION KINK HAS NO JACOBIAN. `fluid_drift_jacobian`
1090 // resolves the tie onto the saturated side, so a verdict read off it would
1091 // depend on which side the integrator stopped. The VERDICT, not the point,
1092 // has to be side-independent: both one-sided Jacobians are ordinary
1093 // matrices, so ASK BOTH and decline only when a side fails. Refusing at
1094 // every kink instead throws away models the reference solves -- the first
1095 // outer iterate runs at sigma2 = 0 and a saturated model's first-order
1096 // fixed point lands on the kink by construction. Later iterates carry a
1097 // positive sigma2 and are smooth, so this costs two Jacobians on the seed
1098 // and nothing after it. Twin of
1099 // `FluidRateFactors.driftKinkStation`/`nudgedOffKink` in the JAR.
1100 const std::vector<std::size_t> kink = fluid_kink_stations(terms, x, used);
1101 if (!kink.empty()) {
1102 const double probe[2] = {-1e-6, 1e-6};
1103 for (std::size_t side = 0; side < 2; ++side) {
1104 const std::vector<double> xs = fluid_nudge_off_kink(terms, x, kink, probe[side]);
1105 try {
1106 fluid_moment_lyapunov(terms, fluid_drift_jacobian(terms, xs, used),
1107 fluid_moment_rates(terms, xs, used));
1108 } catch (const FluidNonHyperbolicError& e) {
1110 "solver_fluid_moments: the fluid fixed point sits on the saturation kink "
1111 "of station " + std::to_string(kink[0] + 1) + " (population equals its " +
1112 std::to_string(terms.S[kink[0]]) +
1113 " servers) and the two one-sided drift Jacobians there disagree on "
1114 "hyperbolicity, so which of them the linear noise approximation would use "
1115 "is decided by the integrator's rounding residue rather than by the model. "
1116 "This is the saturated boundary of a continuum of equilibria; use method "
1117 "'closing' for the mean only. Underlying: " + std::string(e.what()));
1118 }
1119 }
1120 }
1121
1122 const Matrix<double> A = fluid_drift_jacobian(terms, x, used);
1123 try {
1124 Sigma = fluid_moment_lyapunov(terms, A, r);
1125 break;
1126 } catch (const FluidNonHyperbolicError&) {
1127 bool at_seed = true;
1128 for (std::size_t i = 0; i < M && at_seed; ++i)
1129 if (cl.sigma2[i] != okcl.sigma2[i]) at_seed = false;
1130 for (std::size_t i = 0; i < M && at_seed; ++i)
1131 if (cl.cov[i].rows() != 0) at_seed = false;
1132 if (step <= damp_min || at_seed) throw;
1133 step = step / 2.0;
1134 }
1135 }
1136 okcl = trycl;
1137
1138 std::vector<double> s2new(M, 0.0);
1139 std::vector<Matrix<double>> covnew(M, Matrix<double>(0, 0, 0.0));
1140 for (std::size_t i = 0; i < M; ++i) {
1141 const std::vector<std::size_t>& blk = terms.station_block[i];
1142 if (blk.empty()) continue;
1143 double acc = 0.0;
1144 for (std::size_t a = 0; a < blk.size(); ++a)
1145 for (std::size_t b = 0; b < blk.size(); ++b) acc += Sigma(blk[a], blk[b]);
1146 s2new[i] = std::max(0.0, acc);
1147 if (!share_sched[i]) continue;
1148 Matrix<double> B(blk.size(), blk.size(), 0.0);
1149 for (std::size_t a = 0; a < blk.size(); ++a)
1150 for (std::size_t b = 0; b < blk.size(); ++b) B(a, b) = Sigma(blk[a], blk[b]);
1151 covnew[i] = B;
1152 }
1153
1154 double l1new = 0.0, l1diff = 0.0;
1155 for (std::size_t i = 0; i < M; ++i) {
1156 l1new += std::fabs(s2new[i]);
1157 l1diff += std::fabs(s2new[i] - trycl.sigma2[i]);
1158 }
1159 double delta = l1diff / std::max(1.0, l1new);
1160 // `norm(dc,1)` on a MATRIX is the maximum absolute COLUMN SUM, not the
1161 // entrywise sum that the same call gives on a vector. Summing every entry
1162 // instead overstates the residual, so the loop ran past the reference's
1163 // break and settled on a different closure fixed point: on the 3-station
1164 // 2-class PS model of the parity corpus that was Tput 0.8232419 against
1165 // 0.8234276, a 2.3e-4 gap that no tolerance change could close because
1166 // both sides were converged, just to different points.
1167 for (std::size_t i = 0; i < M; ++i) {
1168 if (covnew[i].rows() == 0) continue;
1169 double dn = 0.0, nn = 0.0;
1170 for (std::size_t b = 0; b < covnew[i].cols(); ++b) {
1171 double dcol = 0.0, ncol = 0.0;
1172 for (std::size_t a = 0; a < covnew[i].rows(); ++a) {
1173 const double old = (trycl.cov[i].rows() == covnew[i].rows()) ? trycl.cov[i](a, b) : 0.0;
1174 dcol += std::fabs(covnew[i](a, b) - old);
1175 ncol += std::fabs(covnew[i](a, b));
1176 }
1177 dn = std::max(dn, dcol);
1178 nn = std::max(nn, ncol);
1179 }
1180 delta = std::max(delta, dn / std::max(1.0, nn));
1181 }
1182 cl.sigma2 = s2new;
1183 cl.cov = covnew;
1184 if (delta < outer_tol) break;
1185 }
1186 const std::size_t outer_iters = std::min(outer, outer_max);
1187
1188 // The drift closure: the variance the mean solve used, with the delays already
1189 // excluded on the way in by `no_drift_var`, because they have no min() to close.
1190 //
1191 // NOT const, and the reason is the `refined` branch below: it re-points this at
1192 // the MEAN-FIELD closure once it has corrected the base point, and that choice is
1193 // read after the branch by `gfac`. So the variable carries the drift closure of
1194 // whichever method ran -- converged for `minnormal`, zero for `refined` -- and
1195 // making it const compiles only if that distinction is dropped, which would read
1196 // `refined`'s rate factors at a variance its correction has already resummed.
1197 FluidClosure drift_cl = used;
1198
1199 std::vector<double> refinement;
1200 std::vector<double> r;
1201 if (m == "refined") {
1202 // The refinement is a truncated expansion about the MEAN-FIELD fixed
1203 // point, not about the Gaussian one: adding it to the `minnormal` point
1204 // would count the same O(1/N) term twice, since the Gaussian closure
1205 // already resums it. So the base point is recomputed with the first-order
1206 // closure while the Hessian and the Jacobian are taken from the SMOOTH
1207 // Gaussian drift -- the hard min being only piecewise linear and, at
1208 // saturation, kinked exactly at the fixed point.
1209 FluidOptions mfo = opt;
1210 mfo.method = "closing";
1211 mfo.closure = FluidClosure();
1212 const FluidSolution mf = detail::fluid_dispatch(sn, mfo);
1213 iters += mf.iters;
1214 const std::vector<double> xmf = mf.xvec;
1215
1216 const Matrix<double> A = fluid_drift_jacobian(terms, xmf, drift_cl);
1217 Sigma = fluid_moment_lyapunov(terms, A, fluid_moment_rates(terms, xmf, drift_cl));
1218 FluidRefineInfo rinfo;
1219 // A LINEAR DRIFT NEEDS NO REFINEMENT, and that is not the degenerate
1220 // call `fluid_refine_meanfield` refuses. When every station is either
1221 // an infinite server or `min_exact` -- min(n,c) is the identity on the
1222 // reachable set, the population bound never reaching c -- the drift is
1223 // exactly affine there, its Hessian vanishes and the O(1/N) correction
1224 // is identically zero. The mask above then zeroes all of the drift
1225 // closure, which `gaussian()` reads as "the caller handed me the first
1226 // order closure" and the refinement rejects. Settle it here, where the
1227 // reason for the zero is known: a null correction, not an error.
1228 // Delay + PS(c=2) at N=2 is the smallest case.
1229 bool drift_is_linear = true;
1230 for (std::size_t i = 0; i < M && drift_is_linear; ++i)
1231 if (!terms.min_exact[i] && !no_drift_var[i]) drift_is_linear = false;
1232 if (drift_is_linear)
1233 refinement.assign(xmf.size(), 0.0);
1234 else
1235 refinement = fluid_refine_meanfield(terms, xmf, drift_cl, Sigma, rinfo);
1236 x = xmf;
1237 for (std::size_t a = 0; a < x.size(); ++a) {
1238 x[a] += refinement[a];
1239 if (x[a] < 0.0) x[a] = 0.0;
1240 }
1241 // The corrected point is a correction OF the mean-field fixed point, so
1242 // its rates are read with the mean-field (zero) variance.
1243 drift_cl = FluidClosure();
1244 r = fluid_moment_rates(terms, x, drift_cl);
1245 for (std::size_t i = 0; i < M; ++i) {
1246 const std::vector<std::size_t>& blk = terms.station_block[i];
1247 if (blk.empty()) continue;
1248 double acc = 0.0;
1249 for (std::size_t a = 0; a < blk.size(); ++a)
1250 for (std::size_t b = 0; b < blk.size(); ++b) acc += Sigma(blk[a], blk[b]);
1251 cl.sigma2[i] = std::max(0.0, acc);
1252 }
1253 } else {
1254 r = fluid_moment_rates(terms, x, drift_cl);
1255 }
1256
1257 // ---- performance measures, read off the event representation -------------
1258 const std::vector<double> gfac = fluid_moment_factors(terms, x, drift_cl);
1259
1260 // A load-dependent station clears alpha(n) times the nominal work, so its
1261 // utilization normalises by the PEAK scaling (T*S/peak, as in the CTMC).
1262 std::vector<double> Seff = terms.S;
1263 for (std::size_t i = 0; i < M; ++i) {
1264 const std::vector<double>& lld = terms.sys.lld[i];
1265 for (std::size_t k = 0; k < lld.size(); ++k) Seff[i] = std::max(Seff[i], lld[k]);
1266 }
1267
1268 FluidSolution out;
1269 out.method = m;
1270 out.iters = iters;
1271 out.xvec = x;
1272 out.QN = Matrix<double>(M, K, 0.0);
1273 out.UN = Matrix<double>(M, K, 0.0);
1274 out.RN = Matrix<double>(M, K, 0.0);
1275 out.TN = Matrix<double>(M, K, 0.0);
1276 for (std::size_t i = 0; i < M; ++i)
1277 for (std::size_t k = 0; k < K; ++k) {
1278 const std::vector<std::size_t>& blk = terms.class_block[i][k];
1279 if (blk.empty()) continue;
1280 double q = 0.0, g = 0.0;
1281 for (std::size_t a = 0; a < blk.size(); ++a) {
1282 q += x[blk[a]];
1283 g += gfac[blk[a]];
1284 }
1285 out.QN(i, k) = q;
1286 out.UN(i, k) = (terms.sys.sched[i] == lang::SchedStrategy::INF) ? q : g / Seff[i];
1287 // Summed over ORIGINAL events through `emap`: a reduced event folded
1288 // through an immediate coordinate is a completion at more than one
1289 // (station,class), and its rate has to reach every one of them.
1290 double tn = 0.0;
1291 for (std::size_t e = 0; e < r.size() && e < terms.emap.rows(); ++e) {
1292 double w = 0.0;
1293 for (std::size_t o = 0; o < terms.ev_is_departure.size(); ++o)
1294 if (terms.ev_is_departure[o] && terms.ev_station[o] == i
1295 && terms.ev_class[o] == k)
1296 w += terms.emap(e, o);
1297 if (w != 0.0) tn += r[e] * w;
1298 }
1299 out.TN(i, k) = tn;
1300 // TN is zero only to the integrator's accuracy: a class that never visits leaves
1301 // a ~1e-20 residue in TN too, and a strict > 0 test then divides residue by residue.
1302 if (tn > lang::GlobalConstants::Zero) out.RN(i, k) = q / tn;
1303 }
1304
1305 // A Source and a Sink report no queue length, utilization or response time,
1306 // the same rule `solver_fluid` applies to the first-order methods and
1307 // `NetworkSolver.zeroSourceMetrics` applies in the reference. The closing
1308 // representation holds UNIT MASS at an EXT source so that `g` can read
1309 // `1 - rest` (see `fluid_odes.h`), and that coordinate is a normalisation
1310 // constant rather than a job count: `fluid_moment_terms` already projects it
1311 // out of the covariance, but `class_block` still spans it, so the metric loop
1312 // above reads the pool as a queue. Measured on m3 (Exp(0.5) -> Erlang(2) PS)
1313 // the source row came back QLen 0.2497 and RespT 0.4995 against 0 and 0 in
1314 // the reference, and the pool also entered `CN` below.
1315 for (std::size_t i = 0; i < M; ++i) {
1316 const qn::NodeType nt = sn.stations[i].nodetype;
1317 if (nt != qn::NodeType::Source && nt != qn::NodeType::Sink) continue;
1318 for (std::size_t k = 0; k < K; ++k) {
1319 out.QN(i, k) = 0.0;
1320 out.UN(i, k) = 0.0;
1321 out.RN(i, k) = 0.0;
1322 }
1323 }
1324
1325 // ---- the moment report --------------------------------------------------
1327 rep.Sigma = Sigma;
1328 rep.QVar = Matrix<double>(M, K, 0.0);
1329 rep.QStd = Matrix<double>(M, K, 0.0);
1330 for (std::size_t i = 0; i < M; ++i)
1331 for (std::size_t k = 0; k < K; ++k) {
1332 const std::vector<std::size_t>& blk = terms.class_block[i][k];
1333 if (blk.empty()) continue;
1334 double acc = 0.0;
1335 for (std::size_t a = 0; a < blk.size(); ++a)
1336 for (std::size_t b = 0; b < blk.size(); ++b) acc += Sigma(blk[a], blk[b]);
1337 rep.QVar(i, k) = std::max(0.0, acc);
1338 rep.QStd(i, k) = std::sqrt(rep.QVar(i, k));
1339 }
1340 rep.sigma2 = cl.sigma2;
1341 rep.refinement = refinement;
1342 rep.outer_iters = outer_iters;
1343 rep.class_block = terms.class_block;
1344 out.has_moments = true;
1345 out.moments = rep;
1346 out.closure = drift_cl;
1347
1348 // System throughput and response time, per chain reference station.
1349 out.XN.assign(K, 0.0);
1350 out.CN.assign(K, 0.0);
1351 for (std::size_t k = 0; k < K; ++k) {
1352 const std::size_t rs = sn.classes[k].refstat;
1353 if (rs >= 1 && rs <= M) out.XN[k] = out.TN(rs - 1, k);
1354 double q = 0.0;
1355 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, k);
1356 if (out.XN[k] > 0.0) out.CN[k] = q / out.XN[k];
1357 }
1358 return out;
1359}
1360
1361} // namespace fluid
1362} // namespace line
1363
1364#endif // LINE_SOLVERS_FLUID_FLUID_MOMENTS_H
InputError(const std::string &what)
Definition error.h:39
std::size_t size() const
Definition matrix.h:91
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
bool empty() const
Definition matrix.h:92
Matrix transpose() const
Definition matrix.h:110
UnsupportedError(const std::string &what)
Definition error.h:51
Raised when the moment closure cannot serve this model: the linearization at the fixed point is not h...
FluidNonHyperbolicError(const std::string &what)
A network plus its refreshed NetworkStruct.
static void loop(const char *fmt,...)
Announce an iteration loop and reset its reporting budget.
static void iter(long k, const char *fmt,...)
Report iteration k of the current loop.
Eigenvalues and singular values, backed by LAPACK.
The exception types the port throws.
The moment closures the fluid drift is built from: fluid_min_closure.m, fluid_capacity_closure....
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...
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
Running progress log of a LINE solver run (the "solver console").
Dense matrix and non-owning view.
ClosureValue fluid_lld_scaling(const std::vector< double > &lldrow, double n)
Port of fluid_lld_scaling.m: the limited load-dependent multiplier alpha at a CONTINUOUS population,...
Matrix< double > fluid_lyapunov(const Matrix< double > &A, const Matrix< double > &Qdiff, const Matrix< double > &D, FluidLyapunovInfo &info, double tol=-1.0)
Port of fluid_lyapunov.m: the stationary covariance of the linear noise approximation.
std::vector< double > fluid_nudge_off_kink(const FluidMomentTerms &t, const std::vector< double > &x, const std::vector< std::size_t > &kink, double rel)
A copy of x with every station in kink moved to c_i*(1 + rel), i.e.
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,...
void fluid_rates_closing_factors(const FluidOdeSystem &sys, const double *x, std::vector< double > &g)
Port of ode_rates_closing_factors: the state-dependent factor g(x), in place.
Definition fluid_odes.h:486
Matrix< double > fluid_drift_jacobian(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
Port of fluid_drift_jacobian.m: the analytic Jacobian of the fluid drift.
ShareValue fluid_share_closure(const std::vector< double > &x, const std::vector< double > &wv, const Matrix< double > &C, bool want_jac, bool want_cov=false)
Port of fluid_share_closure.m: E[w_j X_j / sum_m w_m X_m] by the delta method, and its Jacobian at fi...
std::vector< double > fluid_moment_drift(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The drift F(x) = D r(x) under a closure: terms.driftFcn.
std::vector< double > fluid_moment_rates(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The event rates r(x) under a closure: terms.ratesFcn.
FluidMomentTerms fluid_moment_terms(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
FluidSolution solver_fluid_moments(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
Port of solver_fluid_moments.m: the second-order fluid analysis backing minnormal and refined.
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
FluidClosure fluid_blend_closure(const FluidClosure &a, const FluidClosure &b, double step)
a + step*(b - a) for a closure, entry by entry.
std::vector< double > fluid_moment_factors(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The rate factors g(x) under a closure: terms.factorFcn.
ShareValue fluid_gps_share(const std::vector< double > &xk, const std::vector< double > &wk_in, const std::vector< double > &vk, bool want_jac)
Port of fluid_gps_share.m: the expected capacity share of a GPS station under a normal marginal,...
std::vector< std::size_t > fluid_kink_stations(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
Every station whose population sits ON the saturation kink n_i = c_i of the first-order rate factor,...
Matrix< double > fluid_jump_matrix(const FluidOdeSystem &sys)
The reference's dense jump matrix D, (nstates x nevents), rebuilt from the two-index event form this ...
Definition fluid_odes.h:456
FluidImmediateResult fluid_eliminate_immediate(const FluidOdeSystem &sys, double imm_tol=fluid_immediate_transition_tol())
bool fluid_coord_eliminated(const FluidMomentTerms &t, std::size_t s)
True when the immediate reduction folded coordinate s away, so the reduced drift holds no mass there ...
std::vector< double > fluid_refine_meanfield(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl, const Matrix< double > &Sigma, FluidRefineInfo &info, double epsrel=1e-4)
Port of fluid_refine_meanfield.m: the O(1/N) refined mean field correction of Gast (POMACS 2017).
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...
Matrix< double > fluid_moment_lyapunov(const FluidMomentTerms &t, const Matrix< double > &A, const std::vector< double > &r, const Matrix< double > *clampT=nullptr)
local_lyapunov of solver_fluid_moments.m: the covariance on the coordinates that carry a real populat...
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:181
NodeType
Node kinds, with the values of MATLAB NodeType.
Definition lang_types.h:324
Matrix< T > inverse(const Matrix< T > &A)
Inverse by LU with one factorization and n back substitutions.
Definition linalg.h:72
Matrix< T > matmul(const Matrix< T > &A, const Matrix< T > &B)
Matrix product A B.
Definition linalg.h:36
std::vector< std::complex< double > > eig_values(const Matrix< double > &A)
Eigenvalues of a general real square matrix, in LAPACK's order.
Definition eig.h:59
std::vector< double > svd_values(const Matrix< double > &A)
Singular values in descending order.
Definition eig.h:128
Matrix< T > sylvester_solve(const Matrix< T > &A, const Matrix< T > &B, const Matrix< T > &C)
Solve A X + X B = C for X.
Definition sylvester.h:115
Matrix< T > eye(std::size_t n)
Identity of order n.
Definition linalg.h:28
SvdFactors svd_full(const Matrix< double > &A)
Full SVD of a real matrix, singular values in descending order.
Definition svd.h:48
A queueing network and its refreshed NetworkStruct.
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
A = U diag(s) Vt, with U (m x m), s of length min(m,n) and Vt (n x n).
Definition svd.h:41
Matrix< double > U
Definition svd.h:42
std::vector< double > s
Definition svd.h:43
A closure's value and its first two derivatives with respect to the first mean.
The second moment the drift closes its non-linear terms with, i.e.
Definition fluid_odes.h:123
std::vector< Matrix< double > > cov
per station, 0x0 keeps the plug-in share
Definition fluid_odes.h:125
const Matrix< double > * cov_of(std::size_t i) const
Definition fluid_odes.h:133
std::vector< double > sigma2
per station; empty selects first order
Definition fluid_odes.h:124
double sigma2_of(std::size_t i) const
Definition fluid_odes.h:132
bool gaussian() const
any(sigma2 > 0), the reference's GLOBAL gaussian flag.
Definition fluid_odes.h:127
One event of the drift.
Definition fluid_odes.h:101
std::size_t event_idx
state entry whose g(x) drives this rate
Definition fluid_odes.h:104
double rate_base
the model-fixed part of the rate
Definition fluid_odes.h:105
What an elimination attempt produced.
Definition fluid_stiff.h:99
FluidOdeSystem sys
reduced, or the input on a fallback
Matrix< double > emap
emap(e, o): expected firings of the ORIGINAL event o per firing of the reduced event e; the identity ...
bool eliminated
false when the input is returned unchanged
Matrix< double > absorb
Projector for the initial condition: identity on the timed rows, the absorption distribution on the i...
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 reference's MException('LINE:FluidNonHyperbolic'), as a type.
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
Port of fluid_moment_terms.m: the event representation of the fluid population process,...
std::vector< bool > ev_is_departure
the leading n_departures events
std::vector< double > S
servers, INF substituted, lld peak folded
std::vector< std::vector< std::vector< std::size_t > > > class_block
std::vector< std::size_t > cov_idx
coordinates carrying a real population
Matrix< double > emap
emap(e, o): expected firings of the ORIGINAL event o per firing of the reduced event e; the identity ...
std::vector< bool > min_exact
stations whose occupancy cannot reach their server count, where min(n,c) is the identity and the clos...
std::vector< std::vector< std::size_t > > station_block
std::vector< std::size_t > ev_class
0-based, from the event's coordinate
Matrix< double > absorb
Projector taking an initial condition onto the surviving coordinates.
Matrix< double > D
(nstate x nevents)
std::vector< std::size_t > ev_station
std::size_t n_departures
How many leading entries of events are DEPARTURES (a job completing at one block and starting at anot...
Definition fluid_odes.h:206
std::vector< double > nservers
per station, already finite
Definition fluid_odes.h:208
FluidClosure closure
The second moment the closures read; empty is the first-order drift.
Definition fluid_odes.h:218
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
std::vector< FluidEvent > events
Definition fluid_odes.h:197
std::vector< std::vector< double > > weight
per station, per class (DPS, GPS)
Definition fluid_odes.h:209
std::vector< lang::SchedStrategy > sched
per station
Definition fluid_odes.h:207
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 ...
What fluid_refine_meanfield reports about the correction it computed.
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
FluidMomentReport moments
std::vector< double > xvec
the converged fluid state
std::vector< double > CN
A share closure's value and Jacobian, and the joint-closure covariance.
std::vector< double > cn
std::vector< double > s
static constexpr double FineTol
Definition lang_types.h:668
static constexpr double Zero
Definition lang_types.h:670
Singular value decomposition WITH the singular vectors, and the Moore-Penrose pseudo-inverse built fr...
The Sylvester equation A X + X B = C, and MATLAB's lyap(A,B,C).