LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
aph_fit.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_API_MAM_APH_FIT_H
6#define LINE_API_MAM_APH_FIT_H
7
8/**
9 * @file
10 * @ingroup api_mam
11 * Minimal-order acyclic phase-type fit of the first three moments
12 * (matlab/lib/kpctoolbox/aph/aph_fit.m).
13 *
14 * Implements A. Bobbio, A. Horvath, M. Telek, "Matching three moments with
15 * minimal acyclic phase type distributions", Stochastic Models 21:303-326,
16 * 2005. The routine searches the smallest order n <= nmax whose normalized
17 * moment region contains (n2, n3) = (e2/e1^2, e3/(e1 e2)) and then evaluates
18 * one of the paper's two closed forms for the canonical APH(n).
19 *
20 * Gated on transcendental arithmetic: both cases take square roots of moment
21 * discriminants, the order search takes square roots of the region bounds,
22 * and the second case additionally takes cube roots (of complex arguments).
23 * None of these has an exact rational counterpart.
24 *
25 * The second case follows MATLAB and the JAR (Aph_fit.java) in evaluating the
26 * chain K9..K22 in complex arithmetic: several of those radicands are negative
27 * for feasible moment sets and the imaginary parts cancel in f, so real
28 * arithmetic would produce NaN. Only the real part of f is used, as in both
29 * references.
30 *
31 * Reference defect carried over deliberately: the third branch of the f
32 * selection reads `n3 == 2*n2/2`, i.e. n3 == n2, where the surrounding
33 * branches make `3*n2/2` the intended boundary. MATLAB and the JAR agree on
34 * the text, so the port reproduces it rather than silently "fixing" the
35 * reference; the branch is unreachable in practice because exact equality of
36 * two computed doubles is required to enter it.
37 */
38
39#include <cstddef>
40#include <limits>
41#include <vector>
42
46#include "line/num/number.h"
47#include "line/util/error.h"
48#include "line/util/matrix.h"
49
50namespace line {
51namespace mam {
52
53/** Result of aph_fit. */
54template <class T>
56 Map<T> aph; ///< the fitted APH as a MAP pair (D0, D1)
57 bool isexact; ///< false when the moment set had to be relaxed
58 unsigned order; ///< number of phases actually used
59};
60
61namespace fitdetail {
62
63/** Canonical bidiagonal APH assembled from (alpha, T) as in aph_fit.m. */
64template <class T>
65Map<T> aph_canonical(const Matrix<T>& Tm, const std::vector<T>& alpha, const T& e1) {
66 const std::size_t n = Tm.rows();
67 Map<T> m;
68 m.D0 = Tm;
70 for (std::size_t i = 0; i < n; ++i) {
72 for (std::size_t k = 0; k < n; ++k) rs -= Tm(i, k);
73 for (std::size_t j = 0; j < n; ++j) m.D1(i, j) = rs * alpha[j];
74 }
75 return map_scale(map_normalize(m), e1);
76}
77
78/**
79 * Slack for the order search's boundary tests, derived from the number type.
80 *
81 * WHY THIS EXISTS. The Bobbio-Horvath-Telek feasibility conditions compare the
82 * normalized third moment against bounds ln(n) and un(n) that a moment set can
83 * ATTAIN exactly: the moments of an Erlang, and of anything else sitting on the
84 * APH(n) boundary, make n3 equal to a bound rather than merely close to it.
85 * Both sides of such a comparison are computed with rounding, so their
86 * difference is pure noise of the order of the working epsilon, with an
87 * arbitrary sign, and an exact >= or <= then decides feasibility by coin toss.
88 *
89 * Measured on (e1, e2, e3) = (1/3, 8/45, 2/15), where n3 = 9/4 and un(2) = 9/4
90 * exactly: un - n3 came out as +8.88e-16 at double, -1.07e-50 at Real50 and
91 * exactly 0 at Real100, so the fitted order was 2, then 3, then 2 again. The
92 * flip is not monotone in the precision because the residual is noise, not a
93 * bias, which is precisely why a frozen constant cannot fix it and a
94 * precision-relative one can.
95 *
96 * The slack is zero in an exact field, where the comparison is decidable and
97 * the boundary is attained exactly.
98 */
99template <class T>
100T aph_boundary_slack() {
101 if constexpr (num_traits<T>::is_exact) {
102 return num_traits<T>::from_int(0);
103 } else {
104 return T(std::numeric_limits<T>::epsilon() * num_traits<T>::from_int(16));
105 }
106}
107
108/** a >= b, admitting an attained boundary within the slack. */
109template <class T>
110bool ge_slack(const T& a, const T& b, const T& slack) {
111 const T one = num_traits<T>::from_int(1);
112 const T ab = num_abs(b);
113 const T scale = ab > one ? ab : one;
114 return a >= T(b - slack * scale);
115}
116
117/** a <= b, admitting an attained boundary within the slack. */
118template <class T>
119bool le_slack(const T& a, const T& b, const T& slack) {
120 const T one = num_traits<T>::from_int(1);
121 const T ab = num_abs(b);
122 const T scale = ab > one ? ab : one;
123 return a <= T(b + slack * scale);
124}
125
126/**
127 * Read a radicand that is provably nonnegative where it is consumed.
128 *
129 * SAME PHENOMENON AS aph_boundary_slack, ONE LEVEL DOWN. The bound formulas
130 * below do not only COMPARE against an attained boundary, they take square
131 * roots of quantities that VANISH on it: at n2 = (n+1)/n the radicand of un is
132 * exactly 1 + n(n2-2)/(n-1) = 0, and case 1's discriminant is 0 on the same
133 * moment sets. Each is nonnegative on the whole region where its value is used
134 * -- that is a property of the Bobbio-Horvath-Telek feasibility region, not an
135 * empirical observation -- so a negative value within rounding noise of zero is
136 * noise and nothing else, and reading it as zero is the only reading consistent
137 * with the mathematics at every precision.
138 *
139 * Measured on the Erlang(2) moment set (1/3, 1/6, 1/9), where n2 = 3/2 sits ON
140 * the n2 >= (n+1)/n boundary: the un radicand is exactly 0 at double, -1.07e-50
141 * at Real50 and exactly 0 at Real100. Untreated, Real50 replaced the true bound
142 * un = 2 with the placeholder 0, the n3 <= un test failed and the fit returned
143 * order 3 for a distribution that IS an APH(2). The sign of that residual is
144 * noise, so, as with the comparisons, only a precision-derived rule fixes it.
145 *
146 * `scale` is the magnitude of the terms whose cancellation produced x. A
147 * negative value beyond the slack is NOT noise and is returned unchanged, so
148 * the caller's own infeasibility handling still sees it.
149 */
150template <class T>
151T read_nonneg_radicand(const T& x, const T& slack, const T& scale) {
152 const T zero = num_traits<T>::from_int(0);
153 if (x >= zero) return x;
154 const T one = num_traits<T>::from_int(1);
155 const T as = num_abs(scale);
156 const T s = as > one ? as : one;
157 return x >= T(-slack * s) ? zero : x;
158}
159
160} // namespace fitdetail
161
162/**
163 * Fit an APH(n) with n <= nmax to the raw moments e1, e2, e3.
164 *
165 * tol is the tolerance used only for the exponential degeneracy screen
166 * (scv == 1 with the matching third moment), where the general APH(2)
167 * formulas divide by zero.
168 *
169 * The order search's own boundary tests carry a separate, precision-derived
170 * slack (fitdetail::aph_boundary_slack) so that a moment set lying ON an
171 * APH(n) bound is accepted at every arithmetic rather than at whichever ones
172 * happen to round the residual the right way. See that function for the
173 * measurement that motivates it.
174 */
175template <class T>
176AphFitResult<T> aph_fit(const T& e1, const T& e2, const T& e3, unsigned nmax, const T& tol) {
177 static_assert(num_traits<T>::has_transcendental, "aph_fit requires transcendental arithmetic");
178 using fitdetail::Cplx;
179 using fitdetail::cplx_pow_real;
180 using fitdetail::cplx_sqrt;
181 using fitdetail::num_sqrt;
182 using fitdetail::pw;
183
184 const T zero = num_traits<T>::from_int(0);
185 const T one = num_traits<T>::from_int(1);
186 const T two = num_traits<T>::from_int(2);
187 const T three = num_traits<T>::from_int(3);
188 const T six = num_traits<T>::from_int(6);
189
190 if (e1 <= zero) throw InputError("aph_fit: the first moment must be positive");
191 if (nmax < 2) throw InputError("aph_fit: nmax must be at least 2");
192
193 AphFitResult<T> res;
194 res.isexact = true;
195 const T slack = fitdetail::aph_boundary_slack<T>();
196
197 const T scv = (e2 - e1 * e1) / (e1 * e1);
198 if (num_abs(T(scv - one)) < tol && num_abs(T(e3 - six * pw(e1, 3))) < tol) {
199 res.aph = map_exponential_mean(e1);
200 res.order = 1;
201 return res;
202 }
203
204 T n2 = e2 / (e1 * e1);
205 T n3 = e3 / e1 / e2;
206
207 bool n2_feas = false, n3_ubfeas = false, n3_lbfeas = false;
208 unsigned n = 1;
209 T un = zero;
210 T un_1 = zero;
211 while ((!n2_feas || !n3_lbfeas || !n3_ubfeas) && n < nmax) {
212 ++n;
213 const T nn = num_traits<T>::from_int(static_cast<long>(n));
214 un_1 = un;
215 // bound radicand boundary rationale: see _kb/03-api-layer.md (cpp port notes: mam)
216 const T uarg = fitdetail::read_nonneg_radicand(
217 T(one + nn * (n2 - two) / (nn - one)), slack, T(nn * (n2 - two) / (nn - one)));
218 un = uarg < zero ? zero
219 : (one / (nn * nn * n2)) *
220 (two * (nn - two) * (nn * n2 - nn - one) * num_sqrt(uarg) +
221 (nn + two) * (three * nn * n2 - two * nn - two));
222
223 if (fitdetail::ge_slack(n2, T((nn + one) / nn), slack) &&
224 fitdetail::le_slack(n2, T((nn + num_traits<T>::from_int(4)) / (nn + one)), slack)) {
225 n2_feas = true;
226 // On this interval 4(n+1) - 3 n n2 >= (n-2)^2/(n+1) >= 0, so both
227 // radicands below are real.
228 const T pn = ((nn + one) * (n2 - two) / (three * n2 * (nn - one))) *
229 (-two * num_sqrt(T(nn + one)) /
230 num_sqrt(T(num_traits<T>::from_int(4) * (nn + one) - three * nn * n2)) -
231 one);
232 // boundary rationale: see _kb/03-api-layer.md (cpp port notes: mam)
233 const T arad = fitdetail::read_nonneg_radicand(
234 T(pn * pn + pn * nn * (n2 - two) / (nn - one)), slack, T(pn * pn));
235 const T an = (n2 - two) / (pn * (one - n2) + num_sqrt(arad));
236 const T ln = ((three + an) * (nn - one) + two * an) / ((nn - one) * (one + an * pn)) -
237 (two * an * (nn + one)) /
238 (two * (nn - one) + an * pn * (nn * an + two * nn - two));
239 if (fitdetail::ge_slack(n3, ln, slack)) n3_lbfeas = true;
240 } else if (fitdetail::ge_slack(n2, T((nn + num_traits<T>::from_int(4)) / (nn + one)),
241 slack)) {
242 n2_feas = true;
243 if (fitdetail::ge_slack(n3, T(n2 * (nn + one) / nn), slack)) n3_lbfeas = true;
244 }
245 if (fitdetail::ge_slack(n2, T((nn + one) / nn), slack) &&
246 fitdetail::le_slack(n2, T(nn / (nn - one)), slack)) {
247 n2_feas = true;
248 if (fitdetail::le_slack(n3, un, slack)) n3_ubfeas = true;
249 } else if (fitdetail::ge_slack(n2, T(nn / (nn - one)), slack)) {
250 n2_feas = true;
251 n3_ubfeas = true; // MATLAB: n3 < Inf, always true for a finite n3
252 }
253 }
254
255 if (!n2_feas || !n3_lbfeas || !n3_ubfeas || n == nmax) {
256 const T nn = num_traits<T>::from_int(static_cast<long>(n));
257 n2 = (nn + one) / nn;
258 n3 = two * n2 - one;
259 res.isexact = false;
260 }
261
262 const T nn = num_traits<T>::from_int(static_cast<long>(n));
263 res.order = n;
264
265 std::vector<T> alpha(n, zero);
266 Matrix<T> Tm(n, n, zero);
267 const T lambda = one;
268
269 if (n2 <= nn / (nn - one) || n3 <= two * n2 - one) {
270 // discriminant boundary rationale: see _kb/03-api-layer.md (cpp port notes: mam)
271 const T rad = fitdetail::read_nonneg_radicand(
272 T(num_traits<T>::from_int(12) * n2 * n2 * (nn + one) +
273 num_traits<T>::from_int(16) * n3 * (nn + one) +
274 n2 * (nn * (n3 - num_traits<T>::from_int(15)) * (n3 + one) -
275 num_traits<T>::from_int(8) * (n3 + three))),
276 slack, T(num_traits<T>::from_int(12) * n2 * n2 * (nn + one)));
277 if (rad < zero) throw NumericError("aph_fit: negative discriminant in case 1");
278 const T b = two * (num_traits<T>::from_int(4) - nn * (three * n2 - num_traits<T>::from_int(4))) /
279 (n2 * (num_traits<T>::from_int(4) + nn - nn * n3) + num_sqrt(T(nn * n2)) * num_sqrt(rad));
280 const T a = (b * n2 - two) * (nn - one) * b / ((b - one) * nn);
281 if (a == zero) throw NumericError("aph_fit: degenerate case-1 parameter");
282 const T p = (b - one) / a;
283 const T mu = lambda * (nn - one) / a;
284 alpha[0] = p;
285 alpha[n - 1] = one - p;
286 for (std::size_t i = 0; i < n; ++i) Tm(i, i) = -mu;
287 for (std::size_t i = 0; i + 1 < n; ++i) Tm(i, i + 1) = mu;
288 Tm(n - 1, n - 1) = -lambda;
289 } else if (n2 > nn / (nn - one) && n3 > un_1) {
290 // case 2 of 2: the Bobbio-Horvath-Telek chain, evaluated in C
291 const T K1 = nn - one;
292 const T K2 = nn - two;
293 const T K3 = three * n2 - two * n3;
294 const T K4 = n3 - three;
295 const T K5 = nn - n2;
296 const T K6 = one + n2 - n3;
297 const T K7 = nn + n2 - nn * n2;
298 const T K8 = three + three * n2 * n2 + n3 - three * n2 * n3;
299 if (K3 == zero || K4 == zero) throw NumericError("aph_fit: degenerate case-2 parameter");
300
301 const T k9a = num_traits<T>::from_int(4) * K1 * pw(K5, 3) +
302 K1 * K1 * K2 * K4 * K4 * nn * n2 * n2 +
303 num_traits<T>::from_int(4) * K2 * nn * n2 *
304 (K4 * nn * nn - three * K6 * n2 + K8 * nn);
305 const Cplx<T> K9inner =
306 cplx_sqrt(Cplx<T>(T(-num_traits<T>::from_int(16) * K1 * K1 * pw(K7, 6) + k9a * k9a)));
307 const Cplx<T> K9 =
308 (K9inner + Cplx<T>(T(num_traits<T>::from_int(4) * K2 * K2 * K3 * nn * nn * n2 +
309 K1 * K1 * K2 * K4 * K4 * nn * n2 * n2 +
310 num_traits<T>::from_int(4) * K1 * K5 *
311 (K5 * K5 - three * K2 * K6 * nn * n2)))) *
312 T(num_traits<T>::from_int(108) * K1 * K1);
313 const T K10 = K4 * K4 / (num_traits<T>::from_int(4) * K3 * K3) - K5 / (K1 * K3 * n2);
314 const T third = one / three;
315 const Cplx<T> K9cbrt = cplx_pow_real(K9, third);
316 const T c2 = fitdetail::num_exp(T(third * fitdetail::num_log(two))); // 2^(1/3)
317 const T c7 = fitdetail::num_exp(T((num_traits<T>::from_int(7) / three) * fitdetail::num_log(two)));
318 const Cplx<T> K11 =
319 fitdetail::cplx_inv(K9cbrt) * T(c2 * (three * K5 * K5 + K2 * (K3 + two * K4) * nn * n2) / (K3 * n2));
320 const Cplx<T> K12 = K9cbrt / T(three * c7 * K1 * K1 * K3 * n2);
321 const Cplx<T> K13 = cplx_sqrt(K11 + K12 + Cplx<T>(K10));
322 const T K20 = six * K1 * K3 * K4 * K5 + num_traits<T>::from_int(4) * K2 * K3 * K3 * nn -
323 K1 * K1 * pw(K4, 3) * n2;
324 const Cplx<T> K14 =
325 fitdetail::cplx_inv(K13) * T(K20 / (num_traits<T>::from_int(4) * K1 * K1 * pw(K3, 3) * n2));
326 const T K15 = -K4 / (two * K3);
327 const Cplx<T> twoK10(T(two * K10));
328 const Cplx<T> K16 = cplx_sqrt(twoK10 - K11 - K12 - K14);
329 const Cplx<T> K17 = cplx_sqrt(twoK10 - K11 - K12 + K14);
330 const T k18a = num_traits<T>::from_int(4) * pw(K5, 3) +
331 num_traits<T>::from_int(4) * K2 * K4 * K5 * nn * n2 +
332 K1 * K2 * K4 * K4 * nn * n2 * n2;
333 const Cplx<T> K18 =
334 -cplx_sqrt(Cplx<T>(T(num_traits<T>::from_int(81) * k18a * k18a -
336 pw(T(three * K5 * K5 + two * K2 * K4 * nn * n2), 3)))) +
337 Cplx<T>(T(num_traits<T>::from_int(36) * pw(K5, 3) +
338 num_traits<T>::from_int(36) * K2 * K4 * K5 * nn * n2 +
339 num_traits<T>::from_int(9) * K1 * K2 * K4 * K4 * nn * n2 * n2));
340 const Cplx<T> K18cbrt = cplx_pow_real(K18, third);
341 const T c23 = fitdetail::num_exp(T((two / three) * fitdetail::num_log(two))); // 2^(2/3)
342 const T c31 = fitdetail::num_exp(T(third * fitdetail::num_log(three))); // 3^(1/3)
343 const T c62 = fitdetail::num_exp(T((two / three) * fitdetail::num_log(six))); // 6^(2/3)
344 const Cplx<T> K19 =
345 Cplx<T>(T(-K5 / (K1 * K4 * n2))) -
346 fitdetail::cplx_inv(K18cbrt) *
347 T(c23 * (three * K5 * K5 + two * K2 * K4 * nn * n2) / (c31 * K1 * K4 * n2)) -
348 K18cbrt / T(c62 * K1 * K4 * n2);
349 const Cplx<T> K21 = K11 + K12 + Cplx<T>(T(K5 / (two * nn * K1 * K3)));
350 const Cplx<T> K22 = cplx_sqrt(
351 cplx_sqrt(K21 * K21 * num_traits<T>::from_int(4) - Cplx<T>(T(nn * K2 / (n2 * K1 * K1 * K3)))) +
352 Cplx<T>(T(three * K4 * K4 / (num_traits<T>::from_int(4) * K3 * K3) - three * K5 / (K1 * K3 * n2))));
353
354 Cplx<T> fC;
355 bool picked = false;
356 if (n3 > un_1 && n3 < three * n2 / two) {
357 fC = K13 + Cplx<T>(K15) - K17;
358 picked = true;
359 } else if (n3 == two * n2 / two) { // reference text; see header comment
360 fC = K19;
361 picked = true;
362 } else if (n3 > three * n2 / two && K20 > zero) {
363 fC = -K13 + Cplx<T>(K15) + K16;
364 picked = true;
365 } else if (K20 == zero) {
366 fC = K22 + Cplx<T>(K15);
367 picked = true;
368 } else if (K20 < zero) {
369 fC = K13 + Cplx<T>(K15) + K17;
370 picked = true;
371 }
372 if (!picked) throw NumericError("aph_fit: no branch of the case-2 selection applies");
373 const T f = fC.re;
374 const T denom = (nn - one) * (n2 * f * f - two * f + two) - nn;
375 if (denom == zero) throw NumericError("aph_fit: degenerate case-2 denominator");
376 const T a = two * (f - one) * (nn - one) / denom;
377 if (a == zero) throw NumericError("aph_fit: degenerate case-2 parameter");
378 const T p = (f - one) * a;
379 const T mu = lambda * (nn - one) / a;
380 alpha[0] = p;
381 alpha[1] = one - p;
382 for (std::size_t i = 0; i < n; ++i) Tm(i, i) = -mu;
383 for (std::size_t i = 0; i + 1 < n; ++i) Tm(i, i + 1) = mu;
384 Tm(0, 0) = -lambda;
385 Tm(0, 1) = lambda;
386 } else {
387 throw NumericError("aph_fit: the moment set cannot be matched with an APH distribution");
388 }
389
390 res.aph = fitdetail::aph_canonical(Tm, alpha, e1);
391 return res;
392}
393
394/** aph_fit with the MATLAB defaults nmax = 10 and a 1e-12 degeneracy tolerance. */
395template <class T>
396AphFitResult<T> aph_fit(const T& e1, const T& e2, const T& e3) {
397 return aph_fit(e1, e2, e3, 10u, T(num_traits<T>::from_double(1e-12)));
398}
399
400/** aph_fit with an explicit order cap and the default degeneracy tolerance. */
401template <class T>
402AphFitResult<T> aph_fit(const T& e1, const T& e2, const T& e3, unsigned nmax) {
403 return aph_fit(e1, e2, e3, nmax, T(num_traits<T>::from_double(1e-12)));
404}
405
406} // namespace mam
407} // namespace line
408
409#endif // LINE_API_MAM_APH_FIT_H
InputError(const std::string &what)
Definition error.h:39
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
The exception types the port throws.
Scalar helpers shared by the MAP/PH moment-matching headers.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
MAP constructors and structural transformations.
Dense matrix and non-owning view.
AphFitResult< T > aph_fit(const T &e1, const T &e2, const T &e3, unsigned nmax, const T &tol)
Fit an APH(n) with n <= nmax to the raw moments e1, e2, e3.
Definition aph_fit.h:176
Map< T > map_exponential_mean(const T &mean)
Poisson process with the given mean inter-arrival time (map_exponential.m).
Map< T > map_scale(const Map< T > &in, const T &new_mean)
Rescale time so that the mean inter-arrival time becomes new_mean.
Map< T > map_normalize(const Map< T > &in)
Clamp negative off-diagonal entries of D0 and negative entries of D1 to zero, then rebuild the diagon...
T num_abs(const T &v)
Definition number.h:172
Number-type abstraction for the templated API port.
Result of aph_fit.
Definition aph_fit.h:55
bool isexact
false when the moment set had to be relaxed
Definition aph_fit.h:57
Map< T > aph
the fitted APH as a MAP pair (D0, D1)
Definition aph_fit.h:56
unsigned order
number of phases actually used
Definition aph_fit.h:58
A MAP as the pair of matrices (D0, D1).
Definition map_moment.h:53
Matrix< T > D1
Definition map_moment.h:55
Matrix< T > D0
Definition map_moment.h:54