LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_passage.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_PASSAGE_H
6#define LINE_SOLVERS_FLUID_FLUID_PASSAGE_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Response-time distribution by tagged fluid: a port of
12 * `solver_fluid_passage_time.m`, which is what `@@SolverFLD/getCdfRespT`
13 * delegates to.
14 *
15 * THE IDEA. The fluid drift gives means, not distributions. To recover one,
16 * mark the fluid that is at the station of interest RIGHT NOW and watch it
17 * leave: if `z(t)` is how much of that marked fluid is still there at time t
18 * and `z(0)` is how much there was, then
19 *
20 * P(response time <= t) = 1 - z(t) / z(0)
21 *
22 * because a marked drop has completed exactly when it is no longer in the
23 * block. This is a passage time read off a deterministic trajectory, which is
24 * why it needs no state space.
25 *
26 * HOW THE MARKING IS DONE. The reference adds a whole extra CLASS to the model,
27 * a copy of class c that routes as c does everywhere except at the observed
28 * station, where it routes into the ORIGINAL classes. That is a rebuild of the
29 * routing table for a class that, by construction, can only ever hold mass at
30 * one station: the moment it completes there it becomes untagged. This port
31 * therefore adds the tagged block at THAT STATION ONLY and mirrors the events
32 * sourced there, which is the same system with none of the table surgery.
33 *
34 * WHAT THE TAGGED FLUID STILL PARTICIPATES IN. Its own service, and the
35 * station's occupancy: a processor-sharing station splits its capacity over
36 * everything present, marked or not, so the tagged block must be counted in
37 * `ni` or the marked fluid would drain as if the station were emptier than it
38 * is. That coupling is the reason this cannot be computed from the mean
39 * trajectory alone.
40 *
41 * THE INITIAL STATE, as the reference sets it: the whole (station, class)
42 * block is moved into PHASE ONE of the tagged block, and the original block
43 * starts empty. Fluid arriving afterwards is untagged and is not measured.
44 */
45
46#include <algorithm>
47#include <cmath>
48#include <cstddef>
49#include <vector>
50
54#include "line/util/error.h"
55#include "line/util/lsoda.h"
56
57namespace line {
58namespace fluid {
59
60/** The response-time CDF of one (station, class), sampled on a grid. */
62 std::vector<double> t;
63 std::vector<double> cdf;
64 double fluid0 = 0.0; ///< the marked mass at t = 0; zero means nothing to measure
65};
66
67/**
68 * Response-time CDF at station `ist` for class `cls`, both 1-based.
69 *
70 * `x_steady` is the converged fluid state the marking starts from -- the
71 * reference passes `options.init_sol = odeStateVec`, i.e. the steady state, so
72 * the distribution is the stationary one.
73 *
74 * `closure` is the variance the mean solve closed its drift at, i.e.
75 * `FluidSolution::closure`. This is a SECOND solve on that solve's fixed point,
76 * so it has to be driven by the same drift: closing `min(n_i,c_i)` at zero
77 * variance drains a station the mean solve holds below capacity at full rate,
78 * and the distribution then contradicts the mean the same solver reports. A
79 * default-constructed closure is the first-order drift, which is what every
80 * first-order method wants.
81 */
82template <class T>
83FluidPassage fluid_passage_time(const qn::NetworkStruct<T>& sn, const std::vector<double>& x_steady,
84 std::size_t ist, std::size_t cls, double tol = 1e-4,
85 std::size_t points = 201,
86 const FluidClosure& closure = FluidClosure()) {
87 const std::size_t M = sn.nstations, K = sn.nclasses;
88 if (ist == 0 || ist > M) throw InputError("fluid_passage_time: station out of range");
89 if (cls == 0 || cls > K) throw InputError("fluid_passage_time: class out of range");
90 const std::size_t i = ist - 1, c = cls - 1;
91
93 // Only the per-STATION variance travels: the marked block relabels a
94 // station population, which leaves `sigma2` meaningful, whereas the
95 // coordinate covariance is indexed by a layout the tagged block extends, so
96 // the class share stays the plug-in ratio.
97 sys.closure.sigma2 = closure.sigma2;
98 const FluidLayout& L = sys.layout;
99 if (x_steady.size() != L.nstates)
100 throw InputError("fluid_passage_time: the initial state has the wrong length");
101 if (sn.stations[i].nodetype == qn::NodeType::Source)
102 throw InputError("fluid_passage_time: a Source has no response time");
103
104 // THE DEGENERATE CURVE IS STILL A CURVE. A class that holds no fluid at
105 // this station completes instantly, and the reference reports that as F = 1
106 // over the SAME grid every other class gets (`RT{i,c,2} = ones(size(fullt))`
107 // when `fluid_c == 0`), not as a single point. A one-point curve is not a
108 // distribution a consumer can read: `diff(F)' * t(2:end)`, which is how the
109 // mean is taken off it, is empty there -- on cdf_respt_closed_threeclasses,
110 // whose Class2 and Class3 are declared with population 0, that aborted the
111 // example with "the size of the right side is 0-by-1". Two points carry the
112 // same law and the same mean (0) and are readable.
113 const double flat_end = 100.0 / detail::fluid_slow_rate(sn, sys.layout, tol);
114 const std::size_t P = L.kic[i][c];
115 FluidPassage out;
116 if (P == 0) { // the class is not served here: nothing to measure
117 out.t.push_back(0.0);
118 out.t.push_back(flat_end);
119 out.cdf.push_back(1.0);
120 out.cdf.push_back(1.0);
121 return out;
122 }
123
124 const std::size_t n = L.nstates; // untagged states
125 const std::size_t tag0 = n; // the tagged block starts here
126 const std::size_t nt = n + P; // augmented size
127
128 // The marked mass: the whole block, collapsed into phase one.
129 double fluid0 = 0.0;
130 for (std::size_t k = 0; k < P; ++k) fluid0 += x_steady[L.qidx[i][c] + k];
131 out.fluid0 = fluid0;
132 if (!(fluid0 > 0.0)) { // an empty block completes instantly
133 out.t.push_back(0.0);
134 out.t.push_back(flat_end);
135 out.cdf.push_back(1.0);
136 out.cdf.push_back(1.0);
137 return out;
138 }
139
140 std::vector<double> y0(nt, 0.0);
141 for (std::size_t s = 0; s < n; ++s) y0[s] = x_steady[s];
142 for (std::size_t k = 0; k < P; ++k) y0[L.qidx[i][c] + k] = 0.0; // block emptied
143 y0[tag0] = fluid0; // all into phase one
144
145 // The tagged copies of every event sourced at (i, c): a departure takes
146 // marked fluid OUT of the system being measured and delivers it untagged;
147 // a phase change keeps it marked.
148 struct TagEvent {
149 std::size_t minus, plus;
150 std::size_t event_idx;
151 double rate_base;
152 bool leaves; // true when the fluid stops being marked
153 };
154 std::vector<TagEvent> tev;
155 const std::size_t base = L.qidx[i][c];
156 for (std::size_t e = 0; e < sys.events.size(); ++e) {
157 const FluidEvent& ev = sys.events[e];
158 if (ev.event_idx < base || ev.event_idx >= base + P) continue; // not sourced here
159 const std::size_t k = ev.event_idx - base;
160 TagEvent t2;
161 t2.event_idx = tag0 + k;
162 t2.rate_base = ev.rate_base;
163 t2.minus = tag0 + k;
164 if (e < sys.n_departures) {
165 t2.plus = ev.plus; // arrives untagged wherever the class routes
166 t2.leaves = true;
167 } else {
168 t2.plus = tag0 + (ev.plus - base); // stays marked, next phase
169 t2.leaves = false;
170 }
171 tev.push_back(t2);
172 }
173
174 // The drift of the augmented system. The only subtlety is that station i's
175 // occupancy must include the tagged block, so its service sharing is right.
176 const std::size_t Kc = K;
177 const LsodaRhs f = [&sys, &L, &tev, i, c, base, tag0, P, n, nt, Kc](double, const double* x,
178 double* dx) {
179 std::vector<double> xb(x, x + n);
180 // Fold the marked mass back into the block it came from, purely to
181 // compute the station's occupancy and therefore its service share.
182 double tagsum = 0.0;
183 for (std::size_t k = 0; k < P; ++k) tagsum += x[tag0 + k];
184 std::vector<double> g(xb);
185 for (std::size_t k = 0; k < P; ++k) g[base + k] += x[tag0 + k];
186 std::vector<double> gg(g);
187 fluid_rates_closing(sys, g.data(), gg);
188 // The share the station gives to this block, as a factor.
189 double blk = 0.0, gblk = 0.0;
190 for (std::size_t k = 0; k < P; ++k) {
191 blk += g[base + k];
192 gblk += gg[base + k];
193 }
194 const double share = (blk > 0.0) ? gblk / blk : 1.0;
195
196 for (std::size_t s = 0; s < nt; ++s) dx[s] = 0.0;
197 // Untagged events, evaluated on the folded state so the untagged part
198 // of the block gets its correct share too.
199 for (const FluidEvent& ev : sys.events) {
200 double drive = gg[ev.event_idx];
201 if (ev.event_idx >= base && ev.event_idx < base + P) {
202 // Only the UNTAGGED part of this block drives untagged events.
203 const std::size_t k = ev.event_idx - base;
204 drive = xb[base + k] * share;
205 }
206 const double r = ev.rate_base * drive;
207 if (r == 0.0) continue;
208 dx[ev.minus] -= r;
209 dx[ev.plus] += r;
210 }
211 // Tagged events, driven by the marked mass at the same share.
212 for (const TagEvent& te : tev) {
213 const double r = te.rate_base * x[te.event_idx] * share;
214 if (r == 0.0) continue;
215 dx[te.minus] -= r;
216 dx[te.plus] += r;
217 }
218 (void)tagsum;
219 (void)Kc;
220 };
221
222 // Integrate until the marked fluid is gone. The horizon follows the
223 // reference: 100 events of the slowest rate, extended while mass remains.
224 const double min_rate = detail::fluid_slow_rate(sn, L, tol);
225 const double t_end = 100.0 / min_rate;
226
227 std::vector<double> grid(points);
228 for (std::size_t j = 0; j < points; ++j)
229 grid[j] = t_end * static_cast<double>(j) / static_cast<double>(points - 1);
230
231 LsodaOptions lopt;
232 lopt.rtol = tol;
233 lopt.atol = tol;
234
235 // THE GRID IS REFINED WHERE THE CDF JUMPS, and it has to be. The curve is
236 // read back by quadrature -- SolverLN's `moment3` takes its first three
237 // moments off it -- and a uniform grid over a horizon set by the SLOWEST
238 // rate resolves the fast rise near the origin with a handful of points, so
239 // every moment comes out biased HIGH: the mass that arrives inside the first
240 // interval is charged at that interval's midpoint. On a two-layer LQN the
241 // entry service times came out 1 to 7 per cent above the JAR's until this
242 // loop was added, with nothing in the output to say why.
243 //
244 // The rule is the reference's (SolverFluid.passageTime): while some adjacent
245 // pair of CDF values differs by more than `kMaxJump`, insert points inside
246 // the offending intervals and integrate again. The reference walks its own
247 // ODE output grid and refines ONE interval per round; here the grid is
248 // supplied to LSODA, so every offending interval is split in the same round
249 // and the whole trajectory is re-integrated -- same fixed point, fewer
250 // rounds. Both caps are bounds on work, not on accuracy: convergence is the
251 // jump test, and hitting a cap leaves a coarser curve rather than a wrong one.
252 const double kMaxJump = 0.0005;
253 const int kMaxRounds = 5;
254 const std::size_t kMaxPoints = 20001;
255 const std::size_t kSplit = 20;
256
257 auto integrate_on = [&](const std::vector<double>& g) {
258 const LsodaSolution s = fluid_integrate_grid(f, y0, g, lopt);
259 FluidPassage p;
260 p.fluid0 = fluid0;
261 p.t.reserve(s.y.size());
262 p.cdf.reserve(s.y.size());
263 for (std::size_t j = 0; j < s.y.size(); ++j) {
264 double z = 0.0;
265 for (std::size_t k = 0; k < P; ++k) z += std::max(0.0, s.y[j][tag0 + k]);
266 double v = 1.0 - z / fluid0;
267 if (v < 0.0) v = 0.0;
268 if (v > 1.0) v = 1.0;
269 p.t.push_back(s.t[j]);
270 p.cdf.push_back(v);
271 }
272 return p;
273 };
274
275 FluidPassage cur = integrate_on(grid);
276 for (int round = 0; round < kMaxRounds; ++round) {
277 if (cur.t.size() >= kMaxPoints) break;
278 std::vector<double> next;
279 next.reserve(cur.t.size() * 2);
280 bool refined = false;
281 for (std::size_t j = 0; j + 1 < cur.t.size(); ++j) {
282 next.push_back(cur.t[j]);
283 if (cur.cdf[j + 1] - cur.cdf[j] > kMaxJump && cur.t[j + 1] > cur.t[j]) {
284 refined = true;
285 const double dt = (cur.t[j + 1] - cur.t[j]) / static_cast<double>(kSplit);
286 for (std::size_t s = 1; s < kSplit; ++s) next.push_back(cur.t[j] + dt * s);
287 }
288 }
289 if (!cur.t.empty()) next.push_back(cur.t.back());
290 if (!refined || next.size() > kMaxPoints) break;
291 cur = integrate_on(next);
292 }
293
294 out.t.swap(cur.t);
295 out.cdf.swap(cur.cdf);
296 return out;
297}
298
299/**
300 * Port of `@@SolverFLD/getTranCdfPassT`: the same passage-time distribution
301 * started from the model's INITIAL state rather than from its steady state.
302 *
303 * IT IS THE SAME ALGORITHM AT A DIFFERENT STARTING POINT, which is exactly what
304 * the reference is: `getCdfRespT` passes the converged `odeStateVec`,
305 * `getTranCdfPassT` passes `solver_fluid_initsol(sn, options)`. The two are kept
306 * as separate named entry points because the quantity differs -- one is the
307 * stationary response-time law, the other the law seen by a job marked while the
308 * system is still where the model says it starts -- and a caller that had to
309 * assemble the second by hand would have to know that, which is what a name is
310 * for.
311 *
312 * WHAT IS NOT REPRODUCED, and it is a MODEL-LAYER restriction rather than an
313 * algorithmic one: the reference first collapses `sn.state` to the first row of
314 * its prior and ERRORS when more than one initial state carries non-zero prior
315 * mass, because a passage time from a mixture of starting states is not one
316 * distribution. `fluid_default_initsol` is the closed form of the single-state
317 * decode (see fluid_closing.h) and there is no prior to collapse here, so the
318 * refusal has no input in this port.
319 */
320template <class T>
322 std::size_t cls, double tol = 1e-4,
323 std::size_t points = 201) {
324 const FluidLayout L = fluid_layout(sn);
325 return fluid_passage_time(sn, detail::fluid_default_initsol(sn, L), ist, cls, tol, points);
326}
327
328} // namespace fluid
329} // namespace line
330
331#endif // LINE_SOLVERS_FLUID_FLUID_PASSAGE_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...
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
FluidPassage fluid_passage_time(const qn::NetworkStruct< T > &sn, const std::vector< double > &x_steady, std::size_t ist, std::size_t cls, double tol=1e-4, std::size_t points=201, const FluidClosure &closure=FluidClosure())
Response-time CDF at station ist for class cls, both 1-based.
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 ...
FluidOdeSystem fluid_ode_system(const qn::NetworkStruct< T > &sn)
Build the drift of sn: the port of ode_jumps_new and ode_rate_base fused into one pass.
Definition fluid_odes.h:322
FluidPassage fluid_tran_passage_time(const qn::NetworkStruct< T > &sn, std::size_t ist, std::size_t cls, double tol=1e-4, std::size_t points=201)
Port of @@SolverFLD/getTranCdfPassT: the same passage-time distribution started from the model's INIT...
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.
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
Integration controls.
Definition lsoda.h:64
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
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
The second moment the drift closes its non-linear terms with, i.e.
Definition fluid_odes.h:123
std::vector< double > sigma2
per station; empty selects first order
Definition fluid_odes.h:124
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::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
FluidClosure closure
The second moment the closures read; empty is the first-order drift.
Definition fluid_odes.h:218
std::vector< FluidEvent > events
Definition fluid_odes.h:197
The response-time CDF of one (station, class), sampled on a grid.
std::vector< double > t
std::vector< double > cdf
double fluid0
the marked mass at t = 0; zero means nothing to measure