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 <string>
69#include <vector>
70
74#include "line/util/error.h"
75#include "line/util/lsoda.h"
76
77namespace line {
78namespace fluid {
79
80/**
81 * The `options.config.tbi_cells` branch of `tbi_partition.m`: an explicit
82 * partition, returned as given once it is checked to cover every station
83 * exactly once. Indices are 0-based.
84 */
85template <class T>
86std::vector<std::vector<std::size_t>> tbi_check_partition(
87 const qn::NetworkStruct<T>& sn, const std::vector<std::vector<std::size_t>>& cells) {
88 std::vector<std::size_t> covered;
89 for (std::size_t c = 0; c < cells.size(); ++c)
90 covered.insert(covered.end(), cells[c].begin(), cells[c].end());
91 std::sort(covered.begin(), covered.end());
92 bool ok = covered.size() == sn.nstations;
93 for (std::size_t i = 0; ok && i < covered.size(); ++i) ok = covered[i] == i;
94 if (!ok)
95 throw InputError("tbi_partition: options.tbi_cells must be a partition of the station set "
96 "0.." + std::to_string(sn.nstations == 0 ? 0 : sn.nstations - 1) +
97 " (0-based)");
98 return cells;
99}
100
101/** Port of `tbi_partition.m`: stations grouped by routing coupling. */
102template <class T>
103std::vector<std::vector<std::size_t>> tbi_partition(const qn::NetworkStruct<T>& sn,
104 std::size_t cellsize = 5) {
105 const std::size_t M = sn.nstations, K = sn.nclasses;
106 std::vector<std::vector<std::size_t>> cells;
107 for (std::size_t i = 0; i < M; ++i) cells.push_back(std::vector<std::size_t>{i});
108 if (cellsize == 0) return cells;
109 const std::size_t target = std::max<std::size_t>(1, (M + cellsize - 1) / cellsize);
110 if (cells.size() <= target) return cells;
111
112 // Coupling: the total routing mass between two stations, symmetrized.
113 const std::size_t S = sn.nof_stateful();
114 Matrix<double> C(M, M, 0.0);
115 if (sn.rt.rows() == S * K)
116 for (std::size_t i = 0; i < M; ++i) {
117 const std::size_t si = sn.stateful_of_station(i + 1) - 1;
118 for (std::size_t j = 0; j < M; ++j) {
119 const std::size_t sj = sn.stateful_of_station(j + 1) - 1;
120 double w = 0.0;
121 for (std::size_t a = 0; a < K; ++a)
122 for (std::size_t b = 0; b < K; ++b)
123 w += num_traits<T>::to_double(sn.rt(si * K + a, sj * K + b));
124 C(i, j) += w;
125 C(j, i) += w;
126 }
127 }
128 for (std::size_t i = 0; i < M; ++i) C(i, i) = 0.0;
129
130 while (cells.size() > target) {
131 const std::size_t n = cells.size();
132 double best = -1.0;
133 std::size_t ba = n, bb = n;
134 for (std::size_t a = 0; a < n; ++a)
135 for (std::size_t b = a + 1; b < n; ++b) {
136 if (cells[a].size() + cells[b].size() > 2 * cellsize) continue;
137 if (C(a, b) > best) {
138 best = C(a, b);
139 ba = a;
140 bb = b;
141 }
142 }
143 if (ba == n) { // nothing fits the cap: merge the two smallest
144 std::vector<std::size_t> ord(n);
145 for (std::size_t i = 0; i < n; ++i) ord[i] = i;
146 std::sort(ord.begin(), ord.end(),
147 [&](std::size_t p, std::size_t q) { return cells[p].size() < cells[q].size(); });
148 ba = std::min(ord[0], ord[1]);
149 bb = std::max(ord[0], ord[1]);
150 }
151 cells[ba].insert(cells[ba].end(), cells[bb].begin(), cells[bb].end());
152 for (std::size_t k = 0; k < n; ++k) {
153 C(ba, k) += C(bb, k);
154 C(k, ba) += C(k, bb);
155 }
156 C(ba, ba) = 0.0;
157 // Drop row/column bb by compacting into a fresh matrix.
158 Matrix<double> C2(n - 1, n - 1, 0.0);
159 for (std::size_t a = 0, aa = 0; a < n; ++a) {
160 if (a == bb) continue;
161 for (std::size_t b = 0, bbi = 0; b < n; ++b) {
162 if (b == bb) continue;
163 C2(aa, bbi) = C(a, b);
164 ++bbi;
165 }
166 ++aa;
167 }
168 C = C2;
169 cells.erase(cells.begin() + static_cast<long>(bb));
170 }
171 for (std::vector<std::size_t>& c : cells) std::sort(c.begin(), c.end());
172 return cells;
173}
174
175/** Controls of the time-based iteration, mirroring `options.config.tbi_*`. */
177 double tbi_tol = 1e-6; ///< sup-norm gap that ends a segment's sweeps
178 std::size_t tbi_iter_max = 200; ///< sweeps per segment
179 std::size_t cellsize = 5; ///< target stations per cell
180 std::size_t grid = 129; ///< sample points per segment (see the header)
181};
182
183/**
184 * Advance the state over [t0, t1] by time-based iteration.
185 *
186 * Returns the state at t1. `sys` is the ordinary closing system; TBI only
187 * changes HOW it is integrated, never what is integrated.
188 */
189inline std::vector<double> tbi_advance(const FluidOdeSystem& sys,
190 const std::vector<std::vector<std::size_t>>& cells,
191 const std::vector<double>& y0, double t0, double t1,
192 const TbiOptions& topt, const LsodaOptions& lopt) {
193 const FluidLayout& L = sys.layout;
194 const std::size_t n = L.nstates, ncells = cells.size();
195 const std::size_t K = L.qidx.empty() ? 0 : L.qidx[0].size();
196
197 // Which state entries belong to each cell, and which events are sourced
198 // inside it. An event whose driving state is outside the cell is external:
199 // its rate is frozen and its jump only matters where it lands inside.
200 std::vector<std::vector<std::size_t>> mask(ncells);
201 std::vector<std::vector<std::size_t>> eint(ncells), eext(ncells);
202 std::vector<std::vector<long>> g2l(ncells, std::vector<long>(n, -1));
203 for (std::size_t kc = 0; kc < ncells; ++kc) {
204 for (std::size_t i : cells[kc])
205 for (std::size_t r = 0; r < K; ++r)
206 for (std::size_t k = 0; k < L.kic[i][r]; ++k) mask[kc].push_back(L.qidx[i][r] + k);
207 std::sort(mask[kc].begin(), mask[kc].end());
208 for (std::size_t a = 0; a < mask[kc].size(); ++a) g2l[kc][mask[kc][a]] = static_cast<long>(a);
209 for (std::size_t e = 0; e < sys.events.size(); ++e) {
210 const bool inside = g2l[kc][sys.events[e].event_idx] >= 0;
211 if (inside) {
212 eint[kc].push_back(e);
213 } else if (g2l[kc][sys.events[e].minus] >= 0 || g2l[kc][sys.events[e].plus] >= 0) {
214 eext[kc].push_back(e); // lands in the cell but is driven outside
215 }
216 }
217 }
218
219 const std::size_t ng = std::max<std::size_t>(2, topt.grid);
220 std::vector<double> tgrid(ng);
221 for (std::size_t j = 0; j < ng; ++j)
222 tgrid[j] = t0 + (t1 - t0) * static_cast<double>(j) / static_cast<double>(ng - 1);
223
224 // Y[j] is the whole state at tgrid[j]; the first sweep freezes it at y0.
225 std::vector<std::vector<double>> Y(ng, y0);
226
227 for (std::size_t sweep = 0; sweep < topt.tbi_iter_max; ++sweep) {
228 std::vector<std::vector<double>> Ynew = Y;
229 double delta = 0.0;
230 for (std::size_t kc = 0; kc < ncells; ++kc) {
231 const std::size_t nl = mask[kc].size();
232 if (nl == 0) continue;
233
234 // The inbound drift on the grid, from events driven outside.
235 std::vector<std::vector<double>> B(ng, std::vector<double>(nl, 0.0));
236 for (std::size_t j = 0; j < ng; ++j) {
237 std::vector<double> g(Y[j]);
238 fluid_rates_closing(sys, Y[j].data(), g);
239 for (std::size_t e : eext[kc]) {
240 const FluidEvent& ev = sys.events[e];
241 const double rate = ev.rate_base * g[ev.event_idx];
242 if (rate == 0.0) continue;
243 if (g2l[kc][ev.minus] >= 0) B[j][static_cast<std::size_t>(g2l[kc][ev.minus])] -= rate;
244 if (g2l[kc][ev.plus] >= 0) B[j][static_cast<std::size_t>(g2l[kc][ev.plus])] += rate;
245 }
246 }
247
248 // The cell's own drift, plus the frozen inbound drift interpolated
249 // linearly in time -- the port of `fluid_interpcols`.
250 const std::vector<std::size_t>& mk = mask[kc];
251 const std::vector<std::size_t>& ei = eint[kc];
252 const std::vector<long>& gl = g2l[kc];
253 std::vector<double> full(n, 0.0);
254 const LsodaRhs f = [&sys, &mk, &ei, &gl, &B, &tgrid, ng, nl, n,
255 &full](double t, const double* xc, double* dxc) {
256 std::vector<double> x(n, 0.0);
257 for (std::size_t a = 0; a < nl; ++a) x[mk[a]] = xc[a];
258 std::vector<double> g(x);
259 fluid_rates_closing(sys, x.data(), g);
260 for (std::size_t a = 0; a < nl; ++a) dxc[a] = 0.0;
261 for (std::size_t e : ei) {
262 const FluidEvent& ev = sys.events[e];
263 const double rate = ev.rate_base * g[ev.event_idx];
264 if (rate == 0.0) continue;
265 if (gl[ev.minus] >= 0) dxc[static_cast<std::size_t>(gl[ev.minus])] -= rate;
266 if (gl[ev.plus] >= 0) dxc[static_cast<std::size_t>(gl[ev.plus])] += rate;
267 }
268 // linear interpolation of the frozen inbound drift
269 double u = (t - tgrid.front()) / (tgrid.back() - tgrid.front() + 1e-300);
270 u = std::min(1.0, std::max(0.0, u)) * static_cast<double>(ng - 1);
271 const std::size_t j0 = std::min<std::size_t>(ng - 2, static_cast<std::size_t>(u));
272 const double w = u - static_cast<double>(j0);
273 for (std::size_t a = 0; a < nl; ++a)
274 dxc[a] += (1.0 - w) * B[j0][a] + w * B[j0 + 1][a];
275 };
276
277 std::vector<double> yl(nl, 0.0);
278 for (std::size_t a = 0; a < nl; ++a) yl[a] = y0[mk[a]];
279 const LsodaSolution s = fluid_integrate_grid(f, yl, tgrid, lopt);
280 for (std::size_t j = 0; j < s.y.size() && j < ng; ++j)
281 for (std::size_t a = 0; a < nl; ++a) {
282 double v = s.y[j][a];
283 if (v < 0.0) v = 0.0;
284 delta = std::max(delta, std::fabs(v - Ynew[j][mk[a]]));
285 Ynew[j][mk[a]] = v;
286 // Gauss-Seidel: the next cell of this sweep already sees it.
287 Y[j][mk[a]] = v;
288 }
289 }
290 Y = Ynew;
291 if (delta < topt.tbi_tol) break;
292 }
293 return Y.back();
294}
295
296} // namespace fluid
297} // namespace line
298
299#endif // LINE_SOLVERS_FLUID_FLUID_TBI_H
InputError(const std::string &what)
Definition error.h:39
A network plus its refreshed NetworkStruct.
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...
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.
std::vector< std::vector< std::size_t > > tbi_check_partition(const qn::NetworkStruct< T > &sn, const std::vector< std::vector< std::size_t > > &cells)
The options.config.tbi_cells branch of tbi_partition.m: an explicit partition, returned as given once...
Definition fluid_tbi.h:86
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:103
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:189
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
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
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:176
std::size_t grid
sample points per segment (see the header)
Definition fluid_tbi.h:180
std::size_t tbi_iter_max
sweeps per segment
Definition fluid_tbi.h:178
double tbi_tol
sup-norm gap that ends a segment's sweeps
Definition fluid_tbi.h:177
std::size_t cellsize
target stations per cell
Definition fluid_tbi.h:179