LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_stiff.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_STIFF_H
6#define LINE_SOLVERS_FLUID_FLUID_STIFF_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `ode_eliminate_immediate.m`, `eliminate_immediate_matrix.m` and
12 * `ode_solve_stiff.m`: the two answers to an IMMEDIATE transition.
13 *
14 * WHERE THE STIFFNESS COMES FROM. An Immediate distribution fires at
15 * `GlobalConstants.Immediate`, 1e8, while the rest of the model runs at rates
16 * of order one. The drift then has a mode with time constant 1e-8 alongside
17 * modes with time constant 1, and an explicit step controller is pinned to the
18 * fastest one for the WHOLE integration, long after that mode has died: the run
19 * either crawls or goes unstable. That is stiffness, and it is a property of
20 * the equations rather than of the integrator.
21 *
22 * THE TWO ANSWERS ARE NOT EQUALLY GOOD, AND THE REFERENCE PREFERS THE FIRST.
23 * Eliminating the immediate transitions removes the fast mode from the system,
24 * so what is left is not stiff at all. Falling back to a stiff integrator keeps
25 * the fast mode and pays an implicit solve to stay stable across it. The first
26 * is ALGEBRA and the second is NUMERICS, which is why the first is exact:
27 *
28 * ELIMINATION IS EXACT. The stochastic complement of a generator over a
29 * retained set is the generator of the process WATCHED ONLY ON THAT SET,
30 * S = Q11 + Q12 (-Q22)^-1 Q21. Its stationary law is the original's,
31 * conditioned on the retained set and renormalized. It is an identity, not an
32 * approximation, and it holds at any rate ratio. The one place a gap appears
33 * is when the answer is compared against the UNREDUCED system: the immediate
34 * states hold stationary mass of order 1/Immediate, about 1e-8, which the
35 * reduced system does not carry and the conditioning divides out.
36 *
37 * THE STIFF INTEGRATOR ONLY COPES. It keeps the fast mode and controls the
38 * error on it, so its answer carries the integrator's tolerance and nothing
39 * better.
40 *
41 * A CAVEAT THE REFERENCE DOES NOT STATE. Reconstructing a generator from the
42 * jump/rate representation reads the drift as `dx/dt = x W`, which is what the
43 * fluid drift IS wherever the state-dependent factor `g(x)` is x itself: an
44 * infinite server, or any station holding fewer jobs than it has servers. At a
45 * SATURATED station g is `min(n_i, S_i)/n_i` times x and the reconstructed W is
46 * not the drift there, so the complement is exact for the linear part only.
47 * That is the reference's behaviour, reproduced rather than corrected, and it
48 * is why the elimination is applied to the transitions and not to the metrics.
49 *
50 * WHAT WAS ALREADY HERE, AND IS NOT REBUILT. `mc::ctmc_stochcomp` is the
51 * complement, with the shared LU factorization the reference gets from
52 * backslash. `ode_rosenbrock4` in util/ode.h is the stiff integrator: a
53 * four-stage L-stable Rosenbrock method, the family MATLAB's ode23s belongs to,
54 * with an embedded estimate for step control. And the fluid path's ordinary
55 * integrator is LSODA, which already switches itself from Adams to BDF when it
56 * detects stiffness; the point of this file is therefore not "a stiff solver
57 * exists" but that the stiffness can be removed before anyone integrates.
58 */
59
60#include <algorithm>
61#include <cmath>
62#include <cstddef>
63#include <exception>
64#include <functional>
65#include <string>
66#include <type_traits>
67#include <vector>
68
71#include "line/util/linalg.h"
73#include "line/util/error.h"
74#include "line/util/lsoda.h"
75#include "line/util/matrix.h"
76#include "line/util/ode.h"
77
78namespace line {
79namespace fluid {
80
81/**
82 * The reference's two thresholds, which do NOT agree and are not meant to.
83 *
84 * `ode_eliminate_immediate` knows the rates it is looking at are `rateBase`
85 * entries built from an Immediate distribution, so it asks for a rate within 1%
86 * of the constant itself. `eliminate_immediate_matrix` is handed a generator
87 * whose entries have already been multiplied by routing probabilities and
88 * summed, so it asks only for an order of magnitude below it. A transition
89 * routed with probability 0.5 is invisible to the first rule and caught by the
90 * second; both are kept as they are, because tightening the first would start
91 * eliminating transitions the reference integrates.
92 */
97
98/** What an elimination attempt produced. */
100 FluidOdeSystem sys; ///< reduced, or the input on a fallback
101 std::vector<std::size_t> state_map; ///< reduced position -> original index, 0-based
102 bool eliminated = false; ///< false when the input is returned unchanged
103 std::size_t n_immediate = 0; ///< immediate transitions detected
104 /**
105 * Why the elimination was abandoned, empty when it was not attempted or
106 * when it succeeded. The reference warns here and carries on with a system
107 * that is still stiff; a caller that cannot see the warning would integrate
108 * it without knowing to switch to the stiff arm, which is the whole
109 * decision this file exists to inform.
110 */
111 std::string fallback;
112 /**
113 * `emap(e, o)`: expected firings of the ORIGINAL event o per firing of the
114 * reduced event e; the identity when nothing was eliminated. A caller maps a
115 * per-event quantity with `new = emap * old`, which is what lets a throughput
116 * read off a reduced event set stay exact -- an event folded through an
117 * immediate coordinate is a completion at more than one (station,class).
118 */
120 /**
121 * Projector for the initial condition: identity on the timed rows, the
122 * absorption distribution on the immediate ones. Mass parked on an eliminated
123 * coordinate would otherwise be frozen there for the whole integration.
124 */
126};
127
128namespace detail {
129
130/**
131 * The generator the jump/rate representation encodes, `W` in the reference.
132 *
133 * The row is the GATING index, `event_idx`, and not the coordinate the event
134 * takes mass from: the rate of an event is `rate_base * g(x)_{event_idx}`, so
135 * that is the index the rate is proportional to and therefore the only one a
136 * generator row can be. The two coincide in every system `fluid_ode_system`
137 * builds, and the caller below refuses to complement one where they do not
138 * rather than silently complementing a matrix that is not the drift.
139 *
140 * An event whose endpoints coincide contributes nothing to the drift (it adds
141 * and removes the same mass), and it is dropped here for the same reason the
142 * reference's row-sum subtraction cancels it.
143 */
144inline Matrix<double> fluid_generator_from_events(const FluidOdeSystem& sys, std::size_t n) {
145 Matrix<double> W(n, n, 0.0);
146 for (const FluidEvent& e : sys.events) {
147 if (e.minus == e.plus) continue;
148 W(e.event_idx, e.plus) += e.rate_base;
149 }
150 for (std::size_t i = 0; i < n; ++i) {
151 double s = 0.0;
152 for (std::size_t j = 0; j < n; ++j)
153 if (j != i) s += W(i, j);
154 W(i, i) = -s;
155 }
156 return W;
157}
158
159/**
160 * Port of `generator_to_jumps.m`, with the reduced indices mapped back.
161 *
162 * Only strictly positive off-diagonal entries become events. A complement can
163 * emit an entry of size 1e-300 where two paths nearly cancel, and the reference
164 * keeps those too; what it never keeps is the diagonal, which is not a
165 * transition but the negative of the row sum the jumps rebuild by themselves.
166 */
167inline std::vector<FluidEvent> fluid_events_from_generator(
168 const Matrix<double>& W, const std::vector<std::size_t>& state_map) {
169 std::vector<FluidEvent> out;
170 for (std::size_t i = 0; i < W.rows(); ++i)
171 for (std::size_t j = 0; j < W.cols(); ++j) {
172 if (i == j || !(W(i, j) > 0.0)) continue;
173 FluidEvent e;
174 e.minus = state_map[i];
175 e.plus = state_map[j];
176 e.event_idx = state_map[i];
177 e.rate_base = W(i, j);
178 out.push_back(e);
179 }
180 return out;
181}
182
183} // namespace detail
184
185/**
186 * Stochastic complementation of the INSTANTANEOUS coordinates of a fluid drift,
187 * the twin of `ode_eliminate_immediate.m`.
188 *
189 * A coordinate whose exit rate is `GlobalConstants::Immediate` is not a fast
190 * coordinate, it is an INSTANTANEOUS one: the rate is LINE's stand-in for
191 * infinity, written by SolverLN for the branch of an activity that takes no time
192 * (an entry called with probability y < 1 carries a second PH phase at InfRate
193 * entered with probability 1-y). Integrating it is meaningless work no
194 * integrator does well.
195 *
196 * THE REDUCTION IS EXACT. The instantaneous coordinates F are absorbed into the
197 * timed ones S by the absorption probabilities of the embedded jump chain
198 * restricted to F, so the flow that would enter F is routed straight to where F
199 * would have sent it.
200 *
201 * WHY THIS IS A STRUCTURAL COMPOSITION AND NOT A GENERATOR ROUND TRIP, which is
202 * what this function used to be. Every event is a single -1 at `event_idx` and a
203 * single +1 at `plus`, so a path through F composes to ONE event, -1 at the
204 * original source and +1 at the absorbing coordinate, that keeps the original
205 * source's GATING. Rebuilding the events from a reduced generator loses that
206 * identity -- and with it `n_departures`, which this function used to report as
207 * zero because it was "no longer recoverable". It is recoverable: `emap(e, o)`
208 * is the expected number of times the ORIGINAL event o fires per firing of the
209 * reduced event e, so a caller maps any per-event quantity with
210 * `new = emap * old` and gets an exact rate accounting. That is what lets the
211 * moment-closure methods, which used to refuse the reduction outright, read
212 * their throughputs off a reduced event set.
213 *
214 * A COMPOSED EVENT CAN BE A DEPARTURE AT TWO STATIONS AT ONCE: a job that leaves
215 * a delay, passes through a queue's immediate phase and returns has completed at
216 * both, and both throughputs must count it. `emap` gives it a row with weight on
217 * both original events, and the null jump it composes to (-1 and +1 on the same
218 * coordinate) correctly contributes nothing to the drift and nothing to the
219 * diffusion.
220 *
221 * `absorb` projects an initial condition onto the surviving coordinates: mass
222 * parked on an eliminated one would otherwise be frozen there for the whole
223 * integration, because nothing moves it any more.
224 */
225/**
226 * Whether this model's fluid drift is built on the stochastic complement of its
227 * INSTANTANEOUS coordinates.
228 *
229 * Every fluid route that builds its drift from the station/class/phase event set
230 * or from the linear generator asks here rather than reading the flag directly,
231 * so the answer is the same across matrix, closing, statedep, tbi, minnormal,
232 * refined and dae. The flag defaults to TRUE: a coordinate whose exit rate is
233 * `GlobalConstants::Immediate` is LINE's stand-in for infinity, and integrating
234 * it is meaningless work no integrator does well.
235 *
236 * THE STOCHASTIC PETRI NET ROUTE IS THE ONE EXCEPTION, and it is not a refusal.
237 * It carries immediate firings as ALGEBRAIC unknowns of an index-1 DAE, a
238 * stronger treatment than absorbing them, and never builds the event set this
239 * reduction acts on, so the answer here is simply false.
240 */
241template <class T, class Opt>
243 if (!opt.hide_immediate) return false;
244 for (std::size_t i = 0; i < sn.nodes.size(); ++i)
245 if (sn.nodes[i].nodetype == lang::NodeType::Transition) return false;
246 return true;
247}
248
250 const FluidOdeSystem& sys, double imm_tol = fluid_immediate_transition_tol()) {
251 const std::size_t n = sys.layout.nstates;
252 const std::size_t ne = sys.events.size();
254 out.sys = sys;
255 out.state_map.resize(n);
256 for (std::size_t i = 0; i < n; ++i) out.state_map[i] = i;
257 out.emap = Matrix<double>(ne, ne, 0.0);
258 for (std::size_t e = 0; e < ne; ++e) out.emap(e, e) = 1.0;
259 out.absorb = Matrix<double>(n, n, 0.0);
260 for (std::size_t i = 0; i < n; ++i) out.absorb(i, i) = 1.0;
261
262 // The immediate coordinates are the SOURCES of the immediate events: it is
263 // the coordinate that empties instantaneously, not the event.
264 std::vector<bool> is_imm(n, false);
265 for (const FluidEvent& e : sys.events)
266 if (e.rate_base >= imm_tol) {
267 ++out.n_immediate;
268 if (e.event_idx < n) is_imm[e.event_idx] = true;
269 }
270 if (out.n_immediate == 0) return out; // nothing to eliminate, and no warning to give
271
272 for (const FluidEvent& e : sys.events)
273 if (e.minus != e.event_idx) {
274 // The rate would be proportional to one coordinate while the mass
275 // left another, which no branching chain can express.
276 out.fallback =
277 "an event draws its rate from a coordinate other than the one it removes mass "
278 "from, so the drift is not a generator and cannot be complemented";
279 return out;
280 }
281
282 // A coordinate with no outflow cannot be complemented away, and one whose
283 // outflow is entirely a self-loop would make the fundamental matrix
284 // singular. Both are dropped rather than guessed at.
285 for (std::size_t f = 0; f < n; ++f) {
286 if (!is_imm[f]) continue;
287 double tot = 0.0;
288 bool leaves = false;
289 for (const FluidEvent& e : sys.events)
290 if (e.event_idx == f) {
291 tot += e.rate_base;
292 if (e.plus != f) leaves = true;
293 }
294 if (!(tot > 0.0) || !leaves) is_imm[f] = false;
295 }
296
297 std::vector<std::size_t> Fidx, Sidx;
298 for (std::size_t i = 0; i < n; ++i) (is_imm[i] ? Fidx : Sidx).push_back(i);
299 if (Fidx.empty() || Sidx.size() <= 1) {
300 out.fallback =
301 "every fluid coordinate but at most one sources an immediate transition, so the "
302 "complement would be a trivial system; integrating the original stiff drift instead";
303 return out;
304 }
305 const std::size_t nF = Fidx.size(), nS = Sidx.size();
306 std::vector<std::size_t> posF(n, 0), posS(n, 0);
307 for (std::size_t a = 0; a < nF; ++a) posF[Fidx[a]] = a;
308 for (std::size_t b = 0; b < nS; ++b) posS[Sidx[b]] = b;
309
310 // Branching of the embedded jump chain out of each immediate coordinate. The
311 // probabilities are the rate shares, so a coordinate carrying both an
312 // immediate and an ordinary exit gives the ordinary one its (vanishing)
313 // share rather than being special-cased.
314 Matrix<double> PFF(nF, nF, 0.0), PFS(nF, nS, 0.0), cnt(nF, ne, 0.0);
315 for (std::size_t a = 0; a < nF; ++a) {
316 const std::size_t f = Fidx[a];
317 double tot = 0.0;
318 for (const FluidEvent& e : sys.events)
319 if (e.event_idx == f) tot += e.rate_base;
320 for (std::size_t o = 0; o < ne; ++o) {
321 const FluidEvent& e = sys.events[o];
322 if (e.event_idx != f) continue;
323 const double p = e.rate_base / tot;
324 cnt(a, o) += p;
325 if (is_imm[e.plus]) PFF(a, posF[e.plus]) += p;
326 else PFS(a, posS[e.plus]) += p;
327 }
328 }
329
330 // Fundamental matrix of the instantaneous chain. A closed cycle of immediate
331 // coordinates has no absorption distribution and is left unreduced.
332 Matrix<double> ImP(nF, nF, 0.0);
333 for (std::size_t a = 0; a < nF; ++a)
334 for (std::size_t b = 0; b < nF; ++b)
335 ImP(a, b) = (a == b ? 1.0 : 0.0) - PFF(a, b);
336 Matrix<double> Nfm;
337 try {
338 Nfm = inverse(ImP);
339 } catch (const std::exception& ex) {
340 out.fallback = std::string("the immediate coordinates form a closed cycle, so they have "
341 "no absorption distribution: ") + ex.what();
342 return out;
343 }
344 const Matrix<double> Aabs = matmul(Nfm, PFS);
345 const Matrix<double> expcnt = matmul(Nfm, cnt);
346 for (std::size_t a = 0; a < nF; ++a) {
347 double rowsum = 0.0;
348 for (std::size_t b = 0; b < nS; ++b) {
349 if (!std::isfinite(Aabs(a, b))) rowsum = std::numeric_limits<double>::quiet_NaN();
350 rowsum += Aabs(a, b);
351 }
352 if (!(rowsum > 0.5)) {
353 out.fallback =
354 "stochastic complementation produced no absorption distribution, which is what a "
355 "closed set of immediate coordinates looks like once it has been solved";
356 return out;
357 }
358 }
359
360 // Compose the event list. An event sourced in F is dropped: its flow is
361 // already carried by whichever event feeds F. Departures stay first, so
362 // `n_departures` survives the composition -- a composed event is a departure
363 // exactly when the event that fed the immediate coordinate was one.
364 std::vector<FluidEvent> kept_dep, kept_other;
365 std::vector<std::vector<double>> emap_dep, emap_other;
366 for (std::size_t o = 0; o < ne; ++o) {
367 const FluidEvent& e = sys.events[o];
368 if (is_imm[e.event_idx]) continue;
369 const bool is_dep = o < sys.n_departures;
370 std::vector<FluidEvent>& bucket = is_dep ? kept_dep : kept_other;
371 std::vector<std::vector<double>>& rows = is_dep ? emap_dep : emap_other;
372 if (!is_imm[e.plus]) {
373 bucket.push_back(e);
374 std::vector<double> row(ne, 0.0);
375 row[o] = 1.0;
376 rows.push_back(row);
377 continue;
378 }
379 // The event feeds an immediate coordinate: one event per absorbing
380 // destination, keeping the original source and so the original gating,
381 // since the rate of the composed flow IS the rate of the inflow.
382 const std::size_t a = posF[e.plus];
383 for (std::size_t b = 0; b < nS; ++b) {
384 if (!(Aabs(a, b) > 0.0)) continue;
385 FluidEvent ce = e;
386 ce.plus = Sidx[b];
387 ce.rate_base = e.rate_base * Aabs(a, b);
388 bucket.push_back(ce);
389 // Weighting every absorbing branch by the SAME unconditional expected
390 // counts is what makes the rate accounting exact: the branch rates
391 // sum back to e.rate_base, so the mapped total is e.rate_base times
392 // the counts.
393 std::vector<double> row(ne, 0.0);
394 for (std::size_t oo = 0; oo < ne; ++oo) row[oo] = expcnt(a, oo);
395 row[o] += 1.0;
396 rows.push_back(row);
397 }
398 }
399
400 out.sys.events.clear();
401 out.sys.events.insert(out.sys.events.end(), kept_dep.begin(), kept_dep.end());
402 out.sys.events.insert(out.sys.events.end(), kept_other.begin(), kept_other.end());
403 out.sys.n_departures = kept_dep.size();
404
405 const std::size_t nnew = out.sys.events.size();
406 out.emap = Matrix<double>(nnew, ne, 0.0);
407 for (std::size_t e = 0; e < emap_dep.size(); ++e)
408 for (std::size_t o = 0; o < ne; ++o)
409 if (emap_dep[e][o] != 0.0) out.emap(e, o) = emap_dep[e][o];
410 for (std::size_t e = 0; e < emap_other.size(); ++e)
411 for (std::size_t o = 0; o < ne; ++o)
412 if (emap_other[e][o] != 0.0) out.emap(emap_dep.size() + e, o) = emap_other[e][o];
413
414 out.absorb = Matrix<double>(n, n, 0.0);
415 for (std::size_t b = 0; b < nS; ++b) out.absorb(Sidx[b], Sidx[b]) = 1.0;
416 for (std::size_t a = 0; a < nF; ++a)
417 for (std::size_t b = 0; b < nS; ++b)
418 if (Aabs(a, b) > 0.0) out.absorb(Fidx[a], Sidx[b]) = Aabs(a, b);
419
420 out.state_map = Sidx;
421 out.eliminated = true;
422 return out;
423}
424
425/**
426 * The same, at the reference's own signature, which carries `sn`.
427 *
428 * It is here for the arithmetic gate rather than for the struct: the fluid
429 * drift is integrated by LSODA and by the Rosenbrock method below, both of
430 * which are double by construction, so a non-double model has no business
431 * reaching either. The complement ITSELF is field arithmetic and would be exact
432 * over the rationals, which is why the gate sits on the entry point that knows
433 * what the model is made of and not on the complement.
434 */
435template <class T>
437 const FluidOdeSystem& sys,
438 double imm_tol = fluid_immediate_transition_tol()) {
439 (void)sn;
440 if (!std::is_same<T, double>::value)
441 throw UnsupportedError(
442 "fluid_eliminate_immediate: the fluid solver integrates its drift with LSODA, whose "
443 "coefficients assume double precision; rerun with --arith double");
444 return fluid_eliminate_immediate(sys, imm_tol);
445}
446
447/** What the matrix-level elimination produced. */
448template <class T>
450 Matrix<T> W; ///< reduced, or the input on a fallback
451 std::vector<std::size_t> state_map; ///< reduced position -> original index, 0-based
452 bool eliminated = false;
453 std::string fallback;
454 /**
455 * `emap(e, o)`: expected firings of the ORIGINAL event o per firing of the
456 * reduced event e; the identity when nothing was eliminated. A caller maps a
457 * per-event quantity with `new = emap * old`, which is what lets a throughput
458 * read off a reduced event set stay exact -- an event folded through an
459 * immediate coordinate is a completion at more than one (station,class).
460 */
462 /**
463 * Projector for the initial condition: identity on the timed rows, the
464 * absorption distribution on the immediate ones. Mass parked on an eliminated
465 * coordinate would otherwise be frozen there for the whole integration.
466 */
468};
469
470/**
471 * Port of `eliminate_immediate_matrix.m`: the same elimination on a generator
472 * that is already assembled.
473 *
474 * It detects immediate STATES directly, by the largest rate on their row,
475 * rather than immediate transitions. That is the only rule available once the
476 * routing probabilities have been folded in and parallel edges summed, and it
477 * catches a case the transition rule misses: an immediate transition taken with
478 * probability one half carries rate 5e7, which is nowhere near Immediate but is
479 * still seven orders above the rest of the model.
480 */
481template <class T>
483 const Matrix<T>& W, double imm_tol = fluid_immediate_state_tol()) {
484 const std::size_t n = W.rows();
485 if (W.cols() != n)
486 throw InputError("fluid_eliminate_immediate_matrix: the generator is not square");
487
489 out.W = W;
490 out.state_map.resize(n);
491 for (std::size_t i = 0; i < n; ++i) out.state_map[i] = i;
492
493 std::vector<std::size_t> timed;
494 std::size_t n_imm = 0;
495 for (std::size_t i = 0; i < n; ++i) {
496 double mx = 0.0;
497 for (std::size_t j = 0; j < n; ++j)
498 mx = std::max(mx, std::fabs(num_traits<T>::to_double(W(i, j))));
499 if (mx >= imm_tol) ++n_imm;
500 else timed.push_back(i);
501 }
502 if (n_imm == 0) return out;
503 if (timed.size() <= 1) {
504 out.fallback =
505 "at most one state of the generator is timed, so the complement would be a trivial "
506 "system; the original generator is returned unreduced";
507 return out;
508 }
509
510 try {
511 Matrix<T> S = mc::ctmc_stochcomp(W, timed).S;
512 for (std::size_t i = 0; i < S.rows(); ++i)
513 for (std::size_t j = 0; j < S.cols(); ++j)
514 if (!std::isfinite(num_traits<T>::to_double(S(i, j)))) {
515 out.fallback =
516 "stochastic complementation produced non-finite rates, which is what a "
517 "singular immediate block looks like once it has been solved";
518 return out;
519 }
520 out.W = S;
521 out.state_map = timed;
522 out.eliminated = true;
523 } catch (const std::exception& ex) {
524 out.fallback = std::string("stochastic complementation failed: ") + ex.what();
525 }
526 return out;
527}
528
529/**
530 * Controls for the stiff arm.
531 *
532 * `stiff` is `options.stiff`, the reference's choice between its accurate and
533 * its fast stiff solver. THAT CHOICE DOES NOT SURVIVE THE PORT as a choice of
534 * method: MATLAB picks between ode15s and ode23s, and this port has exactly one
535 * stiff method, so claiming either name would be a fiction. What the two arms
536 * differ in that IS expressible here is the NonNegative handling, which the
537 * reference clears for the fast arm because ode23s cannot honour it, and that
538 * is what the flag selects below. Tolerances come from the caller on both arms,
539 * as they do in the reference.
540 */
542 /** Refuses any named MATLAB solver: see the gate below. */
543 std::string solver = "default";
544 bool stiff = true; ///< options.stiff: keep the nonnegativity projection
545 double rtol = 1e-4; ///< the value solver_fluid.h hands LSODA, FluidOptions::tol
546 double atol = 1e-4;
547 std::size_t max_steps = 100000;
548 bool store_trajectory = false;
549 /** Per-accepted-step stop; see OdeOptions::step_stop and solver_fluid.h. */
550 std::function<bool(const double&, const std::vector<double>&)> step_stop;
551};
552
553/**
554 * Port of `ode_solve_stiff.m`.
555 *
556 * The reference dispatches through `options.odesolvers.*StiffOdeSolver`, which
557 * are function handles a caller may point at any MATLAB integrator. There is no
558 * such registry here and no way to honour an arbitrary one, so a solver asked
559 * for BY NAME is refused rather than served by the method that happens to be
560 * present under a name it does not have.
561 *
562 * THE NONNEGATIVITY IS A PROJECTION, NOT A CONSTRAINT. MATLAB's NonNegative
563 * option is enforced by the step controller, which rejects a step that would
564 * take a component below zero. Clamping the accepted trajectory afterwards
565 * cannot rescue such a step; it prevents a negative mass from feeding back into
566 * the drift as a negative rate, which is what `solver_fluid.h` already does
567 * after every integration leg. The difference matters on a drift that is only
568 * defined for nonnegative states, and is stated rather than hidden.
569 */
571 const std::function<void(double, const double*, double*)>& f, double t0, double t1,
572 const std::vector<double>& y0, const FluidStiffOptions& opt = FluidStiffOptions()) {
573 if (!(opt.solver == "default" || opt.solver == "rosenbrock4"))
574 throw UnsupportedError(
575 "fluid_ode_solve_stiff: the '" + opt.solver +
576 "' integrator is a MATLAB solver handle this port does not carry; the stiff arm here "
577 "is one four-stage L-stable Rosenbrock method (util/ode.h), asked for as 'default'");
578 if (y0.empty()) throw InputError("fluid_ode_solve_stiff: the initial state is empty");
579 if (!(t1 > t0))
580 throw InputError(
581 "fluid_ode_solve_stiff: the horizon must be positive; the fluid iteration marches "
582 "forwards and a reversed range is a caller error rather than a backwards solve");
583
585 o.rtol = opt.rtol;
586 o.atol = opt.atol;
587 o.max_steps = opt.max_steps;
588 o.store_trajectory = opt.store_trajectory;
589 o.step_stop = opt.step_stop;
590
591 const std::size_t n = y0.size();
592 // util/ode.h takes and returns whole vectors, the fluid drift writes into a
593 // raw buffer; the adapter is the only thing between them.
594 const auto g = [&f, n](const double& t, const std::vector<double>& y) {
595 std::vector<double> dy(n, 0.0);
596 f(t, y.data(), dy.data());
597 return dy;
598 };
599 OdeSolution<double> sol = ode_rosenbrock4<double>(g, t0, t1, y0, o);
600 if (opt.stiff)
601 for (std::size_t i = 0; i < sol.y.size(); ++i)
602 for (std::size_t j = 0; j < sol.y[i].size(); ++j)
603 if (sol.y[i][j] < 0.0) sol.y[i][j] = 0.0;
604 return sol;
605}
606
607/**
608 * The step budget LSODA gets on a fluid leg before the stiff arm takes over.
609 *
610 * `LsodaOptions::max_steps` defaults to the JAR's raised `mxstep` of 1e7, which
611 * is a GIVE-UP threshold: nothing follows it, so it is set high enough that a
612 * slow-but-finishing integration is not cut off. Here it is a SWITCH threshold
613 * instead, because `fluid_integrate_leg` finishes the leg by another method, and
614 * a switch wants to be cheap. A fluid leg that is going to finish takes tens to
615 * a few thousand steps (measured: 3 to 121 on the LQN layers of `randomLQN`), so
616 * 1e6 keeps a factor of a thousand in hand while costing a fraction of a second
617 * to discover the Adams stability wall instead of the ~20 s that 1e7 cost.
618 */
620 LsodaOptions out = lopt;
621 if (out.max_steps > 1000000) out.max_steps = 1000000;
622 return out;
623}
624
625/**
626 * One integration leg, with the reference's retry on a failed solve.
627 *
628 * WHY A RETRY EXISTS AT ALL. `FluidOptions::stiff` is false in this port where
629 * the reference's `options.stiff` is true, on the argument that LSODA switches
630 * to BDF on its own stiffness detector and so already covers ode15s. That holds
631 * on most drifts and NOT on all of them: an LQN layer whose entry carries an
632 * Immediate rate has 1e8 in `sn.rates`, so the drift's Jacobian eigenvalue is
633 * -1e8 while the trajectory is otherwise O(1), and LSODA has been measured
634 * staying in ADAMS across such a leg -- 1e7 accepted steps at h = 5.6e-9,
635 * reaching t = 0.056 of a horizon of 20.9 before `mxstep` stopped it. Adams is
636 * stability-limited to h < 2/1e8 there, so no step count would have finished.
637 *
638 * WHAT THE FAILURE USED TO COST. Every caller took `final_state()` regardless:
639 * `solver_fluid`'s closing loop broke out and reported the state it happened to
640 * reach as the fixed point, while the matrix arm and the transient getter did
641 * not read `success` at all. A layer that never left its initial transient was
642 * published as a converged answer, which is the one outcome an integrator
643 * failure must not produce.
644 *
645 * The reference retries too -- `solver_fluid_matrix.m` re-solves once with
646 * `hide_immediate` toggled, `solver_fluid_iteration.m` catches the ODE error and
647 * re-solves from the default initial state -- so retrying is the reference's
648 * shape, not an invention. What is retried here is the INTEGRATOR: the same leg,
649 * the same tolerances, run by the L-stable Rosenbrock arm above, which is the
650 * family ode23s belongs to and is not stability-limited. A leg LSODA completes
651 * is untouched, so no result that already converged moves.
652 */
653inline std::vector<double> fluid_integrate_leg(
654 const std::function<void(double, const double*, double*)>& f, double t0, double t1,
655 const std::vector<double>& y0, const LsodaOptions& lopt) {
656 const LsodaSolution s = lsoda_integrate(f, y0, std::vector<double>{t0, t1}, fluid_lsoda(lopt));
657 if (s.success) return s.final_state();
659 sopt.rtol = lopt.rtol;
660 sopt.atol = lopt.atol;
661 // A Rosenbrock failure is raised, not swallowed: both integrators refusing
662 // the same leg is a statement about the model, and answering it with the
663 // partial trajectory of either one would be a fabricated fixed point.
664 const OdeSolution<double> ss = fluid_ode_solve_stiff(f, t0, t1, y0, sopt);
665 return ss.final_state();
666}
667
668/**
669 * The same retry over a whole output grid, for the callers that ask LSODA for a
670 * trajectory rather than an endpoint.
671 *
672 * `lsoda_integrate` stops at the FIRST failed interval and returns the grid it
673 * reached, so a caller that reads `s.y` without reading `s.success` silently
674 * publishes a trajectory that stops short of the horizon. On failure the grid is
675 * re-walked one leg at a time through `fluid_integrate_leg`, which restarts the
676 * integrator at every output point -- a small accuracy cost paid only where the
677 * single-call integration had already given up.
678 */
680 const std::function<void(double, const double*, double*)>& f,
681 const std::vector<double>& y0, const std::vector<double>& grid,
682 const LsodaOptions& lopt) {
683 LsodaSolution s = lsoda_integrate(f, y0, grid, fluid_lsoda(lopt));
684 if (s.success) return s;
685 LsodaSolution out;
686 out.t.assign(1, grid[0]);
687 out.y.assign(1, y0);
688 for (std::size_t j = 1; j < grid.size(); ++j) {
689 out.y.push_back(grid[j] == grid[j - 1]
690 ? out.y.back()
691 : fluid_integrate_leg(f, grid[j - 1], grid[j], out.y.back(), lopt));
692 out.t.push_back(grid[j]);
693 }
694 out.method = "rosenbrock4"; // the arm that finished it, not the one that gave up
695 return out;
696}
697
698} // namespace fluid
699} // namespace line
700
701#endif // LINE_SOLVERS_FLUID_FLUID_STIFF_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
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
Equilibrium distribution of a discrete-time Markov chain, and stochastic complementation.
The exception types the port throws.
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
Dense matrix and non-owning view.
std::vector< double > fluid_integrate_leg(const std::function< void(double, const double *, double *)> &f, double t0, double t1, const std::vector< double > &y0, const LsodaOptions &lopt)
One integration leg, with the reference's retry on a failed solve.
double fluid_immediate_state_tol()
Definition fluid_stiff.h:96
LsodaOptions fluid_lsoda(const LsodaOptions &lopt)
The step budget LSODA gets on a fluid leg before the stiff arm takes over.
LsodaSolution fluid_integrate_grid(const std::function< void(double, const double *, double *)> &f, const std::vector< double > &y0, const std::vector< double > &grid, const LsodaOptions &lopt)
The same retry over a whole output grid, for the callers that ask LSODA for a trajectory rather than ...
FluidImmediateMatrix< T > fluid_eliminate_immediate_matrix(const Matrix< T > &W, double imm_tol=fluid_immediate_state_tol())
Port of eliminate_immediate_matrix.m: the same elimination on a generator that is already assembled.
double fluid_immediate_transition_tol()
The reference's two thresholds, which do NOT agree and are not meant to.
Definition fluid_stiff.h:93
FluidImmediateResult fluid_eliminate_immediate(const FluidOdeSystem &sys, double imm_tol=fluid_immediate_transition_tol())
bool fluid_hide_immediate(const qn::NetworkStruct< T > &sn, const Opt &opt)
Stochastic complementation of the INSTANTANEOUS coordinates of a fluid drift, the twin of ode_elimina...
OdeSolution< double > fluid_ode_solve_stiff(const std::function< void(double, const double *, double *)> &f, double t0, double t1, const std::vector< double > &y0, const FluidStiffOptions &opt=FluidStiffOptions())
Port of ode_solve_stiff.m.
StochCompResult< T > ctmc_stochcomp(const Matrix< T > &Q, const std::vector< std::size_t > &I)
Definition dtmc_solve.h:150
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
LsodaSolution lsoda_integrate(const LsodaRhs &f, const std::vector< double > &y0, const std::vector< double > &t_eval, const LsodaOptions &opt=LsodaOptions())
Definition lsoda.h:178
Matrix< T > inverse(const Matrix< T > &A)
Inverse by LU with one factorization and n back substitutions.
Definition linalg.h:72
Matrix< T > matmul(const Matrix< T > &A, const Matrix< T > &B)
Matrix product A B.
Definition linalg.h:36
A queueing network and its refreshed NetworkStruct.
Adaptive stiff ODE integrator: a four-stage Rosenbrock method of order four with an embedded order-th...
Integration controls.
Definition lsoda.h:64
std::size_t max_steps
internal steps between output points
Definition lsoda.h:79
double atol
absolute tolerance, applied to every component
Definition lsoda.h:66
double rtol
relative tolerance, applied to every component
Definition lsoda.h:65
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
bool success
false when LSODA returned istate < 0
Definition lsoda.h:132
const std::vector< double > & final_state() const
Definition lsoda.h:137
std::vector< std::vector< double > > y
y[i] is the state at t[i]
Definition lsoda.h:128
std::vector< double > t
output times, t[0] = t_eval[0]
Definition lsoda.h:127
std::string method
The method in force at the end: "adams" (nonstiff) or "bdf" (stiff).
Definition lsoda.h:135
Integration controls.
Definition ode.h:118
bool store_trajectory
keep every accepted point, not just the last
Definition ode.h:125
T atol
absolute tolerance per component
Definition ode.h:120
std::function< bool(const T &, const std::vector< T > &)> step_stop
A test consulted after every ACCEPTED STEP; true ends the integration there, holding that state as th...
Definition ode.h:138
std::size_t max_steps
abort after this many accepted steps
Definition ode.h:124
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
const std::vector< T > & final_state() const
Definition ode.h:151
One event of the drift.
Definition fluid_odes.h:101
std::size_t event_idx
state entry whose g(x) drives this rate
Definition fluid_odes.h:104
double rate_base
the model-fixed part of the rate
Definition fluid_odes.h:105
What the matrix-level elimination produced.
Matrix< double > emap
emap(e, o): expected firings of the ORIGINAL event o per firing of the reduced event e; the identity ...
Matrix< double > absorb
Projector for the initial condition: identity on the timed rows, the absorption distribution on the i...
std::vector< std::size_t > state_map
reduced position -> original index, 0-based
Matrix< T > W
reduced, or the input on a fallback
What an elimination attempt produced.
Definition fluid_stiff.h:99
FluidOdeSystem sys
reduced, or the input on a fallback
std::vector< std::size_t > state_map
reduced position -> original index, 0-based
std::string fallback
Why the elimination was abandoned, empty when it was not attempted or when it succeeded.
Matrix< double > emap
emap(e, o): expected firings of the ORIGINAL event o per firing of the reduced event e; the identity ...
std::size_t n_immediate
immediate transitions detected
bool eliminated
false when the input is returned unchanged
Matrix< double > absorb
Projector for the initial condition: identity on the timed rows, the absorption distribution on the i...
std::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::size_t n_departures
How many leading entries of events are DEPARTURES (a job completing at one block and starting at anot...
Definition fluid_odes.h:206
std::vector< FluidEvent > events
Definition fluid_odes.h:197
Controls for the stiff arm.
std::function< bool(const double &, const std::vector< double > &)> step_stop
Per-accepted-step stop; see OdeOptions::step_stop and solver_fluid.h.
std::string solver
Refuses any named MATLAB solver: see the gate below.
double rtol
the value solver_fluid.h hands LSODA, FluidOptions::tol
bool stiff
options.stiff: keep the nonnegativity projection
static constexpr double Immediate
Rate of an Immediate distribution; its mean is 1/Immediate = 1e-8.
Definition lang_types.h:674