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