LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_tbi.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_TBI_H
6#define LINE_SOLVERS_FLUID_FLUID_TBI_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The `tbi` method: a port of `solver_fluid_tbi_iteration.m` and
12 * `tbi_partition.m`.
13 *
14 * WHAT TBI IS FOR. The closing drift couples every station to every other, so
15 * one integration works on the whole state vector at once and its cost grows
16 * with the model. Time-based iteration splits the stations into CELLS, solves
17 * each cell's sub-drift on its own, and treats the flow arriving from the other
18 * cells as a KNOWN FUNCTION OF TIME, frozen at the previous sweep's
19 * trajectories. Sweeping until the trajectories stop moving recovers the
20 * coupled solution, while each solve only ever sees one cell's states. It is a
21 * domain decomposition in time, and it pays off when the model is too large
22 * for one integration to be comfortable.
23 *
24 * THE PARTITION is the reference's greedy merge: start with one station per
25 * cell and repeatedly merge the pair with the largest routing coupling
26 * (W + W', diagonal dropped) whose combined size stays within twice the target
27 * cell size, until the cell count reaches ceil(M / cellsize) with cellsize 5.
28 * When nothing can be merged within the cap, the two smallest cells are merged
29 * instead so the loop always terminates.
30 *
31 * GAUSS-SEIDEL BY DEFAULT. After a cell is solved, the frozen rates of ITS
32 * events are refreshed immediately, so later cells in the same sweep already
33 * see the update. The reference offers a Jacobi variant for parallel runs;
34 * this port implements the sequential Gauss-Seidel default, which is what the
35 * reference selects when it is not asked for parallelism.
36 *
37 * ONE DELIBERATE SIMPLIFICATION, and what it costs. The reference carries
38 * whatever output grid its ODE solver happens to produce, takes the union of
39 * the cells' grids and interpolates every cell onto it. This port integrates
40 * every cell on ONE FIXED GRID per segment instead, so the sweeps compare
41 * trajectories sampled at the same instants and no interpolation of one cell
42 * onto another's grid is needed. The frozen inbound drift is still linearly
43 * interpolated between grid points, exactly as `fluid_interpcols` does.
44 *
45 * THE GRID IS THE ACCURACY KNOB, and the error it leaves is measurable. Only
46 * the EXTERNAL contribution is approximated -- each cell's own drift is
47 * integrated exactly -- so the residual behaves like the interpolation error,
48 * falling roughly quadratically as the grid is refined. On a ten-queue model
49 * whose exact population is 4, the closed-form closing solve gives 4.000000
50 * and this decomposition gives
51 *
52 * grid 16 65 129 257
53 * pop 4.167 4.0222 4.00285 3.99936
54 *
55 * so the default of 129 holds the population to under a tenth of a percent.
56 * A model that needs more can raise `TbiOptions::grid`; nothing else changes,
57 * because the fixed point being chased is the same coupled ODE either way.
58 *
59 * A SINGLE CELL IS THE CLOSING METHOD. With M <= cellsize the partition has one
60 * cell, there are no external events, the inbound drift is identically zero and
61 * the cell drift IS the closing drift. That is the case the tests pin, because
62 * it is the one where TBI has an independent right answer to be checked against.
63 */
64
65#include <algorithm>
66#include <cmath>
67#include <cstddef>
68#include <vector>
69
73#include "line/util/lsoda.h"
74
75namespace line {
76namespace fluid {
77
78/** Port of `tbi_partition.m`: stations grouped by routing coupling. */
79template <class T>
80std::vector<std::vector<std::size_t>> tbi_partition(const qn::NetworkStruct<T>& sn,
81 std::size_t cellsize = 5) {
82 const std::size_t M = sn.nstations, K = sn.nclasses;
83 std::vector<std::vector<std::size_t>> cells;
84 for (std::size_t i = 0; i < M; ++i) cells.push_back(std::vector<std::size_t>{i});
85 if (cellsize == 0) return cells;
86 const std::size_t target = std::max<std::size_t>(1, (M + cellsize - 1) / cellsize);
87 if (cells.size() <= target) return cells;
88
89 // Coupling: the total routing mass between two stations, symmetrized.
90 const std::size_t S = sn.nof_stateful();
91 Matrix<double> C(M, M, 0.0);
92 if (sn.rt.rows() == S * K)
93 for (std::size_t i = 0; i < M; ++i) {
94 const std::size_t si = sn.stateful_of_station(i + 1) - 1;
95 for (std::size_t j = 0; j < M; ++j) {
96 const std::size_t sj = sn.stateful_of_station(j + 1) - 1;
97 double w = 0.0;
98 for (std::size_t a = 0; a < K; ++a)
99 for (std::size_t b = 0; b < K; ++b)
100 w += num_traits<T>::to_double(sn.rt(si * K + a, sj * K + b));
101 C(i, j) += w;
102 C(j, i) += w;
103 }
104 }
105 for (std::size_t i = 0; i < M; ++i) C(i, i) = 0.0;
106
107 while (cells.size() > target) {
108 const std::size_t n = cells.size();
109 double best = -1.0;
110 std::size_t ba = n, bb = n;
111 for (std::size_t a = 0; a < n; ++a)
112 for (std::size_t b = a + 1; b < n; ++b) {
113 if (cells[a].size() + cells[b].size() > 2 * cellsize) continue;
114 if (C(a, b) > best) {
115 best = C(a, b);
116 ba = a;
117 bb = b;
118 }
119 }
120 if (ba == n) { // nothing fits the cap: merge the two smallest
121 std::vector<std::size_t> ord(n);
122 for (std::size_t i = 0; i < n; ++i) ord[i] = i;
123 std::sort(ord.begin(), ord.end(),
124 [&](std::size_t p, std::size_t q) { return cells[p].size() < cells[q].size(); });
125 ba = std::min(ord[0], ord[1]);
126 bb = std::max(ord[0], ord[1]);
127 }
128 cells[ba].insert(cells[ba].end(), cells[bb].begin(), cells[bb].end());
129 for (std::size_t k = 0; k < n; ++k) {
130 C(ba, k) += C(bb, k);
131 C(k, ba) += C(k, bb);
132 }
133 C(ba, ba) = 0.0;
134 // Drop row/column bb by compacting into a fresh matrix.
135 Matrix<double> C2(n - 1, n - 1, 0.0);
136 for (std::size_t a = 0, aa = 0; a < n; ++a) {
137 if (a == bb) continue;
138 for (std::size_t b = 0, bbi = 0; b < n; ++b) {
139 if (b == bb) continue;
140 C2(aa, bbi) = C(a, b);
141 ++bbi;
142 }
143 ++aa;
144 }
145 C = C2;
146 cells.erase(cells.begin() + static_cast<long>(bb));
147 }
148 for (std::vector<std::size_t>& c : cells) std::sort(c.begin(), c.end());
149 return cells;
150}
151
152/** Controls of the time-based iteration, mirroring `options.config.tbi_*`. */
154 double tbi_tol = 1e-6; ///< sup-norm gap that ends a segment's sweeps
155 std::size_t tbi_iter_max = 200; ///< sweeps per segment
156 std::size_t cellsize = 5; ///< target stations per cell
157 std::size_t grid = 129; ///< sample points per segment (see the header)
158};
159
160/**
161 * Advance the state over [t0, t1] by time-based iteration.
162 *
163 * Returns the state at t1. `sys` is the ordinary closing system; TBI only
164 * changes HOW it is integrated, never what is integrated.
165 */
166inline std::vector<double> tbi_advance(const FluidOdeSystem& sys,
167 const std::vector<std::vector<std::size_t>>& cells,
168 const std::vector<double>& y0, double t0, double t1,
169 const TbiOptions& topt, const LsodaOptions& lopt) {
170 const FluidLayout& L = sys.layout;
171 const std::size_t n = L.nstates, ncells = cells.size();
172 const std::size_t K = L.qidx.empty() ? 0 : L.qidx[0].size();
173
174 // Which state entries belong to each cell, and which events are sourced
175 // inside it. An event whose driving state is outside the cell is external:
176 // its rate is frozen and its jump only matters where it lands inside.
177 std::vector<std::vector<std::size_t>> mask(ncells);
178 std::vector<std::vector<std::size_t>> eint(ncells), eext(ncells);
179 std::vector<std::vector<long>> g2l(ncells, std::vector<long>(n, -1));
180 for (std::size_t kc = 0; kc < ncells; ++kc) {
181 for (std::size_t i : cells[kc])
182 for (std::size_t r = 0; r < K; ++r)
183 for (std::size_t k = 0; k < L.kic[i][r]; ++k) mask[kc].push_back(L.qidx[i][r] + k);
184 std::sort(mask[kc].begin(), mask[kc].end());
185 for (std::size_t a = 0; a < mask[kc].size(); ++a) g2l[kc][mask[kc][a]] = static_cast<long>(a);
186 for (std::size_t e = 0; e < sys.events.size(); ++e) {
187 const bool inside = g2l[kc][sys.events[e].event_idx] >= 0;
188 if (inside) {
189 eint[kc].push_back(e);
190 } else if (g2l[kc][sys.events[e].minus] >= 0 || g2l[kc][sys.events[e].plus] >= 0) {
191 eext[kc].push_back(e); // lands in the cell but is driven outside
192 }
193 }
194 }
195
196 const std::size_t ng = std::max<std::size_t>(2, topt.grid);
197 std::vector<double> tgrid(ng);
198 for (std::size_t j = 0; j < ng; ++j)
199 tgrid[j] = t0 + (t1 - t0) * static_cast<double>(j) / static_cast<double>(ng - 1);
200
201 // Y[j] is the whole state at tgrid[j]; the first sweep freezes it at y0.
202 std::vector<std::vector<double>> Y(ng, y0);
203
204 for (std::size_t sweep = 0; sweep < topt.tbi_iter_max; ++sweep) {
205 std::vector<std::vector<double>> Ynew = Y;
206 double delta = 0.0;
207 for (std::size_t kc = 0; kc < ncells; ++kc) {
208 const std::size_t nl = mask[kc].size();
209 if (nl == 0) continue;
210
211 // The inbound drift on the grid, from events driven outside.
212 std::vector<std::vector<double>> B(ng, std::vector<double>(nl, 0.0));
213 for (std::size_t j = 0; j < ng; ++j) {
214 std::vector<double> g(Y[j]);
215 fluid_rates_closing(sys, Y[j].data(), g);
216 for (std::size_t e : eext[kc]) {
217 const FluidEvent& ev = sys.events[e];
218 const double rate = ev.rate_base * g[ev.event_idx];
219 if (rate == 0.0) continue;
220 if (g2l[kc][ev.minus] >= 0) B[j][static_cast<std::size_t>(g2l[kc][ev.minus])] -= rate;
221 if (g2l[kc][ev.plus] >= 0) B[j][static_cast<std::size_t>(g2l[kc][ev.plus])] += rate;
222 }
223 }
224
225 // The cell's own drift, plus the frozen inbound drift interpolated
226 // linearly in time -- the port of `fluid_interpcols`.
227 const std::vector<std::size_t>& mk = mask[kc];
228 const std::vector<std::size_t>& ei = eint[kc];
229 const std::vector<long>& gl = g2l[kc];
230 std::vector<double> full(n, 0.0);
231 const LsodaRhs f = [&sys, &mk, &ei, &gl, &B, &tgrid, ng, nl, n,
232 &full](double t, const double* xc, double* dxc) {
233 std::vector<double> x(n, 0.0);
234 for (std::size_t a = 0; a < nl; ++a) x[mk[a]] = xc[a];
235 std::vector<double> g(x);
236 fluid_rates_closing(sys, x.data(), g);
237 for (std::size_t a = 0; a < nl; ++a) dxc[a] = 0.0;
238 for (std::size_t e : ei) {
239 const FluidEvent& ev = sys.events[e];
240 const double rate = ev.rate_base * g[ev.event_idx];
241 if (rate == 0.0) continue;
242 if (gl[ev.minus] >= 0) dxc[static_cast<std::size_t>(gl[ev.minus])] -= rate;
243 if (gl[ev.plus] >= 0) dxc[static_cast<std::size_t>(gl[ev.plus])] += rate;
244 }
245 // linear interpolation of the frozen inbound drift
246 double u = (t - tgrid.front()) / (tgrid.back() - tgrid.front() + 1e-300);
247 u = std::min(1.0, std::max(0.0, u)) * static_cast<double>(ng - 1);
248 const std::size_t j0 = std::min<std::size_t>(ng - 2, static_cast<std::size_t>(u));
249 const double w = u - static_cast<double>(j0);
250 for (std::size_t a = 0; a < nl; ++a)
251 dxc[a] += (1.0 - w) * B[j0][a] + w * B[j0 + 1][a];
252 };
253
254 std::vector<double> yl(nl, 0.0);
255 for (std::size_t a = 0; a < nl; ++a) yl[a] = y0[mk[a]];
256 const LsodaSolution s = fluid_integrate_grid(f, yl, tgrid, lopt);
257 for (std::size_t j = 0; j < s.y.size() && j < ng; ++j)
258 for (std::size_t a = 0; a < nl; ++a) {
259 double v = s.y[j][a];
260 if (v < 0.0) v = 0.0;
261 delta = std::max(delta, std::fabs(v - Ynew[j][mk[a]]));
262 Ynew[j][mk[a]] = v;
263 // Gauss-Seidel: the next cell of this sweep already sees it.
264 Y[j][mk[a]] = v;
265 }
266 }
267 Y = Ynew;
268 if (delta < topt.tbi_tol) break;
269 }
270 return Y.back();
271}
272
273} // namespace fluid
274} // namespace line
275
276#endif // LINE_SOLVERS_FLUID_FLUID_TBI_H
A network plus its refreshed NetworkStruct.
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
Port of ode_eliminate_immediate.m, eliminate_immediate_matrix.m and ode_solve_stiff....
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
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 ...
std::vector< std::vector< std::size_t > > tbi_partition(const qn::NetworkStruct< T > &sn, std::size_t cellsize=5)
Port of tbi_partition.m: stations grouped by routing coupling.
Definition fluid_tbi.h:80
std::vector< double > tbi_advance(const FluidOdeSystem &sys, const std::vector< std::vector< std::size_t > > &cells, const std::vector< double > &y0, double t0, double t1, const TbiOptions &topt, const LsodaOptions &lopt)
Advance the state over [t0, t1] by time-based iteration.
Definition fluid_tbi.h:166
void fluid_rates_closing(const FluidOdeSystem &sys, const double *x, std::vector< double > &g)
The reference's ode_rates_closing name, kept for the first-order callers.
Definition fluid_odes.h:646
std::function< void(double t, const double *y, double *dydt)> LsodaRhs
The right-hand side dy/dt = f(t, y).
Definition lsoda.h:54
A queueing network and its refreshed NetworkStruct.
Integration controls.
Definition lsoda.h:64
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
std::vector< std::vector< double > > y
y[i] is the state at t[i]
Definition lsoda.h:128
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
Where each (station, class) block sits in the state vector.
Definition fluid_odes.h:86
std::vector< std::vector< std::size_t > > qidx
0-based first index of (i,r)
Definition fluid_odes.h:88
std::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::vector< std::vector< std::size_t > > kic
phases held by (i,r); 0 when disabled
Definition fluid_odes.h:89
std::vector< FluidEvent > events
Definition fluid_odes.h:197
Controls of the time-based iteration, mirroring options.config.tbi_*.
Definition fluid_tbi.h:153
std::size_t grid
sample points per segment (see the header)
Definition fluid_tbi.h:157
std::size_t tbi_iter_max
sweeps per segment
Definition fluid_tbi.h:155
double tbi_tol
sup-norm gap that ends a segment's sweeps
Definition fluid_tbi.h:154
std::size_t cellsize
target stations per cell
Definition fluid_tbi.h:156