LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_closures.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_CLOSURES_H
6#define LINE_SOLVERS_FLUID_FLUID_CLOSURES_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The moment closures the fluid drift is built from: `fluid_min_closure.m`,
12 * `fluid_capacity_closure.m`, `fluid_lld_scaling.m`, `fluid_share_closure.m` and
13 * `fluid_gps_share.m`.
14 *
15 * WHY THESE ARE ONE HEADER AND NOT PART OF THE DRIFT. Each answers the same
16 * question about a different non-linear term of the rate function: what is
17 * E[f(X)] when X is not known but its first two moments are. The first-order
18 * fluid closure answers f(E[X]) for all of them, which is why they collapse to
19 * nothing when the variance is zero and why every routine below returns the
20 * first-order value on that path rather than special-casing it at the caller.
21 * The drift needs the VALUE, the covariance equation needs the DERIVATIVE, and
22 * they must be the derivative OF that value or the Lyapunov solve is linearizing
23 * a different drift than the one integrated; each routine returns both together
24 * for exactly that reason.
25 *
26 * THREE DISTINCT NON-LINEARITIES, and they are not interchangeable:
27 * min(n, c) the server capacity, closed by a normal marginal
28 * (`fluid_min_closure`, and `fluid_capacity_closure` once a
29 * load-dependent alpha(n) multiplies it)
30 * w_j x_j / sum the capacity share of PS and DPS, a RATIO, closed by the
31 * delta method (`fluid_share_closure`)
32 * 1{x_k >= 1} the backlog indicator of GPS, closed by ENUMERATING the 2^K
33 * backlog patterns (`fluid_gps_share`)
34 * The third is not a correction but the whole mechanism: with continuous mass
35 * every class is always backlogged, so a first-order closure prices GPS at its
36 * heavy-traffic constant regardless of load.
37 */
38
39#include <algorithm>
40#include <cmath>
41#include <cstddef>
42#include <limits>
43#include <string>
44#include <vector>
45
47#include "line/util/error.h"
48#include "line/util/matrix.h"
49
50namespace line {
51namespace fluid {
52
53/** A closure's value and its first two derivatives with respect to the first mean. */
55 double h = 0.0;
56 double dh = 0.0;
57 double d2h = 0.0;
58};
59
60/** The standard normal cdf, without a statistics library. */
61inline double closure_normcdf(double z) { return 0.5 * std::erfc(-z / std::sqrt(2.0)); }
62
63/** The standard normal pdf. */
64inline double closure_normpdf(double z) {
65 return std::exp(-0.5 * z * z) / std::sqrt(2.0 * 3.14159265358979323846);
66}
67
68/**
69 * Port of `fluid_min_closure.m`: E[min(X,Y)] for jointly normal X, Y, and its
70 * derivative with respect to E[X].
71 *
72 * The published closure is Guenther, Stefanek and Bradley (EPEW/UKPEW 2012,
73 * LNCS 7587:32-47, eq. 4) in its general two-population form, of which SolverFLD
74 * uses only the specialisation Y = c with c the deterministic server count. The
75 * general arguments are kept so this IS the published closure rather than one
76 * instance of it.
77 *
78 * With theta = 0 the expression collapses to min(n,c) and the derivative to
79 * 1{n < c}, so the first-order closure shares this code path exactly. The
80 * derivative AT the kink is 0, the right derivative of min(), which is the
81 * convention the strict inequality of the first-order branch already implies.
82 */
83inline ClosureValue fluid_min_closure(double n, double c, double s2, double vc = 0.0,
84 double cov_nc = 0.0) {
86 double th2 = s2 - 2.0 * cov_nc + vc;
87 if (th2 < 0.0) th2 = 0.0; // a covariance beyond Cauchy-Schwarz is not admissible
88 if (!(th2 > 0.0) || std::isinf(c)) {
89 // THE INDICATOR CARRIES A BAND, and it is a cross-codebase requirement rather
90 // than a modelling choice. A saturated fluid fixed point sits exactly AT n = c,
91 // and each engine's ODE stops on its own residual: MATLAB lands at 1.0004 and
92 // this port at 1 - 1.8e-13 on the same model, so a strict `n < c` reads
93 // saturated in one and unsaturated in the other. That flips a whole Jacobian row
94 // between zero and unit, and with it the hyperbolicity verdict that decides
95 // whether SolverFluid answers with 'minnormal' or falls back to the first-order
96 // method. A population within FineTol of the server count IS at the kink.
97 r.h = std::min(n, c);
98 r.dh = ((c - n) > lang::GlobalConstants::FineTol * std::max(1.0, n)) ? 1.0 : 0.0;
99 return r;
100 }
101 const double th = std::sqrt(th2);
102 const double d = (n - c) / th;
103 const double Phid = closure_normcdf(d);
104 const double phid = closure_normpdf(d);
105 r.h = n * (1.0 - Phid) + c * Phid - th * phid;
106 r.dh = 1.0 - Phid;
107 // min() is piecewise linear, so its second derivative is carried entirely by the
108 // atom at X = Y; smoothing over the normal marginal turns that atom into the
109 // density. It stays zero on the degenerate branch above, which smooths nothing.
110 r.d2h = -phid / th;
111 // A POPULATION IS NONNEGATIVE AND THE NORMAL MARGINAL IS NOT. For X >= 0
112 // pathwise min(X,c) >= 0, and min() being concave Jensen puts
113 // E[min(X,c)] <= min(E[X],c), so the value belongs to [0, min(n,c)]. The normal
114 // marginal has no such support, and the mass it places below zero drags the
115 // expectation out of that range once the mean falls to about one standard
116 // deviation: n = 0, c = 1, th = 0.664 returns -0.019, which is a station that
117 // CREATES work. Project onto the admissible range and take the derivatives of
118 // the bound that binds, so the Jacobian still matches h.
119 const double hi = std::min(n, c);
120 const bool at_lo = r.h < 0.0;
121 const bool at_hi = r.h > hi;
122 r.h = std::min(std::max(r.h, 0.0), hi);
123 if (at_lo) {
124 r.dh = 0.0;
125 } else if (at_hi) {
126 r.dh = (n < c) ? 1.0 : 0.0;
127 }
128 if (at_lo || at_hi) r.d2h = 0.0;
129 return r;
130}
131
132/**
133 * Port of `fluid_lld_scaling.m`: the limited load-dependent multiplier alpha at a
134 * CONTINUOUS population, by linear interpolation of the integer table.
135 *
136 * `sn.lldscaling(i,:)` is tabulated at populations 1..lldlimit and the discrete
137 * solvers read it at `min(n, lldlimit)`. The fluid state is continuous, so the
138 * table is interpolated between consecutive entries and clamped to the first
139 * entry below n = 1 and to the last above the table end, which is the clamping
140 * the CTMC already applies.
141 */
142inline ClosureValue fluid_lld_scaling(const std::vector<double>& lldrow, double n) {
143 ClosureValue r;
144 if (lldrow.empty()) {
145 r.h = 1.0;
146 r.dh = 0.0;
147 return r;
148 }
149 const std::size_t L = lldrow.size();
150 if (n <= 1.0) {
151 r.h = lldrow[0];
152 return r;
153 }
154 if (n >= static_cast<double>(L)) {
155 r.h = lldrow[L - 1];
156 return r;
157 }
158 const std::size_t k = static_cast<std::size_t>(std::floor(n)); // 1 <= k <= L-1
159 const double frac = n - static_cast<double>(k);
160 r.h = lldrow[k - 1] * (1.0 - frac) + lldrow[k] * frac;
161 r.dh = lldrow[k] - lldrow[k - 1];
162 return r;
163}
164
165namespace detail {
166
167/** psi(u) = A + B*u + C*u^2 on [p,q], from base(u)*alpha(u). */
168struct PsiSegment {
169 double A = 0.0, B = 0.0, C = 0.0;
170};
171
172/**
173 * `local_segment` of `fluid_capacity_closure.m`.
174 *
175 * alpha is constant on [0,1] and on [L,inf) and linear on each unit interval, so
176 * psi = base*alpha is piecewise quadratic with breakpoints at the integers and at
177 * the saturation point; the breakpoint list guarantees the segment lies entirely
178 * on one side of c, which is what lets base be either u or c and not both.
179 */
180inline PsiSegment psi_segment(double p, double q, double c, const std::vector<double>& lldrow,
181 bool is_inf) {
182 const std::size_t L = lldrow.size();
183 double a0 = 0.0, a1 = 0.0;
184 if (p >= static_cast<double>(L)) {
185 a0 = lldrow[L - 1];
186 } else if (p < 1.0) {
187 a0 = lldrow[0];
188 } else {
189 const std::size_t k = static_cast<std::size_t>(std::floor(p));
190 a1 = lldrow[k] - lldrow[k - 1];
191 a0 = lldrow[k - 1] - a1 * static_cast<double>(k);
192 }
193 PsiSegment s;
194 if (is_inf || !std::isfinite(c) || q <= c) {
195 s.A = 0.0;
196 s.B = a0;
197 s.C = a1; // base = u
198 } else {
199 s.A = c * a0;
200 s.B = c * a1;
201 s.C = 0.0; // base = c
202 }
203 return s;
204}
205
206/** Truncated moments E[X^j 1{p < X < q}] for X ~ Normal(n, s^2), j = 0,1,2. */
207struct TruncMoments {
208 double M0 = 0.0, M1 = 0.0, M2 = 0.0;
209};
210
211inline TruncMoments trunc_moments(double p, double q, double n, double s) {
212 const double zp = (p - n) / s;
213 const double Pp = closure_normcdf(zp);
214 const double pp = closure_normpdf(zp);
215 const bool qinf = std::isinf(q);
216 const double Pq = qinf ? 1.0 : closure_normcdf((q - n) / s);
217 const double pq = qinf ? 0.0 : closure_normpdf((q - n) / s);
218 TruncMoments m;
219 m.M0 = Pq - Pp;
220 m.M1 = n * m.M0 + s * (pp - pq);
221 m.M2 = qinf ? (n * n + s * s) * m.M0 + s * ((p + n) * pp)
222 : (n * n + s * s) * m.M0 + s * ((p + n) * pp - (q + n) * pq);
223 return m;
224}
225
226/** psi and its derivative at a continuous population, extended by zero below 0. */
227inline ClosureValue psi_point(double u, double c, const std::vector<double>& lldrow, bool is_inf) {
228 const ClosureValue a = fluid_lld_scaling(lldrow, u);
229 const double base = is_inf ? u : std::min(u, c);
230 const double dbase = is_inf ? 1.0 : ((u < c) ? 1.0 : 0.0);
231 ClosureValue r;
232 r.h = base * a.h;
233 r.dh = dbase * a.h + base * a.dh;
234 // base and alpha are both piecewise linear, so psi is piecewise quadratic and
235 // psi'' = 2*base'*alpha' away from the breakpoints. The atoms AT the
236 // breakpoints are not representable without a marginal to smooth them, and the
237 // first-order closure this branch serves does not smooth them either.
238 r.d2h = 2.0 * dbase * a.dh;
239 if (u <= 0.0) {
240 r.h = 0.0;
241 r.dh = 0.0;
242 r.d2h = 0.0;
243 }
244 return r;
245}
246
247} // namespace detail
248
249/**
250 * Port of `fluid_capacity_closure.m`: E[psi(X)] and its derivative, where
251 * psi(n) = min(n,c)*alpha(n) at a queueing station and n*alpha(n) at an infinite
252 * server.
253 *
254 * THE LOAD-DEPENDENT INTEGRATION IS EXACT AND NOT A QUADRATURE, which matters
255 * beyond accuracy: Gauss-Hermite on a fixed node set does not smooth the kinks of
256 * a piecewise-linear integrand, it RELOCATES them, and the resulting E[psi] is
257 * itself piecewise linear in n. Its second derivative is then zero almost
258 * everywhere and `fluid_refine_meanfield` silently returns a null correction.
259 * The segment-wise closed form below is what avoids that.
260 *
261 * The zero floor on the no-load-dependence branch is the reference's minimal
262 * repair for the normal marginal putting mass below zero, where min(X,c) = X < 0:
263 * once n is small against the standard deviation E[min(X,c)] itself goes
264 * negative, which is a negative service rate and mass destroyed by the
265 * integrator's non-negativity clamp.
266 */
267inline ClosureValue fluid_capacity_closure(double n, double c, double s2,
268 const std::vector<double>& lldrow, bool is_inf) {
269 if (lldrow.empty()) {
270 ClosureValue r;
271 if (is_inf) {
272 r.h = n;
273 r.dh = 1.0;
274 return r;
275 }
276 r = fluid_min_closure(n, c, s2);
277 if (r.h < 0.0) {
278 r.h = 0.0;
279 r.dh = 0.0;
280 r.d2h = 0.0;
281 }
282 return r;
283 }
284
285 const std::size_t L = lldrow.size();
286 if (!(s2 > 0.0)) return detail::psi_point(n, c, lldrow, is_inf);
287
288 const double s = std::sqrt(s2);
289 // The breakpoints of psi: the lattice of the table, the origin, and the
290 // saturation point c when it falls beyond the table.
291 std::vector<double> bps;
292 for (std::size_t k = 0; k <= L; ++k) bps.push_back(static_cast<double>(k));
293 if (!is_inf && std::isfinite(c) && c > static_cast<double>(L)) bps.push_back(c);
294 std::sort(bps.begin(), bps.end());
295 bps.erase(std::unique(bps.begin(), bps.end()), bps.end());
296
297 ClosureValue r;
298 // psi is extended by zero below the first breakpoint, so psi' jumps there too
299 // and that atom belongs in psi'' exactly like the interior ones
300 double Bprev = 0.0, Cprev = 0.0;
301 for (std::size_t k = 0; k < bps.size(); ++k) {
302 const double p = bps[k];
303 const double q = (k + 1 < bps.size()) ? bps[k + 1] : std::numeric_limits<double>::infinity();
304 const detail::PsiSegment g = detail::psi_segment(p, q, c, lldrow, is_inf);
305 const detail::TruncMoments mo = detail::trunc_moments(p, q, n, s);
306 r.h += g.A * mo.M0 + g.B * mo.M1 + g.C * mo.M2;
307 r.dh += g.B * mo.M0 + 2.0 * g.C * mo.M1;
308 r.d2h += 2.0 * g.C * mo.M0;
309 // psi' jumps across this breakpoint, so psi'' carries an atom there; the
310 // segment sum above sees only the quadratic part and would miss it
311 const double jump = (g.B + 2.0 * g.C * p) - (Bprev + 2.0 * Cprev * p);
312 r.d2h += jump * closure_normpdf((p - n) / s) / s;
313 Bprev = g.B;
314 Cprev = g.C;
315 }
316 return r;
317}
318
319/**
320 * Port of `local_project_rate` in `ode_rates_closing_factors.m`: project a jointly
321 * closed per-coordinate service share onto the set it has to live in, namely
322 * `r >= 0`, `r <= xb` where that bound applies, and `sum(r) = tot`.
323 *
324 * THE JOINT CLOSURE IS AN EXPANSION AND CAN LEAVE THAT SET. `r = s*psi +
325 * psi'*Cov(S,N)` adds a term that sums to ZERO over the coordinates, so it moves
326 * mass between them and its entries can push one past either bound; the
327 * first-order share `x_j/n_i*psi` cannot, being x_j scaled by `psi/n_i <= 1`.
328 * Either breach ends the same way, because the integrator holds every coordinate
329 * non-negative: `r_j > x_j` drains coordinate j faster than it holds, the state
330 * goes negative and the clamp INJECTS mass.
331 *
332 * THE UPPER BOUND HOLDS ONLY WITHOUT LOAD DEPENDENCE, which is what CAPPED
333 * selects. r is an expected NUMBER in service so `r_j <= x_j`, but
334 * `psi(n) = min(n,c)*alpha(n)` folds the load-dependent scaling into the same
335 * variable, and with alpha > 1 the first-order share itself exceeds x_j.
336 *
337 * Clip, then move the residual onto the coordinates that still have slack in
338 * proportion to it, so `sum(r) = tot` survives and the station still clears what
339 * its capacity closure says it clears. It is a NO-OP whenever the expansion stayed
340 * inside the set, which is why models already inside it are bit-identical.
341 */
342inline void fluid_project_rate(std::vector<double>& r, const std::vector<double>& xb, bool capped,
343 double tot) {
344 const double zt = lang::GlobalConstants::Zero;
345 bool inside = true;
346 for (std::size_t j = 0; j < r.size() && inside; ++j) {
347 if (!(r[j] >= -zt)) inside = false;
348 if (capped && !(r[j] <= xb[j] + zt)) inside = false;
349 }
350 if (inside) return;
351 for (std::size_t j = 0; j < r.size(); ++j) {
352 r[j] = std::max(r[j], 0.0);
353 if (capped) r[j] = std::min(r[j], xb[j]);
354 }
355 for (std::size_t it = 0; it <= r.size(); ++it) {
356 double sum = 0.0;
357 for (std::size_t j = 0; j < r.size(); ++j) sum += r[j];
358 const double d = tot - sum;
359 if (std::fabs(d) <= zt) break;
360 std::vector<double> slack(r.size(), 0.0);
361 for (std::size_t j = 0; j < r.size(); ++j)
362 slack[j] = (d > 0.0) ? (capped ? (xb[j] - r[j]) : 1.0) : r[j];
363 double tsl = 0.0;
364 for (std::size_t j = 0; j < slack.size(); ++j) tsl += slack[j];
365 if (tsl <= zt) break;
366 for (std::size_t j = 0; j < r.size(); ++j) {
367 r[j] = std::max(r[j] + d * slack[j] / tsl, 0.0);
368 if (capped) r[j] = std::min(r[j], xb[j]);
369 }
370 }
371}
372
373/** A share closure's value and Jacobian, and the joint-closure covariance. */
375 std::vector<double> s;
377 std::vector<double> cn; // Cov(S_j, N) at the means, summing to zero
378 Matrix<double> dcn; // d cn_j / d x_m, with C held fixed
379};
380
381/** How much of the second-order correction the series admits, and d tau / d ratio. */
383 double tau = 1.0;
384 double dtau = 0.0;
385};
386
387/**
388 * Port of `local_expansion_weight` in `fluid_share_closure.m`.
389 *
390 * Every second-order term of the share closure is a term of the series for
391 * E[1/v], whose successive terms are in the ratio `Var(v)/v^2`, so the truncation
392 * is meaningful below 1 and the terms GROW above it. Nothing in the algebra
393 * notices: at a near-empty station the corrections come back larger than the
394 * quantity they correct, and the drift that follows is not integrable.
395 *
396 * One on [0,1], zero from 4 up, and the C^1 smoothstep between. Both ends matter.
397 * The lower one has to be EXACTLY one on the whole convergent region, so every
398 * model already inside it is bit-identical; the upper one has to be reached with a
399 * vanishing derivative, because the drift is integrated and a kink in it is what
400 * collapses the step size. The two thresholds are the series, not a tuning: at
401 * ratio 1 successive terms stop shrinking, and at ratio 4 the standard deviation
402 * of v is twice its mean, where a non-negative v has essentially no mass near the
403 * point being expanded about.
404 */
406 const double lo = 1.0, hi = 4.0;
408 if (ratio <= lo) {
409 w.tau = 1.0;
410 w.dtau = 0.0;
411 } else if (ratio >= hi) {
412 w.tau = 0.0;
413 w.dtau = 0.0;
414 } else {
415 const double t = (ratio - lo) / (hi - lo);
416 w.tau = 1.0 - t * t * (3.0 - 2.0 * t);
417 w.dtau = -6.0 * t * (1.0 - t) / (hi - lo);
418 }
419 return w;
420}
421
422/**
423 * Port of `fluid_share_closure.m`: E[w_j X_j / sum_m w_m X_m] by the delta
424 * method, and its Jacobian at fixed covariance.
425 *
426 * THE INVARIANT TO CHECK ON ANY CHANGE HERE is that the shares sum to one. The
427 * correction is exactly capacity conserving because sum_j Cov(u_j,v) = Var(v), so
428 * the two correction terms cancel in the sum; a work-conserving discipline that
429 * lost that identity would leak or invent capacity.
430 *
431 * The expansion is local and fails where v is small against its own standard
432 * deviation, the exact expectation there being a Cauchy-like integral with no
433 * finite mean. A raw share can come out negative; it is clipped at zero and the
434 * survivors renormalised, which preserves the identity.
435 */
436inline ShareValue fluid_share_closure(const std::vector<double>& x, const std::vector<double>& wv,
437 const Matrix<double>& C, bool want_jac,
438 bool want_cov = false) {
439 const std::size_t n = x.size();
440 ShareValue out;
441 out.s.assign(n, 0.0);
442 if (want_jac) out.ds = Matrix<double>(n, n, 0.0);
443 if (want_cov) {
444 out.cn.assign(n, 0.0);
445 if (want_jac) out.dcn = Matrix<double>(n, n, 0.0);
446 }
447
448 std::vector<double> u(n, 0.0);
449 double v = 0.0;
450 for (std::size_t j = 0; j < n; ++j) {
451 u[j] = wv[j] * x[j];
452 v += u[j];
453 }
454 if (v <= 0.0) return out;
455
456 for (std::size_t j = 0; j < n; ++j) out.s[j] = u[j] / v;
457 const auto plugin_jac = [&](Matrix<double>& J) {
458 for (std::size_t j = 0; j < n; ++j)
459 for (std::size_t m = 0; m < n; ++m)
460 J(j, m) = (j == m ? wv[j] / v : 0.0) - (u[j] / (v * v)) * wv[m];
461 };
462 if (want_jac) plugin_jac(out.ds);
463
464 bool have_cov = C.rows() == n && C.cols() == n;
465 if (have_cov) {
466 bool any = false;
467 for (std::size_t a = 0; a < n && !any; ++a)
468 for (std::size_t b = 0; b < n && !any; ++b)
469 if (C(a, b) != 0.0) any = true;
470 have_cov = any;
471 }
472 if (!have_cov) return out;
473
474 std::vector<double> cuv(n, 0.0); // Cov(u_j, v)
475 double cvv = 0.0; // Var(v)
476 for (std::size_t j = 0; j < n; ++j) {
477 double acc = 0.0;
478 for (std::size_t b = 0; b < n; ++b) acc += C(j, b) * wv[b];
479 cuv[j] = wv[j] * acc;
480 cvv += wv[j] * acc;
481 }
482 // How far into the series this point sits, and how much of the second-order
483 // correction that leaves admissible. tau depends on x through v alone, since C
484 // is held fixed here exactly as the Jacobians are.
485 const ExpansionWeight tw = fluid_expansion_weight(cvv / (v * v));
486 if (tw.tau <= 0.0 && !want_jac) return out; // first-order share, already exact
487 const double tau = tw.tau;
488 const double dtau_scale = tw.dtau * (-2.0 * cvv / (v * v * v)); // d tau / d x_m = this * wv[m]
489
490 if (want_cov) {
491 // Cov(S_j, N) at the means, from grad(S_j)'*C*1: S_j = w_j X_j / V, so
492 // dS_j/dX_a = w_j*delta_aj/V - u_j*w_a/V^2 and the two pieces contract
493 // against C*1 = Cov(X,N) and w'*C*1 = Cov(V,N).
494 std::vector<double> an(n, 0.0);
495 double cvn = 0.0;
496 for (std::size_t j = 0; j < n; ++j) {
497 double acc = 0.0;
498 for (std::size_t b = 0; b < n; ++b) acc += C(j, b);
499 an[j] = acc;
500 cvn += wv[j] * acc;
501 }
502 std::vector<double> cn0(n, 0.0);
503 for (std::size_t j = 0; j < n; ++j) cn0[j] = wv[j] * an[j] / v - u[j] * (cvn / (v * v));
504 for (std::size_t j = 0; j < n; ++j) out.cn[j] = tau * cn0[j];
505 if (want_jac)
506 for (std::size_t j = 0; j < n; ++j)
507 for (std::size_t m = 0; m < n; ++m)
508 out.dcn(j, m) =
509 tau * (-(wv[j] * an[j]) * (wv[m] / (v * v)) -
510 (j == m ? (cvn / (v * v)) * wv[j] : 0.0) +
511 (2.0 * cvn / (v * v * v)) * u[j] * wv[m]) +
512 cn0[j] * dtau_scale * wv[m];
513 }
514
515 std::vector<double> scorr(n, 0.0);
516 for (std::size_t j = 0; j < n; ++j)
517 scorr[j] = -cuv[j] / (v * v) + (u[j] * cvv) / (v * v * v);
518 for (std::size_t j = 0; j < n; ++j) out.s[j] += tau * scorr[j];
519 if (want_jac)
520 for (std::size_t j = 0; j < n; ++j)
521 for (std::size_t m = 0; m < n; ++m)
522 out.ds(j, m) += tau * ((2.0 / (v * v * v)) * cuv[j] * wv[m] +
523 (j == m ? (cvv / (v * v * v)) * wv[j] : 0.0) -
524 (3.0 * cvv / (v * v * v * v)) * u[j] * wv[m]) +
525 scorr[j] * dtau_scale * wv[m];
526
527 // A COORDINATE CARRYING NO MASS MUST NOT DECIDE THE CLIP. With u_j = 0 the
528 // plug-in share is zero and the correction leaves s_j = -Cov(u_j,v)/v^2, a
529 // quantity of the order of rounding whose SIGN is not meaningful. Letting it
530 // select the branch below zeroes that coordinate's whole Jacobian row, and a
531 // zero row is an exact zero eigenvalue: the Lyapunov step then reads the fixed
532 // point as non-hyperbolic and SolverFLD silently drops from 'minnormal' to the
533 // first-order method. Clip only a share that is negative BEYOND the numerical
534 // zero.
535 bool all_nonneg = true;
536 for (std::size_t j = 0; j < n; ++j)
537 all_nonneg = all_nonneg && (out.s[j] >= -lang::GlobalConstants::Zero);
538 if (all_nonneg) {
539 for (std::size_t j = 0; j < n; ++j)
540 if (out.s[j] < 0.0) out.s[j] = 0.0;
541 return out;
542 }
543
544 // Outside the region where the expansion is valid for at least one
545 // coordinate: clip and renormalise the survivors so the shares still sum to
546 // one. With nothing left, fall back to the plug-in share.
547 std::vector<bool> act(n, false);
548 double tot = 0.0;
549 bool any_act = false;
550 for (std::size_t j = 0; j < n; ++j) {
551 act[j] = out.s[j] > 0.0;
552 if (act[j]) {
553 tot += out.s[j];
554 any_act = true;
555 }
556 }
557 if (!any_act) {
558 for (std::size_t j = 0; j < n; ++j) out.s[j] = u[j] / v;
559 if (want_jac) plugin_jac(out.ds);
560 return out;
561 }
562 std::vector<double> snew(n, 0.0);
563 if (want_jac) {
564 std::vector<double> dT(n, 0.0);
565 for (std::size_t m = 0; m < n; ++m)
566 for (std::size_t j = 0; j < n; ++j)
567 if (act[j]) dT[m] += out.ds(j, m);
568 Matrix<double> dsnew(n, n, 0.0);
569 for (std::size_t j = 0; j < n; ++j) {
570 if (!act[j]) continue;
571 for (std::size_t m = 0; m < n; ++m)
572 dsnew(j, m) = out.ds(j, m) / tot - (out.s[j] / (tot * tot)) * dT[m];
573 }
574 out.ds = dsnew;
575 }
576 for (std::size_t j = 0; j < n; ++j)
577 if (act[j]) snew[j] = out.s[j] / tot;
578 out.s = snew;
579 return out;
580}
581
582/**
583 * Port of `fluid_gps_share.m`: the expected capacity share of a GPS station under
584 * a normal marginal, and its Jacobian.
585 *
586 * GPS divides the server by WEIGHT among the BACKLOGGED classes and then equally
587 * among that class's own jobs, so the share is a function of the backlog
588 * INDICATOR and not of the populations. The closure is an exact ENUMERATION of
589 * the 2^K patterns rather than an expansion: the share is piecewise constant over
590 * them, so once the pattern probabilities are given there is no truncation error
591 * left. Those probabilities come from the marginals with the continuity
592 * correction P(N_k > 1/2), multiplied as if the backlogs were independent -- the
593 * one approximation here, and not an innocuous one, since a closed network
594 * correlates the station coordinates negatively through population conservation.
595 *
596 * The shares sum to 1 - P(station empty), not to 1. That is how the idle server
597 * is represented: GPS is single-server, the indicator plays the role min(n,c)
598 * plays at a PS station, and the caller applies no separate capacity term.
599 */
600inline ShareValue fluid_gps_share(const std::vector<double>& xk, const std::vector<double>& wk_in,
601 const std::vector<double>& vk, bool want_jac) {
602 const std::size_t K = xk.size();
603 ShareValue out;
604 out.s.assign(K, 0.0);
605 if (want_jac) out.ds = Matrix<double>(K, K, 0.0);
606
607 // The enumeration is 2^K in the classes AT ONE STATION, which is small in
608 // every practical model; refuse rather than crawl.
609 if (K > 12)
610 throw UnsupportedError(
611 "fluid_gps_share: GPS closes its capacity share by enumerating the 2^K backlog "
612 "patterns of a station, and this station carries " +
613 std::to_string(K) +
614 " classes. Above 12 the enumeration is no longer tractable; use method 'closing' with a "
615 "DPS station instead");
616
617 double sw = 0.0;
618 for (std::size_t k = 0; k < K; ++k) sw += wk_in[k];
619 if (sw <= 0.0) return out;
620 std::vector<double> wk(K, 0.0);
621 for (std::size_t k = 0; k < K; ++k) wk[k] = wk_in[k] / sw;
622
623 // P(class k backlogged) = P(N_k >= 1) for an INTEGER population, so the
624 // normal approximation takes the continuity correction P(N_k > 1/2).
625 // Thresholding at 1 instead understates the backlog probability, and at
626 // sigma = 0 it makes the share vanish for every class with x_k < 1, which
627 // stalls the server completely and is an ABSORBING state for the ODE. The
628 // sigma = 0 fallback is the fluid statement that positive mass is backlogged.
629 std::vector<double> p(K, 0.0), dp(K, 0.0);
630 for (std::size_t k = 0; k < K; ++k) {
631 if (vk[k] > 0.0) {
632 const double sd = std::sqrt(vk[k]);
633 const double z = (xk[k] - 0.5) / sd;
634 p[k] = closure_normcdf(z);
635 dp[k] = closure_normpdf(z) / sd;
636 } else {
637 p[k] = (xk[k] > 0.0) ? 1.0 : 0.0;
638 dp[k] = 0.0;
639 }
640 }
641
642 Matrix<double> dsdp(K, K, 0.0);
643 const unsigned long long masks = 1ULL << K;
644 for (unsigned long long mask = 1; mask < masks; ++mask) {
645 double W = 0.0;
646 for (std::size_t k = 0; k < K; ++k)
647 if (mask & (1ULL << k)) W += wk[k];
648 if (W <= 0.0) continue; // every backlogged class here carries zero weight
649 std::vector<double> q(K, 0.0);
650 double prodq = 1.0;
651 for (std::size_t k = 0; k < K; ++k) {
652 q[k] = (mask & (1ULL << k)) ? p[k] : (1.0 - p[k]);
653 prodq *= q[k];
654 }
655 for (std::size_t k = 0; k < K; ++k)
656 if (mask & (1ULL << k)) out.s[k] += prodq * wk[k] / W;
657 if (!want_jac) continue;
658 for (std::size_t m = 0; m < K; ++m) {
659 double prodm = 1.0; // the product over j != m
660 for (std::size_t j = 0; j < K; ++j)
661 if (j != m) prodm *= q[j];
662 const double sgn = (mask & (1ULL << m)) ? 1.0 : -1.0;
663 for (std::size_t k = 0; k < K; ++k)
664 if (mask & (1ULL << k)) dsdp(k, m) += sgn * prodm * wk[k] / W;
665 }
666 }
667
668 if (want_jac)
669 for (std::size_t k = 0; k < K; ++k)
670 for (std::size_t m = 0; m < K; ++m) out.ds(k, m) = dsdp(k, m) * dp[m];
671 return out;
672}
673
674} // namespace fluid
675} // namespace line
676
677#endif // LINE_SOLVERS_FLUID_FLUID_CLOSURES_H
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
UnsupportedError(const std::string &what)
Definition error.h:51
The exception types the port throws.
Enumerations and the minimal distribution descriptor shared by the model layer of the C++ port.
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,...
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,...
ClosureValue fluid_min_closure(double n, double c, double s2, double vc=0.0, double cov_nc=0.0)
Port of fluid_min_closure.m: E[min(X,Y)] for jointly normal X, Y, and its derivative with respect to ...
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...
ExpansionWeight fluid_expansion_weight(double ratio)
Port of local_expansion_weight in fluid_share_closure.m.
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,...
void fluid_project_rate(std::vector< double > &r, const std::vector< double > &xb, bool capped, double tot)
Port of local_project_rate in ode_rates_closing_factors.m: project a jointly closed per-coordinate se...
double closure_normpdf(double z)
The standard normal pdf.
double closure_normcdf(double z)
The standard normal cdf, without a statistics library.
A closure's value and its first two derivatives with respect to the first mean.
How much of the second-order correction the series admits, and d tau / d ratio.
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