LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
ctmc_transient.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_MC_CTMC_TRANSIENT_H
6#define LINE_API_MC_CTMC_TRANSIENT_H
7
8/**
9 * @file
10 * @ingroup api_mc
11 * Transient distribution of a CTMC over a time interval, by integrating the
12 * forward equations d pi/dt = pi Q.
13 *
14 * Templated port of matlab/src/api/mc/ctmc_transient.m (and the richer
15 * kpctoolbox copy). With `useStiff` false, its default, the reference
16 * integrates with ode23, MATLAB's Bogacki-Shampine 3(2) pair with
17 * first-same-as-last, adaptive step and default tolerances RelTol 1e-3, AbsTol
18 * 1e-6; the same pair, the same step controller and the same defaults are
19 * reproduced here, so the returned time grid is the solver's own accepted steps
20 * rather than a fixed grid.
21 *
22 * `stiff` IS THE REFERENCE'S `useStiff`, and SolverCTMC's transient passes
23 * `options.stiff`, which defaults to TRUE: MATLAB then integrates with ode15s.
24 * That is not a tuning choice. A chain carrying an immediate transition (a
25 * Cache's hit/miss switch, rate GlobalConstants::Immediate = 1e8) has
26 * eigenvalues 1e8 apart, and the explicit pair's step is capped by the fastest
27 * one for the WHOLE horizon: ~4e7 accepted steps per unit time, so a horizon of
28 * 100 never finishes (and stores every step). The stiff branch uses the
29 * L-stable Rosenbrock integrator of `line/util/ode.h` with the exact Jacobian
30 * Q^T, at ode15s's default tolerances; it takes a handful of steps on the same
31 * chain. It conserves total probability exactly, since 1^T Q^T = 0 makes every
32 * stage increment sum to zero.
33 *
34 * MATLAB-VS-JAVA DISAGREEMENT. jline.api.mc.Ctmc_transient integrates the same
35 * equations with LSODA at absolute and relative tolerance 1e-6, so the two
36 * references return DIFFERENT time grids and solutions agreeing only to the
37 * looser of the two tolerances. This port follows MATLAB, the ground truth.
38 *
39 * GATED ON TRANSCENDENTAL ARITHMETIC. The step controller raises the error
40 * ratio to the power 1/3, and adaptive integration is an approximation with a
41 * tolerance rather than a finite exact computation: there is no exact value of
42 * pi(t) for a general rational Q, exp(Qt) not being a rational function of t.
43 * For a tightly controlled transient use ctmc_foxglynn or ctmc_uniformization
44 * at high precision, which bound their truncation error explicitly.
45 */
46
47#include <algorithm>
48#include <cmath>
49#include <cstddef>
50#include <limits>
51#include <vector>
52
53#include "line/num/number.h"
54#include "line/util/error.h"
55#include "line/util/matrix.h"
56#include "line/util/ode.h"
57
58namespace line {
59namespace mc {
60
61template <class T>
63 std::vector<T> t; ///< accepted time points, the first being t0
64 Matrix<T> pi; ///< one row per time point
65};
66
67namespace detail {
68
69/**
70 * Bogacki-Shampine 3(2) integrator with MATLAB's ode23 step control, for the
71 * autonomous system y' = f(y).
72 *
73 * @param f right-hand side, called as f(y, dy) and writing dy
74 * @param rtol relative tolerance (MATLAB RelTol, default 1e-3)
75 * @param atol absolute tolerance (MATLAB AbsTol, default 1e-6)
76 * @param t0 start of the integration horizon
77 * @param t1 end of the integration horizon
78 * @param y0 initial condition
79 * @param tout out: the accepted time points
80 * @param yout out: the solution at each accepted time point
81 */
82template <class T, class F>
83void ode23(F f, const T& t0, const T& t1, const std::vector<T>& y0, double rtol, double atol,
84 std::vector<T>& tout, std::vector<std::vector<T>>& yout) {
85 const std::size_t n = y0.size();
86 const T zero = num_traits<T>::from_int(0);
87 const T one = num_traits<T>::from_int(1);
88 const T third = num_traits<T>::from_rational(1, 3);
89 const T threshold = num_traits<T>::from_double(atol / rtol);
90 const T rtolT = num_traits<T>::from_double(rtol);
91 const T eps = std::numeric_limits<T>::epsilon();
92 const T span = t1 - t0;
93 if (!(span > zero)) throw InputError("ode23: the time interval must have positive length");
94 const T hmax = span / num_traits<T>::from_int(10);
95
96 std::vector<T> y = y0, f1(n), f2(n), f3(n), f4(n), ytmp(n), ynew(n);
97 f(y, f1);
98
99 // Initial step, selected as MATLAB's ode23 does.
100 using std::pow;
101 T h = hmax;
102 {
103 T rh = zero;
104 for (std::size_t i = 0; i < n; ++i) {
105 T d = num_abs(T(y[i]));
106 if (d < threshold) d = threshold;
107 const T v = num_abs(T(f1[i] / d));
108 if (v > rh) rh = v;
109 }
110 rh /= num_traits<T>::from_rational(4, 5) * pow(rtolT, third);
111 if (h * rh > one) h = one / rh;
112 }
113
114 T t = t0;
115 tout.assign(1, t0);
116 yout.assign(1, y0);
117 bool done = false;
118 while (!done) {
119 const T hmin = num_traits<T>::from_int(16) * eps * (num_abs(T(t)) + one);
120 if (h > hmax) h = hmax;
121 if (h < hmin) h = hmin;
122 if (num_traits<T>::from_rational(11, 10) * h >= t1 - t) {
123 h = t1 - t;
124 done = true;
125 }
126
127 bool nofailed = true;
128 T err = zero;
129 for (;;) {
130 for (std::size_t i = 0; i < n; ++i) ytmp[i] = y[i] + h * f1[i] / num_traits<T>::from_int(2);
131 f(ytmp, f2);
132 for (std::size_t i = 0; i < n; ++i)
133 ytmp[i] = y[i] + h * num_traits<T>::from_rational(3, 4) * f2[i];
134 f(ytmp, f3);
135 for (std::size_t i = 0; i < n; ++i)
136 ynew[i] = y[i] + h *
137 (num_traits<T>::from_int(2) * f1[i] + num_traits<T>::from_int(3) * f2[i] +
138 num_traits<T>::from_int(4) * f3[i]) /
140 f(ynew, f4);
141
142 err = zero;
143 for (std::size_t i = 0; i < n; ++i) {
144 T d = num_abs(T(y[i]));
145 const T dn = num_abs(T(ynew[i]));
146 if (dn > d) d = dn;
147 if (d < threshold) d = threshold;
148 const T e = (num_traits<T>::from_int(-5) * f1[i] + num_traits<T>::from_int(6) * f2[i] +
149 num_traits<T>::from_int(8) * f3[i] - num_traits<T>::from_int(9) * f4[i]) /
151 const T v = num_abs(T(e / d));
152 if (v > err) err = v;
153 }
154 err *= num_abs(T(h));
155
156 if (!(err > rtolT)) break;
157 if (h <= hmin)
158 throw NumericError("ode23: step size underflow, the system is too stiff for ode23");
159 T fac = num_traits<T>::from_rational(4, 5) * pow(T(rtolT / err), third);
161 h *= fac;
162 if (h < hmin) h = hmin;
163 nofailed = false;
164 done = false;
165 }
166
167 t += h;
168 y = ynew;
169 f1 = f4; // first-same-as-last
170 tout.push_back(t);
171 yout.push_back(y);
172 if (done) break;
173 // A step that was accepted without a prior rejection may grow, by at
174 // most a factor of five; one that failed keeps its reduced size.
175 if (nofailed) {
176 if (err == zero) {
178 } else {
179 const T temp = num_traits<T>::from_rational(5, 4) * pow(T(err / rtolT), third);
180 if (temp > num_traits<T>::from_rational(1, 5))
181 h /= temp;
182 else
184 }
185 }
186 }
187}
188
189} // namespace detail
190
191/**
192 * @brief Transient distribution of a CTMC over a time interval, by
193 * integrating the forward equations d pi/dt = pi Q. Templated port of
194 * matlab/src/api/mc/ctmc_transient.m (and the richer kpctoolbox copy).
195 *
196 * @param Q generator
197 * @param pi0 initial distribution (row vector)
198 * @param t0 initial time
199 * @param t1 final time
200 * @param rtol relative tolerance of the integrator (MATLAB RelTol, 1e-3)
201 * @param atol absolute tolerance of the integrator (MATLAB AbsTol, 1e-6)
202 * @param stiff the reference's `useStiff` (default false): true integrates with
203 * the stiff Rosenbrock method where MATLAB uses ode15s
204 */
205template <class T>
206TransientResult<T> ctmc_transient(const Matrix<T>& Q, const std::vector<T>& pi0, const T& t0, const T& t1,
207 double rtol = 1e-3, double atol = 1e-6, bool stiff = false) {
209 "ctmc_transient requires transcendental arithmetic: the ode23 step controller "
210 "raises the error ratio to the power 1/3, and the result is an approximation of "
211 "pi0 exp(Qt) governed by a tolerance rather than an exact quantity");
212 const std::size_t n = Q.rows();
213 if (Q.cols() != n) throw InputError("ctmc_transient: generator is not square");
214 if (pi0.size() != n) throw InputError("ctmc_transient: pi0 has the wrong length");
215
216 std::vector<T> tv;
217 std::vector<std::vector<T>> yv;
218 if (stiff) {
219 // y' = y Q as a column system y' = Q^T y, whose Jacobian is Q^T exactly.
220 Matrix<T> QT(n, n);
221 for (std::size_t i = 0; i < n; ++i)
222 for (std::size_t j = 0; j < n; ++j) QT(j, i) = Q(i, j);
223 OdeOptions<T> oo;
227 [&Q, n](const T&, const std::vector<T>& y) {
228 std::vector<T> dy(n, num_traits<T>::from_int(0));
229 for (std::size_t j = 0; j < n; ++j) {
231 for (std::size_t i = 0; i < n; ++i) s += y[i] * Q(i, j);
232 dy[j] = s;
233 }
234 return dy;
235 },
236 [&QT](const T&, const std::vector<T>&) { return QT; }, t0, t1, pi0, oo);
237 tv = sol.t;
238 yv = sol.y;
239 } else {
240 detail::ode23<T>(
241 [&Q, n](const std::vector<T>& y, std::vector<T>& dy) {
242 for (std::size_t j = 0; j < n; ++j) {
244 for (std::size_t i = 0; i < n; ++i) s += y[i] * Q(i, j);
245 dy[j] = s;
246 }
247 },
248 t0, t1, pi0, rtol, atol, tv, yv);
249 }
250
252 r.t = tv;
253 r.pi = Matrix<T>(yv.size(), n);
254 for (std::size_t k = 0; k < yv.size(); ++k)
255 for (std::size_t j = 0; j < n; ++j) r.pi(k, j) = yv[k][j];
256 return r;
257}
258
259/**
260 * Overload starting from the uniform distribution, as MATLAB's short forms do.
261 * The initial time stays explicit: an overload taking (Q, pi0, t1) would be
262 * ambiguous with the four-argument form at T = double, since the tolerances are
263 * doubles too.
264 */
265template <class T>
266TransientResult<T> ctmc_transient(const Matrix<T>& Q, const T& t0, const T& t1, double rtol = 1e-3,
267 double atol = 1e-6) {
268 const std::size_t n = Q.rows();
269 if (n == 0) throw InputError("ctmc_transient: empty generator");
270 const std::vector<T> pi0(n, num_traits<T>::from_int(1) / num_traits<T>::from_int(static_cast<long>(n)));
271 return ctmc_transient(Q, pi0, t0, t1, rtol, atol);
272}
273
274/**
275 * Resample an adaptive transient onto the uniform grid `t0 : dt : t1`.
276 *
277 * `options.timestep` of the reference. `ctmc_transient.m` passes the grid
278 * STRAIGHT INTO ode23 as its tspan, and MATLAB's integrator then reports the
279 * solution at exactly those points by evaluating its own dense output -- it does
280 * not change the steps it takes, only where it reports them. This does the same
281 * thing explicitly: the adaptive solve is untouched, and the answer at a grid
282 * point is the cubic Hermite interpolant of the bracketing pair.
283 *
284 * THE DERIVATIVES ARE EXACT AND NOT DIFFERENCED. `d pi/dt = pi Q` holds at every
285 * stored point, so the Hermite data is the solution and its true derivative
286 * rather than a secant estimate; that is what makes this the same order as
287 * ode23's own dense output instead of a linear interpolation dressed up as one.
288 *
289 * `t1` IS ALWAYS THE LAST POINT even when the step does not divide the horizon,
290 * as `ctmc_transient.m` appends it: a transient reported to 9.9 when 10 was
291 * asked for is a different answer, not a rounded one.
292 */
293template <class T>
294TransientResult<T> ctmc_transient_on_grid(const Matrix<T>& Q, const TransientResult<T>& r,
295 const std::vector<T>& grid);
296
297template <class T>
299 const T& t0, const T& t1, const T& dt) {
300 if (!(num_traits<T>::to_double(dt) > 0.0))
301 throw InputError("ctmc_transient_on_grid: the timestep must be positive");
302 if (r.t.empty()) return r;
303
304 std::vector<T> grid;
305 const double d0 = num_traits<T>::to_double(t0), d1 = num_traits<T>::to_double(t1),
307 for (double x = d0; x <= d1 + 1e-12 * (d1 - d0); x += dd)
308 grid.push_back(num_traits<T>::from_double(x));
309 if (grid.empty() || num_traits<T>::to_double(grid.back()) < d1) grid.push_back(t1);
310 return ctmc_transient_on_grid(Q, r, grid);
311}
312
313/**
314 * The same resampling onto an ARBITRARY grid, which a uniform step cannot
315 * express.
316 *
317 * A caller that integrates a quantity AGAINST the trajectory needs the points
318 * its integrand asks for, not the points the step controller happened to stop
319 * at: the environment coupling forms a Riemann-Stieltjes sum of each stage's
320 * transient against the holding-time CDF, and the grid that resolves that CDF
321 * (`refine_grid`, 90% of its points under 5*E[S]) is not uniform. Resampling
322 * here rather than re-integrating keeps ONE integration behind every grid.
323 *
324 * LINEAR, NOT HERMITE, AND DELIBERATELY THE LESS ACCURATE CHOICE. Every
325 * codebase resamples here rather than re-integrating, but MATLAB's
326 * `refineForCdf_`, the JAR's `CdfGrid.on` and native python all interpolate
327 * LINEARLY, and this port used a cubic Hermite built from the exact derivative
328 * `y' = yQ`. That is the better interpolant and it is what made
329 * renv_threestages_repairmen read Queue1 QLen 0.83092 where the other three read
330 * 0.83053: a 4.7e-4 disagreement that is entirely the difference between the two
331 * quadratures, on a row whose gate is 3.2e-4. Parity measures whether the
332 * codebases give the SAME answer, so the interpolant is aligned rather than the
333 * golden rebased onto the more accurate one. Changed 2026-08-13 on the user's
334 * decision; if this is ever revisited, note that the metrics read off pi(t) are
335 * LINEAR functionals of it, so interpolating pi linearly and interpolating each
336 * metric linearly are the same operation -- which is why matching the
337 * reference here is enough to match it in every measure.
338 */
339template <class T>
341 const std::vector<T>& grid) {
342 if (r.t.empty() || grid.empty()) return r;
343 const std::size_t n = Q.rows();
344 (void)Q; // the linear interpolant needs no derivative, so no Q
345
347 out.t = grid;
348 out.pi = Matrix<T>(grid.size(), n);
349 std::size_t k = 0;
350 for (std::size_t g = 0; g < grid.size(); ++g) {
351 const double x = num_traits<T>::to_double(grid[g]);
352 while (k + 2 < r.t.size() && num_traits<T>::to_double(r.t[k + 1]) < x) ++k;
353 const double a = num_traits<T>::to_double(r.t[k]);
354 const std::size_t k1 = (k + 1 < r.t.size()) ? k + 1 : k;
355 const double b = num_traits<T>::to_double(r.t[k1]);
356 if (k1 == k || b <= a) {
357 for (std::size_t j = 0; j < n; ++j) out.pi(g, j) = r.pi(k, j);
358 continue;
359 }
360 // CLAMPED at both ends, as the reference's own resamplers are: a grid
361 // point outside the stored span takes the nearest endpoint rather than
362 // an extrapolation, which on a probability vector could leave the
363 // simplex.
364 double u = (x - a) / (b - a);
365 if (u < 0.0) u = 0.0;
366 if (u > 1.0) u = 1.0;
367 for (std::size_t j = 0; j < n; ++j)
368 out.pi(g, j) = T(num_traits<T>::from_double(1.0 - u) * r.pi(k, j) +
369 num_traits<T>::from_double(u) * r.pi(k1, j));
370 }
371 return out;
372}
373
374} // namespace mc
375} // namespace line
376
377#endif // LINE_API_MC_CTMC_TRANSIENT_H
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
NumericError(const std::string &what)
Definition error.h:45
The exception types the port throws.
Dense matrix and non-owning view.
TransientResult< T > ctmc_transient_on_grid(const Matrix< T > &Q, const TransientResult< T > &r, const std::vector< T > &grid)
Resample an adaptive transient onto the uniform grid t0 : dt : t1.
TransientResult< T > ctmc_transient(const Matrix< T > &Q, const std::vector< T > &pi0, const T &t0, const T &t1, double rtol=1e-3, double atol=1e-6, bool stiff=false)
Transient distribution of a CTMC over a time interval, by integrating the forward equations d pi/dt =...
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
T num_abs(const T &v)
Definition number.h:198
OdeSolution< T > ode_rosenbrock4(const F &f, const J &jac, const T &t0, const T &t1, const std::vector< T > &y0, const OdeOptions< T > &opt)
Integrate y' = f(t,y) from t0 to t1 with an analytic Jacobian.
Definition ode.h:304
Number-type abstraction for the templated API port.
Adaptive stiff ODE integrator: a four-stage Rosenbrock method of order four with an embedded order-th...
Integration controls.
Definition ode.h:118
T atol
absolute tolerance per component
Definition ode.h:120
T rtol
relative tolerance per component
Definition ode.h:119
Result of an integration.
Definition ode.h:143
std::vector< std::vector< T > > y
y[i] is the state at t[i]
Definition ode.h:145
std::vector< T > t
accepted time points, t[0] = t0
Definition ode.h:144
std::vector< T > t
accepted time points, the first being t0
Matrix< T > pi
one row per time point