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