LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_mvn_rectangle.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_MVN_RECTANGLE_H
6#define LINE_SOLVERS_FLUID_FLUID_MVN_RECTANGLE_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `fluid_mvn_rectangle.m`: the rectangle probability
12 * P(a <= Y <= b) for Y ~ Normal(m, C), the cell integral behind `getProbAggr`
13 * under the moment-closure methods.
14 *
15 * The integral has no closed form beyond one dimension, so it is evaluated by
16 * the separation-of-variables transformation of Genz (1992): the Cholesky factor
17 * of C turns the rectangle into an iterated integral over the unit cube whose
18 * integrand is a product of normal-CDF differences, and the first coordinate is
19 * integrated exactly. The remaining cube is integrated with a DETERMINISTIC
20 * Richtmyer lattice rule, frac(k sqrt(p_j)) over the first primes, averaged with
21 * its antithetic reflection. Determinism is required here, not merely
22 * convenient: the four codebases must return the same number, and a randomized
23 * rule would make them agree only in distribution.
24 *
25 * C may be SINGULAR, which is the common case: a closed population fixes the sum
26 * of the station coordinates, so the covariance of a station holding a whole
27 * class is rank deficient. A coordinate whose CONDITIONAL variance vanishes is
28 * not integrated; it is a hard constraint, contributing 1 when the conditional
29 * mean falls inside its interval and 0 otherwise.
30 */
31
32#include <algorithm>
33#include <cmath>
34#include <cstddef>
35#include <limits>
36#include <vector>
37
38#include "line/util/error.h"
39#include "line/util/matrix.h"
40
41namespace line {
42namespace fluid {
43namespace detail {
44
45/**
46 * The first 100 primes, listed rather than sieved so that the MATLAB, Java and
47 * Python twins generate the identical lattice.
48 */
49inline const std::vector<int>& mvn_primes() {
50 static const std::vector<int> p = {
51 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71,
52 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173,
53 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281,
54 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409,
55 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541};
56 return p;
57}
58
59/** The standard normal cdf. */
60inline double mvn_phi(double x) {
61 if (std::isinf(x)) return x > 0.0 ? 1.0 : 0.0;
62 return 0.5 * std::erfc(-x / std::sqrt(2.0));
63}
64
65/**
66 * The standard normal quantile, MATLAB's `sqrt(2)*erfinv(2u-1)`.
67 *
68 * Acklam's rational approximation (relative error 1.15e-9) refined by one
69 * Halley step on `mvn_phi`, which takes it to double precision. C++ has no
70 * `erfinv`, and bisection would be too slow here: this is called once per
71 * coordinate per lattice point.
72 */
73inline double mvn_phi_inv(double u) {
74 static const double a[6] = {-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
75 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00};
76 static const double b[5] = {-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
77 6.680131188771972e+01, -1.328068155288572e+01};
78 static const double c[6] = {-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
79 -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00};
80 static const double d[4] = {7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
81 3.754408661907416e+00};
82 const double plow = 0.02425, phigh = 1.0 - plow;
83 double x;
84 if (u < plow) {
85 const double q = std::sqrt(-2.0 * std::log(u));
86 x = (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
87 ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
88 } else if (u > phigh) {
89 const double q = std::sqrt(-2.0 * std::log(1.0 - u));
90 x = -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
91 ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0);
92 } else {
93 const double q = u - 0.5, r = q * q;
94 x = (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
95 (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0);
96 }
97 // Halley refinement
98 const double e = mvn_phi(x) - u;
99 const double pdf = std::exp(-0.5 * x * x) / std::sqrt(2.0 * 3.14159265358979323846);
100 if (pdf > 0.0) {
101 const double t = e / pdf;
102 x -= t / (1.0 + 0.5 * x * t);
103 }
104 return x;
105}
106
107/**
108 * Cholesky factor of a symmetric positive SEMI-definite matrix. A vanishing
109 * pivot leaves a zero row and column, which the caller reads as a deterministic
110 * coordinate rather than as a failure.
111 */
112inline Matrix<double> mvn_chol_psd(const Matrix<double>& C, double dtol) {
113 const std::size_t d = C.rows();
114 Matrix<double> L(d, d, 0.0);
115 for (std::size_t i = 0; i < d; ++i) {
116 double v = C(i, i);
117 for (std::size_t j = 0; j < i; ++j) v -= L(i, j) * L(i, j);
118 if (v > dtol) {
119 L(i, i) = std::sqrt(v);
120 for (std::size_t r = i + 1; r < d; ++r) {
121 double s = C(r, i);
122 for (std::size_t j = 0; j < i; ++j) s -= L(r, j) * L(i, j);
123 L(r, i) = s / L(i, i);
124 }
125 } else {
126 L(i, i) = 0.0;
127 for (std::size_t r = i + 1; r < d; ++r) L(r, i) = 0.0;
128 }
129 }
130 return L;
131}
132
133/** One point of the transformed integrand, Genz's recursion over the coordinates. */
134inline double mvn_evaluate(const Matrix<double>& L, const std::vector<double>& al,
135 const std::vector<double>& bu, const std::vector<double>& w,
136 std::vector<double>& y, std::size_t last_int, bool has_int, double ctol,
137 bool antithetic) {
138 const std::size_t d = al.size();
139 double f = 1.0;
140 std::size_t kw = 0;
141 for (std::size_t i = 0; i < d; ++i) {
142 double s = 0.0;
143 for (std::size_t j = 0; j < i; ++j) s += L(i, j) * y[j];
144 if (L(i, i) > 0.0) {
145 const double dd = mvn_phi((al[i] - s) / L(i, i));
146 const double ee = mvn_phi((bu[i] - s) / L(i, i));
147 f *= std::max(0.0, ee - dd);
148 if (f == 0.0) return 0.0;
149 if (!(has_int && i == last_int)) {
150 const double wk = antithetic ? 1.0 - w[kw] : w[kw];
151 ++kw;
152 double u = dd + wk * (ee - dd);
153 // the inverse cdf is evaluated strictly inside the unit interval
154 u = std::min(std::max(u, 1e-15), 1.0 - 1e-15);
155 y[i] = mvn_phi_inv(u);
156 }
157 } else {
158 // zero conditional variance: the coordinate is pinned at s, so the
159 // cell is either met or not
160 if (s < al[i] - ctol || s > bu[i] + ctol) return 0.0;
161 y[i] = 0.0;
162 }
163 }
164 return f;
165}
166
167} // namespace detail
168
169/** Lattice points per antithetic pair. */
170const std::size_t FLUID_MVN_POINTS = 4096;
171
172/**
173 * P(a <= Y <= b) for Y ~ Normal(m, C).
174 *
175 * @param m mean vector
176 * @param C covariance, symmetric positive semi-definite
177 * @param a lower corner, -infinity allowed
178 * @param b upper corner, +infinity allowed
179 * @param npoints lattice points per antithetic pair
180 */
181inline double fluid_mvn_rectangle(const std::vector<double>& m, const Matrix<double>& C,
182 const std::vector<double>& a, const std::vector<double>& b,
183 std::size_t npoints = FLUID_MVN_POINTS) {
184 const std::size_t d = m.size();
185 if (d == 0) return 1.0;
186 std::vector<double> al(d), bu(d);
187 for (std::size_t i = 0; i < d; ++i) {
188 al[i] = a[i] - m[i];
189 bu[i] = b[i] - m[i];
190 if (bu[i] <= al[i]) return 0.0;
191 }
192
193 // scale-relative tolerances: dtol decides which coordinate carries noise,
194 // ctol whether a deterministic coordinate satisfies its constraint
195 double scale = 1.0;
196 for (std::size_t i = 0; i < d; ++i) scale = std::max(scale, std::fabs(C(i, i)));
197 const double dtol = 1e-12 * scale;
198 const double ctol = 1e-6 * std::sqrt(scale);
199
200 const Matrix<double> L = detail::mvn_chol_psd(C, dtol);
201 std::size_t n_int = 0, last_int = 0;
202 bool has_int = false;
203 for (std::size_t i = 0; i < d; ++i)
204 if (L(i, i) > 0.0) {
205 ++n_int;
206 last_int = i;
207 has_int = true;
208 }
209 const std::size_t nw = (n_int > 0) ? n_int - 1 : 0;
210 if (nw > detail::mvn_primes().size())
211 throw InputError(
212 "fluid_mvn_rectangle: the lattice rule carries generators for at most 100 integration "
213 "dimensions. Aggregate classes before evaluating the cell");
214
215 std::vector<double> alpha(nw);
216 for (std::size_t j = 0; j < nw; ++j) alpha[j] = std::sqrt(static_cast<double>(detail::mvn_primes()[j]));
217
218 const std::size_t npairs = (nw == 0) ? 1 : npoints;
219 const std::size_t n_eval = (nw == 0) ? 1 : 2 * npoints;
220 std::vector<double> w(nw), y(d, 0.0);
221 double acc = 0.0;
222 for (std::size_t k = 1; k <= npairs; ++k) {
223 for (std::size_t j = 0; j < nw; ++j) {
224 const double v = static_cast<double>(k) * alpha[j];
225 w[j] = v - std::floor(v);
226 }
227 acc += detail::mvn_evaluate(L, al, bu, w, y, last_int, has_int, ctol, false);
228 if (nw > 0) acc += detail::mvn_evaluate(L, al, bu, w, y, last_int, has_int, ctol, true);
229 }
230
231 const double p = acc / static_cast<double>(n_eval);
232 return std::min(std::max(p, 0.0), 1.0);
233}
234
235} // namespace fluid
236} // namespace line
237
238#endif // LINE_SOLVERS_FLUID_FLUID_MVN_RECTANGLE_H
InputError(const std::string &what)
Definition error.h:39
The exception types the port throws.
Dense matrix and non-owning view.
double fluid_mvn_rectangle(const std::vector< double > &m, const Matrix< double > &C, const std::vector< double > &a, const std::vector< double > &b, std::size_t npoints=FLUID_MVN_POINTS)
P(a <= Y <= b) for Y ~ Normal(m, C).
const std::size_t FLUID_MVN_POINTS
Lattice points per antithetic pair.