LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_odes.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_ODES_H
6#define LINE_SOLVERS_FLUID_FLUID_ODES_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The fluid drift: a port of `solver_fluid_odes.m` and the `ode_jumps_new` /
12 * `ode_rate_base` / `ode_rates_closing` triple it drives on the DEFAULT
13 * (`closing`) method.
14 *
15 * WHAT THE STATE VECTOR IS. One entry per (station, class, service phase),
16 * laid out station-major then class-major then phase, and holding the mean
17 * number of class-r jobs at station i currently in phase k. `q_indices(i,r)`
18 * is where that block starts and `Kic(i,r)` is how long it is; a (station,
19 * class) pair with no service contributes NO entries at all, which is why the
20 * layout has to be computed rather than assumed to be M*K*phases.
21 *
22 * WHY THE DRIFT FACTORS THE WAY IT DOES. Every event is a single job moving:
23 * it either completes at (i,c,ki) and starts at (j,l,kj), or it changes phase
24 * within (i,c). So each event's effect on the state is a vector with one -1
25 * and one +1, and the drift is
26 *
27 * dx/dt = sum over events of jump_e * rate_e(x)
28 *
29 * The reference stores those jumps as a dense (nstates x nevents) matrix and
30 * multiplies. THIS PORT STORES THE TWO INDICES INSTEAD, which is the same
31 * arithmetic -- the matrix has exactly two nonzeros per column -- while
32 * turning an O(nstates * nevents) product into O(nevents) per evaluation. On
33 * a model with a few hundred states that is the difference between a fluid
34 * solve dominated by the drift and one dominated by the integrator, and LSODA
35 * evaluates the drift thousands of times.
36 *
37 * The rate of an event factors into a part fixed by the model and a part that
38 * depends on the state:
39 *
40 * rate_e(x) = rateBase_e * g(x)_{eventIdx_e}
41 *
42 * `rateBase` folds the phase completion rate, the routing probability and the
43 * destination's entry-phase probability into one number computed once;
44 * `g(x)` is where the scheduling lives, and is the only thing re-evaluated.
45 * That split is the reference's and is what makes the drift cheap.
46 *
47 * WHAT g(x) IS, PER DISCIPLINE (`ode_rates_closing_factors`). g starts as x
48 * itself, which is already right for a delay and for any station whose
49 * population is below its server count, and is then corrected:
50 * INF nothing, unless a load-dependent alpha(n_i) scales the station
51 * EXT the source keeps unit mass per class, so the first phase absorbs
52 * whatever the other phases do not hold
53 * PS/FCFS the servers are shared: the block is scaled by psi(n_i)/n_i, with
54 * psi = min(n_i,c)*alpha(n_i) the work the station clears
55 * DPS the same capacity psi, split by the WEIGHTED share w_j x_j / sum
56 * GPS the server is split by weight among the BACKLOGGED classes, then
57 * equally among that class's own jobs
58 * Any other discipline is left as x, exactly as the reference leaves it -- which
59 * means it is integrated as an infinite server, and is why the featset gate
60 * refuses the disciplines that have no branch here.
61 *
62 * ALL THREE NON-LINEAR TERMS ABOVE TAKE A SECOND MOMENT WHEN ONE IS SUPPLIED
63 * (`FluidClosure`): min() through `fluid_capacity_closure`, the PS/DPS ratio
64 * through `fluid_share_closure`, the GPS indicator through `fluid_gps_share`.
65 * With no closure every one of them collapses to its value at the mean, so the
66 * first-order methods take exactly the same code path.
67 */
68
69#include <cmath>
70#include <cstddef>
71#include <functional>
72#include <string>
73#include <vector>
74
78#include "line/util/error.h"
80#include "line/util/matrix.h"
81
82namespace line {
83namespace fluid {
84
85/** Where each (station, class) block sits in the state vector. */
87 std::size_t nstates = 0; ///< length of the state vector
88 std::vector<std::vector<std::size_t>> qidx; ///< 0-based first index of (i,r)
89 std::vector<std::vector<std::size_t>> kic; ///< phases held by (i,r); 0 when disabled
90 std::vector<std::vector<bool>> enabled; ///< whether (i,r) is served at all
91};
92
93/**
94 * One event of the drift.
95 *
96 * `minus` and `plus` are the state entries the event moves a job out of and
97 * into. They can coincide -- a self-routing class completing and restarting in
98 * the same phase -- and then the event contributes nothing, which is exactly
99 * what the reference's jump column of all zeros contributes.
100 */
102 std::size_t minus = 0;
103 std::size_t plus = 0;
104 std::size_t event_idx = 0; ///< state entry whose g(x) drives this rate
105 double rate_base = 0.0; ///< the model-fixed part of the rate
106};
107
108/**
109 * The second moment the drift closes its non-linear terms with, i.e.
110 * `options.config.moment_sigma2` and `options.config.moment_cov`.
111 *
112 * EMPTY IS THE FIRST-ORDER CLOSURE, and not a missing input: every closure
113 * evaluates at the mean when it is given no variance, which is what the
114 * `closing`, `matrix`, `statedep`, `softmin` and `tbi` methods do. Only
115 * `solver_fluid_moments` fills this, and it fills it from the covariance it
116 * converged to rather than from the one its own solve produced -- see there for
117 * why the two must be the same one.
118 *
119 * `sigma2[i]` is the population variance of station i and closes its min(); the
120 * capacity SHARE is a ratio of coordinates, so closing it needs `cov[i]`, the
121 * covariance block over station i's own coordinates, and not just their total.
122 */
124 std::vector<double> sigma2; ///< per station; empty selects first order
125 std::vector<Matrix<double>> cov; ///< per station, 0x0 keeps the plug-in share
126 /** `any(sigma2 > 0)`, the reference's GLOBAL gaussian flag. */
127 bool gaussian() const {
128 for (std::size_t i = 0; i < sigma2.size(); ++i)
129 if (sigma2[i] > 0.0) return true;
130 return false;
131 }
132 double sigma2_of(std::size_t i) const { return i < sigma2.size() ? sigma2[i] : 0.0; }
133 const Matrix<double>* cov_of(std::size_t i) const {
134 if (i >= cov.size() || cov[i].rows() == 0) return nullptr;
135 return &cov[i];
136 }
137};
138
139/** The assembled drift: the layout, the events, and the per-station schedule. */
140/**
141 * Port of `solver_fluid_ratemult.m`'s output: a per-event MULTIPLIER on a time
142 * grid, evaluated by `fluid_interpcols`.
143 *
144 * WHY A MULTIPLIER AND NOT A SECOND DRIFT. The closing rate is
145 * `rate = rate_base .* theta(x)` and `rate_base` is LINEAR in the station-class
146 * service or arrival rate, so any time variation of that rate -- an NHPP source
147 * intensity, an inter-layer demand trajectory injected by the coupled LN
148 * transient -- reduces exactly to a scalar factor per event per instant. That is
149 * what lets three independent sources compose by elementwise product on the
150 * union of their grids instead of each needing its own drift.
151 *
152 * EMPTY IS THE AUTONOMOUS DRIFT, not a missing input. `fluid_drift` skips the
153 * lookup entirely when there is no multiplier, so a model with no time-varying
154 * source integrates exactly the function it integrated before this existed.
155 */
157 std::vector<double> tgrid; ///< strictly increasing, one column per entry
158 Matrix<double> Mmat; ///< (nevents x ngrid)
159 bool empty() const { return tgrid.empty() || Mmat.cols() == 0; }
160};
161
162/**
163 * Port of `fluid_interpcols.m`: clamped piecewise-linear interpolation of the
164 * columns of `B` at a scalar time, into `out`.
165 *
166 * CLAMPED, not extrapolated: a time outside the grid takes the boundary column
167 * (a zero-order hold). A trajectory supplied over [0, T] therefore holds its
168 * last value if the integrator steps past T, rather than continuing a linear
169 * ramp to a rate the caller never declared.
170 */
171inline void fluid_interpcols(const std::vector<double>& tg, const Matrix<double>& B, double tt,
172 std::vector<double>& out) {
173 const std::size_t nr = B.rows();
174 out.assign(nr, 1.0);
175 if (tg.empty() || B.cols() == 0) return;
176 if (tt <= tg.front()) {
177 for (std::size_t i = 0; i < nr; ++i) out[i] = B(i, 0);
178 return;
179 }
180 if (tt >= tg.back()) {
181 for (std::size_t i = 0; i < nr; ++i) out[i] = B(i, B.cols() - 1);
182 return;
183 }
184 std::size_t j = 0;
185 for (std::size_t k = 0; k < tg.size(); ++k)
186 if (tg[k] <= tt) j = k;
187 if (j + 1 >= tg.size()) {
188 for (std::size_t i = 0; i < nr; ++i) out[i] = B(i, j);
189 return;
190 }
191 const double w = (tt - tg[j]) / (tg[j + 1] - tg[j]);
192 for (std::size_t i = 0; i < nr; ++i) out[i] = (1.0 - w) * B(i, j) + w * B(i, j + 1);
193}
194
197 std::vector<FluidEvent> events;
198 /**
199 * How many leading entries of `events` are DEPARTURES (a job completing at
200 * one block and starting at another); the rest are phase changes within a
201 * block. The passage-time construction needs the distinction, and it
202 * cannot be recovered by inspecting the indices -- a self-routing class
203 * produces a departure whose two endpoints lie in the same block, exactly
204 * like a phase change.
205 */
206 std::size_t n_departures = 0;
207 std::vector<lang::SchedStrategy> sched; ///< per station
208 std::vector<double> nservers; ///< per station, already finite
209 std::vector<std::vector<double>> weight; ///< per station, per class (DPS, GPS)
210 /**
211 * `sn.lldscaling(i,:)` per station, EMPTY when the station has none or when
212 * every entry is one -- the reference's `if all(lldrow == 1), lldrow = []`,
213 * which keeps a station without load dependence on the plain branch rather
214 * than on an interpolation that would return 1 anyway.
215 */
216 std::vector<std::vector<double>> lld;
217 /** The second moment the closures read; empty is the first-order drift. */
219 /** `solver_fluid_ratemult`'s multiplier; empty is the autonomous drift. */
221};
222
223namespace detail {
224
225/** True when the (station, class) pair has a usable service process. */
226template <class T>
227bool fluid_service_defined(const qn::NetworkStruct<T>& sn, std::size_t i, std::size_t r) {
228 if (sn.disabled[i][r]) return false;
229 const lang::Distrib<T>& d = sn.service[i][r];
230 return d.D0.rows() > 0 && d.D0.rows() == d.D0.cols();
231}
232
233/**
234 * mu and phi of a phase-type service process, as `Markovian.getMu`/`getPhi`
235 * define them: mu(k) is the total rate out of phase k and phi(k) the share of
236 * it that is a completion. The reference's guard for a zero diagonal -- an
237 * Immediate distribution -- is reproduced, since dividing by it would produce
238 * a NaN that then poisons the whole drift.
239 */
240template <class T>
241void fluid_mu_phi(const lang::Distrib<T>& d, std::vector<double>& mu, std::vector<double>& phi) {
242 const std::size_t n = d.D0.rows();
243 mu.assign(n, 0.0);
244 phi.assign(n, 0.0);
245 for (std::size_t k = 0; k < n; ++k) {
246 const double d0kk = num_traits<T>::to_double(d.D0(k, k));
247 double rowD1 = 0.0;
248 for (std::size_t j = 0; j < d.D1.cols(); ++j) rowD1 += num_traits<T>::to_double(d.D1(k, j));
249 mu[k] = -d0kk;
250 phi[k] = (d0kk == 0.0) ? 1.0 : rowD1 / (-d0kk);
251 }
252}
253
254/** The entry-phase distribution of a service process, `map_pie`. */
255template <class T>
256std::vector<double> fluid_pie(const lang::Distrib<T>& d) {
257 const std::size_t n = d.D0.rows();
258 if (n == 0) return std::vector<double>{1.0};
259 if (n == 1) return std::vector<double>{1.0};
260 mam::Map<double> m;
261 m.D0 = Matrix<double>(n, n, 0.0);
262 m.D1 = Matrix<double>(n, n, 0.0);
263 for (std::size_t a = 0; a < n; ++a)
264 for (std::size_t b = 0; b < n; ++b) {
265 m.D0(a, b) = num_traits<T>::to_double(d.D0(a, b));
266 m.D1(a, b) = num_traits<T>::to_double(d.D1(a, b));
267 }
268 return mam::map_pie(m);
269}
270
271} // namespace detail
272
273/**
274 * Port of the layout half of `solver_fluid_odes.m`.
275 *
276 * The cumulative index runs even over disabled pairs, which contribute zero
277 * phases: the reference records `q_indices` for them too, so that a later
278 * lookup of a disabled pair lands on the next block's start rather than out of
279 * range. Reproduced deliberately.
280 */
281template <class T>
283 const std::size_t M = sn.nstations, K = sn.nclasses;
284 FluidLayout L;
285 L.qidx.assign(M, std::vector<std::size_t>(K, 0));
286 L.kic.assign(M, std::vector<std::size_t>(K, 0));
287 L.enabled.assign(M, std::vector<bool>(K, false));
288 std::size_t cursor = 0;
289 for (std::size_t i = 0; i < M; ++i)
290 for (std::size_t r = 0; r < K; ++r) {
291 L.qidx[i][r] = cursor;
292 if (detail::fluid_service_defined(sn, i, r)) {
293 L.enabled[i][r] = true;
294 L.kic[i][r] = sn.service[i][r].D0.rows();
295 }
296 cursor += L.kic[i][r];
297 }
298 L.nstates = cursor;
299 return L;
300}
301
302/**
303 * Build the drift of `sn`: the port of `ode_jumps_new` and `ode_rate_base`
304 * fused into one pass.
305 *
306 * The two reference functions walk the same nested loops in the same order and
307 * their outputs are matched element by element, so building them together is
308 * the only way to keep them aligned by construction rather than by comment.
309 *
310 * `rt` is read in STATION space, by the STOCHASTIC COMPLEMENT of `sn.rt` on the
311 * station rows. `sn.rt` is indexed by stateful node, and the reference indexes
312 * it with `(i-1)*K+c` for i over stations, which is the same thing only when
313 * every stateful node is a station. Mapping the station index through
314 * `stateful_of_station` is not enough either: it reads the DIRECT station pair
315 * and so drops every path that traverses a non-station stateful node. On a
316 * cache model that is the whole flow -- Think -> Cache -> Queue has a zero
317 * direct entry -- and the drift then has no outgoing route at all, so the ODE
318 * returns its initial condition with every job parked at the reference station.
319 * Absorbing those nodes is exact because they hold no jobs.
320 */
321template <class T>
323 const std::size_t M = sn.nstations, K = sn.nclasses;
324 FluidOdeSystem sys;
325 sys.layout = fluid_layout(sn);
326 const FluidLayout& L = sys.layout;
327
328 // The service processes, lowered once to plain doubles.
329 std::vector<std::vector<std::vector<double>>> mu(M, std::vector<std::vector<double>>(K));
330 std::vector<std::vector<std::vector<double>>> phi(M, std::vector<std::vector<double>>(K));
331 std::vector<std::vector<std::vector<double>>> pie(M, std::vector<std::vector<double>>(K));
332 for (std::size_t i = 0; i < M; ++i)
333 for (std::size_t r = 0; r < K; ++r) {
334 if (!L.enabled[i][r]) {
335 pie[i][r] = std::vector<double>{1.0};
336 continue;
337 }
338 detail::fluid_mu_phi(sn.service[i][r], mu[i][r], phi[i][r]);
339 pie[i][r] = detail::fluid_pie(sn.service[i][r]);
340 }
341
342 // Station-space routing, rt((i,c) -> (j,l)), through the stochastic
343 // complement of the stateful-indexed matrix on the station rows.
344 const std::size_t S = sn.nof_stateful();
345 const bool have_rt = sn.rt.rows() == S * K;
346 std::vector<std::size_t> keep_idx;
347 keep_idx.reserve(M * K);
348 for (std::size_t i = 0; i < M; ++i) {
349 const std::size_t isf = sn.stateful_of_station(i + 1) - 1;
350 for (std::size_t c = 0; c < K; ++c) keep_idx.push_back(isf * K + c);
351 }
352 Matrix<double> rt_st(M * K, M * K, 0.0);
353 if (have_rt) {
354 Matrix<double> rt_full(S * K, S * K, 0.0);
355 for (std::size_t a = 0; a < S * K; ++a)
356 for (std::size_t b = 0; b < S * K; ++b)
357 rt_full(a, b) = num_traits<T>::to_double(sn.rt(a, b));
358 rt_st = mc::dtmc_stochcomp(rt_full, keep_idx);
359 }
360 const auto route = [&](std::size_t i, std::size_t c, std::size_t j, std::size_t l) -> double {
361 if (!have_rt) return 0.0;
362 return rt_st(i * K + c, j * K + l);
363 };
364
365 // ---- departures: (i,c,ki) completes and the job starts at (j,l,kj) -----
366 for (std::size_t i = 0; i < M; ++i)
367 for (std::size_t c = 0; c < K; ++c) {
368 if (!L.enabled[i][c]) continue;
369 for (std::size_t j = 0; j < M; ++j)
370 for (std::size_t l = 0; l < K; ++l) {
371 const double p = route(i, c, j, l);
372 if (!(p > 0.0)) continue;
373 for (std::size_t ki = 0; ki < L.kic[i][c]; ++ki)
374 for (std::size_t kj = 0; kj < L.kic[j][l]; ++kj) {
375 FluidEvent e;
376 e.minus = L.qidx[i][c] + ki;
377 e.plus = L.qidx[j][l] + kj;
378 e.event_idx = L.qidx[i][c] + ki;
379 const double pj = kj < pie[j][l].size() ? pie[j][l][kj] : 0.0;
380 e.rate_base = phi[i][c][ki] * mu[i][c][ki] * p * pj;
381 sys.events.push_back(e);
382 }
383 }
384 }
385
386 sys.n_departures = sys.events.size();
387
388 // ---- phase changes within (i,c): rate is the off-diagonal of D0 --------
389 for (std::size_t i = 0; i < M; ++i)
390 for (std::size_t c = 0; c < K; ++c) {
391 if (!L.enabled[i][c]) continue;
392 const lang::Distrib<T>& d = sn.service[i][c];
393 // EVERY source phase, the last included. Bounding ki at kic-1 is
394 // valid only for an acyclic PH and drops the LAST ROW of D0, which
395 // a general MAP or an MMPP2 carries: on the 2-phase MAP
396 // D0=[-5 1; 2 -4], D1=[3 1; 1 1] the missing row cost 14% of the
397 // arrival rate (2.7586 against the exact 3.2). MATLAB
398 // ode_jumps_new and the JAR PassageTimeODE both iterate all rows.
399 for (std::size_t ki = 0; ki < L.kic[i][c]; ++ki)
400 for (std::size_t kp = 0; kp < L.kic[i][c]; ++kp) {
401 if (kp == ki) continue;
402 FluidEvent e;
403 e.minus = L.qidx[i][c] + ki;
404 e.plus = L.qidx[i][c] + kp;
405 e.event_idx = L.qidx[i][c] + ki;
407 sys.events.push_back(e);
408 }
409 }
410
411 // ---- per-station scheduling data --------------------------------------
412 sys.sched.resize(M);
413 sys.nservers.resize(M);
414 sys.weight.assign(M, std::vector<double>(K, 1.0));
415 double closed_pop = 0.0;
416 for (std::size_t r = 0; r < K; ++r)
417 if (std::isfinite(sn.classes[r].population)) closed_pop += sn.classes[r].population;
418 for (std::size_t i = 0; i < M; ++i) {
419 sys.sched[i] = sn.stations[i].sched;
420 const double c = sn.stations[i].nservers;
421 // A delay has infinitely many servers; the reference substitutes the
422 // closed population, which is the most that can ever be in service.
423 sys.nservers[i] = std::isfinite(c) ? c : closed_pop;
424 if (sn.stations[i].sched == lang::SchedStrategy::DPS ||
425 sn.stations[i].sched == lang::SchedStrategy::GPS)
426 for (std::size_t r = 0; r < K && r < sn.stations[i].schedparam.size(); ++r)
427 sys.weight[i][r] = num_traits<T>::to_double(sn.stations[i].schedparam[r]);
428 }
429
430 // Load dependence, lowered per station and dropped where it is the identity.
431 sys.lld.assign(M, std::vector<double>());
432 for (std::size_t i = 0; i < M; ++i) {
433 const std::vector<T>& row = sn.stations[i].lldscaling;
434 bool all_one = true;
435 for (std::size_t k = 0; k < row.size(); ++k)
436 if (num_traits<T>::to_double(row[k]) != 1.0) all_one = false;
437 if (row.empty() || all_one) continue;
438 sys.lld[i].resize(row.size());
439 for (std::size_t k = 0; k < row.size(); ++k)
440 sys.lld[i][k] = num_traits<T>::to_double(row[k]);
441 }
442 return sys;
443}
444
445/**
446 * The reference's dense jump matrix D, (nstates x nevents), rebuilt from the
447 * two-index event form this port stores instead.
448 *
449 * `solver_fluid_odes.m` discards D once it has composed the right-hand side and
450 * the drift never needs it back, which is why the drift does not keep it. The
451 * covariance equation of the linear noise approximation does: its diffusion
452 * matrix is D*diag(r(x))*D', which cannot be recovered from F alone, and its
453 * reachable subspace is range(D). Built on demand, in the event order
454 * `fluid_ode_system` emits, so column e is event e.
455 */
457 Matrix<double> D(sys.layout.nstates, sys.events.size(), 0.0);
458 for (std::size_t e = 0; e < sys.events.size(); ++e) {
459 D(sys.events[e].minus, e) -= 1.0;
460 D(sys.events[e].plus, e) += 1.0;
461 }
462 return D;
463}
464
465/**
466 * Port of `ode_rates_closing_factors`: the state-dependent factor g(x), in place.
467 *
468 * `g` must already be a copy of `x` on entry, which is the reference's
469 * `rates = x` and is what makes INF and every under-loaded station correct
470 * with no work.
471 *
472 * THE CLOSURE VARIANCE IS READ FROM THE SYSTEM, not passed separately, because
473 * `fluid_drift` closes over the system alone and LSODA holds that callback for
474 * the whole integration; a closure supplied beside it would have to be captured
475 * somewhere else and could then disagree with the Jacobian, which reads it from
476 * here.
477 *
478 * WHAT THE `gaussian` FLAG IS. The reference's `any(sigma2 > 0)` is GLOBAL over
479 * the stations, not per station, so one station carrying a variance sends every
480 * PS/FCFS station down the closure branch -- with its own sigma2(i), which may be
481 * zero, in which case `fluid_capacity_closure` returns min(n_i,c) and the branch
482 * reproduces the first-order value. Reproduced as the same global flag so that
483 * the one place the two differ, the zero floor on a negative E[min], is reached
484 * on the same models as in the reference.
485 */
486inline void fluid_rates_closing_factors(const FluidOdeSystem& sys, const double* x,
487 std::vector<double>& g) {
488 const FluidLayout& L = sys.layout;
489 const std::size_t M = L.qidx.size();
490 const std::size_t K = M ? L.qidx[0].size() : 0;
491 const FluidClosure& cl = sys.closure;
492 const bool gaussian = cl.gaussian();
493
494 for (std::size_t i = 0; i < M; ++i) {
495 const std::vector<double>& lld = sys.lld[i];
496 const double s2 = cl.sigma2_of(i);
497 const Matrix<double>* Ci = cl.cov_of(i);
498 const std::size_t blo = K ? L.qidx[i][0] : 0;
499 const std::size_t bhi = K ? L.qidx[i][K - 1] + L.kic[i][K - 1] : 0; // one past the end
500 switch (sys.sched[i]) {
502 // Without load dependence each job is served at its own rate and
503 // the share is the identity; alpha(n_i) scales the whole station.
504 if (lld.empty()) break;
505 double ni = 0.0;
506 for (std::size_t p = blo; p < bhi; ++p) ni += x[p];
507 if (!(ni > 0.0)) break;
508 const ClosureValue h = fluid_capacity_closure(ni, sys.nservers[i], s2, lld, true);
509 for (std::size_t p = blo; p < bhi; ++p) g[p] = x[p] / ni * h.h;
510 break;
511 }
513 // The source holds unit mass per class at all times: phase one
514 // carries whatever the later phases do not.
515 for (std::size_t k = 0; k < K; ++k) {
516 if (!L.enabled[i][k]) continue;
517 const std::size_t b = L.qidx[i][k], n = L.kic[i][k];
518 double rest = 0.0;
519 for (std::size_t p = 1; p < n; ++p) rest += x[b + p];
520 g[b] = 1.0 - rest;
521 }
522 break;
523 }
526 if (K == 0) break;
527 double ni = 0.0;
528 for (std::size_t p = blo; p < bhi; ++p) ni += x[p];
529 if ((gaussian || !lld.empty()) && ni > 0.0) {
530 const ClosureValue h =
531 fluid_capacity_closure(ni, sys.nservers[i], s2, lld, false);
532 if (Ci == nullptr) {
533 for (std::size_t p = blo; p < bhi; ++p) g[p] = x[p] / ni * h.h;
534 } else {
535 // THE SHARE AND THE CAPACITY ARE CLOSED JOINTLY. What
536 // the station clears is S_j*psi(N), and both factors move
537 // with N, so the product needs Cov(S_j,N)*psi'(n) on top
538 // of the two separate closures; see `fluid_share_closure`.
539 // With unit weights this is the DPS branch below.
540 const std::size_t nb = bhi - blo;
541 std::vector<double> xb(x + blo, x + bhi), wv(nb, 1.0);
542 const ShareValue sh = fluid_share_closure(xb, wv, *Ci, false, true);
543 std::vector<double> rb(nb, 0.0);
544 for (std::size_t p = 0; p < nb; ++p)
545 rb[p] = sh.s[p] * h.h + h.dh * sh.cn[p];
546 fluid_project_rate(rb, xb, lld.empty(), h.h);
547 for (std::size_t p = 0; p < nb; ++p) g[blo + p] = rb[p];
548 }
549 } else if (ni > sys.nservers[i]) { // min = ni is handled by g = x
550 const double s = sys.nservers[i] / ni;
551 for (std::size_t p = blo; p < bhi; ++p) g[p] = x[p] * s;
552 }
553 break;
554 }
556 // DPS is PS with a weighted share: the class-k coordinates get
557 // w_k*x/xi of the station capacity psi(xi) instead of x/xi of it.
558 //
559 // THE DENOMINATOR CARRIES NO ADDITIVE GUARD. It used to seed the
560 // sum with mean(w) to keep the ratio finite on an empty station;
561 // that term never cancels, so the shares summed to
562 // 1 - mean(w)/xi instead of 1 and the utilization was depressed
563 // by that factor. The capacity was also taken as the full server
564 // count rather than psi(xi), so an underloaded station was served
565 // at full rate. Both are the PS/FCFS branch's rules here, guarded
566 // by xi > 0 the way that branch guards, and equal weights now
567 // reduce DPS to PS identically.
568 if (K == 0) break;
569 double wsum = 0.0;
570 for (std::size_t k = 0; k < K; ++k) wsum += sys.weight[i][k];
571 if (wsum <= 0.0) break;
572 const std::size_t nb = bhi - blo;
573 std::vector<double> wv(nb, 0.0);
574 for (std::size_t k = 0; k < K; ++k) {
575 if (!L.enabled[i][k]) continue;
576 const std::size_t b = L.qidx[i][k] - blo;
577 for (std::size_t p = 0; p < L.kic[i][k]; ++p)
578 wv[b + p] = sys.weight[i][k] / wsum;
579 }
580 double xi = 0.0, wx = 0.0;
581 for (std::size_t p = 0; p < nb; ++p) {
582 xi += x[blo + p];
583 wx += wv[p] * x[blo + p];
584 }
585 if (!(xi > 0.0) || !(wx > 0.0)) break;
586 const ClosureValue psi =
587 fluid_capacity_closure(xi, sys.nservers[i], s2, lld, false);
588 const std::vector<double> xb(x + blo, x + bhi);
590 xb, wv, Ci ? *Ci : Matrix<double>(0, 0, 0.0), false, true);
591 std::vector<double> rb(nb, 0.0);
592 for (std::size_t p = 0; p < nb; ++p)
593 rb[p] = sh.s[p] * psi.h + psi.dh * sh.cn[p];
594 fluid_project_rate(rb, xb, lld.empty(), psi.h);
595 for (std::size_t p = 0; p < nb; ++p) g[blo + p] = rb[p];
596 break;
597 }
599 // GPS splits the server by WEIGHT among the BACKLOGGED classes,
600 // then equally among that class's own jobs. The share is a
601 // function of the backlog indicator, so `fluid_gps_share` closes
602 // it over the 2^K patterns using P(X_k >= 1). No capacity term
603 // multiplies it: GPS is single-server and the indicator already
604 // carries the idle server, so the shares sum to
605 // 1 - P(station empty) by design.
606 if (K == 0) break;
607 if (sys.nservers[i] > 1.0)
608 throw UnsupportedError(
609 "ode_rates_closing_factors: multi-server GPS stations are not supported, as "
610 "in the reference: the backlog closure splits ONE server by weight");
611 std::vector<double> xk(K, 0.0), vk(K, 0.0), wk(K, 0.0);
612 for (std::size_t k = 0; k < K; ++k) {
613 wk[k] = sys.weight[i][k];
614 if (!L.enabled[i][k]) continue;
615 const std::size_t b = L.qidx[i][k], n = L.kic[i][k];
616 for (std::size_t p = 0; p < n; ++p) xk[k] += x[b + p];
617 if (Ci == nullptr) continue;
618 double v = 0.0;
619 for (std::size_t p = 0; p < n; ++p)
620 for (std::size_t q = 0; q < n; ++q)
621 v += (*Ci)(b - blo + p, b - blo + q);
622 vk[k] = std::max(0.0, v);
623 }
624 const ShareValue sk = fluid_gps_share(xk, wk, vk, false);
625 double a = 1.0;
626 if (!lld.empty()) {
627 double ni = 0.0;
628 for (std::size_t p = blo; p < bhi; ++p) ni += x[p];
629 a = fluid_lld_scaling(lld, ni).h;
630 }
631 for (std::size_t k = 0; k < K; ++k) {
632 if (!L.enabled[i][k] || !(xk[k] > 0.0)) continue;
633 const std::size_t b = L.qidx[i][k], n = L.kic[i][k];
634 for (std::size_t p = 0; p < n; ++p)
635 g[b + p] = x[b + p] / xk[k] * sk.s[k] * a;
636 }
637 break;
638 }
639 default:
640 break; // as the reference leaves it: g = x
641 }
642 }
643}
644
645/** The reference's `ode_rates_closing` name, kept for the first-order callers. */
646inline void fluid_rates_closing(const FluidOdeSystem& sys, const double* x, std::vector<double>& g) {
648}
649
650/**
651 * The drift dx/dt, ready to hand to the integrator.
652 *
653 * Returned by value as a closure over a copy of the system, so the caller can
654 * let the builder go out of scope; LSODA holds the callback for the whole
655 * integration.
656 */
657inline std::function<void(double, const double*, double*)> fluid_drift(const FluidOdeSystem& sys) {
658 const std::size_t n = sys.layout.nstates;
659 return [sys, n](double t, const double* x, double* dx) {
660 std::vector<double> g(x, x + n);
661 fluid_rates_closing(sys, x, g);
662 for (std::size_t i = 0; i < n; ++i) dx[i] = 0.0;
663 // The autonomous arm is kept SEPARATE rather than multiplied by a vector
664 // of ones: the multiplier is absent on every model that has no
665 // time-varying source, and a per-step interpolation there would be paid
666 // by every fluid solve in the port for nothing.
667 if (sys.ratemult.empty()) {
668 for (const FluidEvent& e : sys.events) {
669 const double r = e.rate_base * g[e.event_idx];
670 if (r == 0.0) continue;
671 dx[e.minus] -= r;
672 dx[e.plus] += r;
673 }
674 return;
675 }
676 std::vector<double> mult;
677 fluid_interpcols(sys.ratemult.tgrid, sys.ratemult.Mmat, t, mult);
678 for (std::size_t k = 0; k < sys.events.size(); ++k) {
679 const FluidEvent& e = sys.events[k];
680 const double m = k < mult.size() ? mult[k] : 1.0;
681 const double r = m * e.rate_base * g[e.event_idx];
682 if (r == 0.0) continue;
683 dx[e.minus] -= r;
684 dx[e.plus] += r;
685 }
686 };
687}
688
689} // namespace fluid
690} // namespace line
691
692#endif // LINE_SOLVERS_FLUID_FLUID_ODES_H
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.
Stochastic complement of a DTMC partition, a port of matlab/lib/kpctoolbox/mc/dtmc_stochcomp....
The exception types the port throws.
The moment closures the fluid drift is built from: fluid_min_closure.m, fluid_capacity_closure....
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
ClosureValue fluid_lld_scaling(const std::vector< double > &lldrow, double n)
Port of fluid_lld_scaling.m: the limited load-dependent multiplier alpha at a CONTINUOUS population,...
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
ClosureValue fluid_capacity_closure(double n, double c, double s2, const std::vector< double > &lldrow, bool is_inf)
Port of fluid_capacity_closure.m: E[psi(X)] and its derivative, where psi(n) = min(n,...
void fluid_rates_closing_factors(const FluidOdeSystem &sys, const double *x, std::vector< double > &g)
Port of ode_rates_closing_factors: the state-dependent factor g(x), in place.
Definition fluid_odes.h:486
ShareValue fluid_share_closure(const std::vector< double > &x, const std::vector< double > &wv, const Matrix< double > &C, bool want_jac, bool want_cov=false)
Port of fluid_share_closure.m: E[w_j X_j / sum_m w_m X_m] by the delta method, and its Jacobian at fi...
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
std::function< void(double, const double *, double *)> fluid_drift(const FluidOdeSystem &sys)
The drift dx/dt, ready to hand to the integrator.
Definition fluid_odes.h:657
ShareValue fluid_gps_share(const std::vector< double > &xk, const std::vector< double > &wk_in, const std::vector< double > &vk, bool want_jac)
Port of fluid_gps_share.m: the expected capacity share of a GPS station under a normal marginal,...
void fluid_project_rate(std::vector< double > &r, const std::vector< double > &xb, bool capped, double tot)
Port of local_project_rate in ode_rates_closing_factors.m: project a jointly closed per-coordinate se...
Matrix< double > fluid_jump_matrix(const FluidOdeSystem &sys)
The reference's dense jump matrix D, (nstates x nevents), rebuilt from the two-index event form this ...
Definition fluid_odes.h:456
void fluid_interpcols(const std::vector< double > &tg, const Matrix< double > &B, double tt, std::vector< double > &out)
Port of fluid_interpcols.m: clamped piecewise-linear interpolation of the columns of B at a scalar ti...
Definition fluid_odes.h:171
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::vector< T > map_pie(const Map< T > &m)
Phase distribution seen by an arriving job, pie = pi D1 / (pi D1 e).
Definition map_moment.h:89
Matrix< T > dtmc_stochcomp(const Matrix< T > &P, const std::vector< std::size_t > &keep)
Stochastic complement of a DTMC partition, a port of matlab/lib/kpctoolbox/mc/dtmc_stochcomp....
A queueing network and its refreshed NetworkStruct.
A closure's value and its first two derivatives with respect to the first mean.
The second moment the drift closes its non-linear terms with, i.e.
Definition fluid_odes.h:123
std::vector< Matrix< double > > cov
per station, 0x0 keeps the plug-in share
Definition fluid_odes.h:125
const Matrix< double > * cov_of(std::size_t i) const
Definition fluid_odes.h:133
std::vector< double > sigma2
per station; empty selects first order
Definition fluid_odes.h:124
double sigma2_of(std::size_t i) const
Definition fluid_odes.h:132
bool gaussian() const
any(sigma2 > 0), the reference's GLOBAL gaussian flag.
Definition fluid_odes.h:127
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< bool > > enabled
whether (i,r) is served at all
Definition fluid_odes.h:90
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
std::vector< double > nservers
per station, already finite
Definition fluid_odes.h:208
FluidClosure closure
The second moment the closures read; empty is the first-order drift.
Definition fluid_odes.h:218
FluidRateMult ratemult
solver_fluid_ratemult's multiplier; empty is the autonomous drift.
Definition fluid_odes.h:220
std::vector< std::vector< double > > lld
sn.lldscaling(i,:) per station, EMPTY when the station has none or when every entry is one – the refe...
Definition fluid_odes.h:216
std::vector< FluidEvent > events
Definition fluid_odes.h:197
std::vector< std::vector< double > > weight
per station, per class (DPS, GPS)
Definition fluid_odes.h:209
std::vector< lang::SchedStrategy > sched
per station
Definition fluid_odes.h:207
The assembled drift: the layout, the events, and the per-station schedule.
Definition fluid_odes.h:156
Matrix< double > Mmat
(nevents x ngrid)
Definition fluid_odes.h:158
std::vector< double > tgrid
strictly increasing, one column per entry
Definition fluid_odes.h:157
A share closure's value and Jacobian, and the joint-closure covariance.
std::vector< double > cn
std::vector< double > s
Matrix< T > D0
The (D0,D1) pair when the type carries one directly.
Definition lang_types.h:759