LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_kp.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_KP_H
6#define LINE_SOLVERS_FLUID_FLUID_KP_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * Port of `solver_fluid_kp.m`: the fluid AND diffusion limits of the
12 * (MAP_t/Ph_t/inf)^N network of Y. M. Ko and J. Pender, "Diffusion limits for the
13 * (MAP_t/Ph_t/inf)^N queueing network", Oper. Res. Lett. 45 (2017) 248-253.
14 *
15 * The mean and the covariance are integrated JOINTLY:
16 *
17 * dq/dt = A f(t,q)
18 * dSigma/dt = J Sigma + Sigma J' + G, J = A df/dq, G = A diag(f) A'
19 *
20 * with A the jump matrix whose column e is the jump vector of event e. G is
21 * exactly dH dH' of the paper's Theorem 3.3, each independent Poisson term
22 * contributing l_e l_e' f_e. Where f is affine in q -- infinite-server stations
23 * and the arrival phase process -- J does not depend on q and both equations close
24 * EXACTLY, so for the (MAP/Ph/inf)^N case the mean and covariance are exact rather
25 * than asymptotic. Finite-server stations are admitted through the usual fluid
26 * min(x,c) term, where the covariance degrades to a linear noise approximation.
27 *
28 * THIS IS THE ONLY FLUID METHOD IN THE PORT THAT RETURNS A SECOND MOMENT FOR AN
29 * OPEN NETWORK, and it is the only one whose second moment is TRANSIENT rather
30 * than stationary -- `minnormal` solves a stationary Lyapunov equation, this
31 * integrates the covariance along the trajectory.
32 *
33 * IT DELIBERATELY DOES NOT REUSE THE CLOSING ODE. That formulation routes a
34 * departure from the source to the destination station and returns mass through
35 * the STATIONARY arrival-instant vector pie, replacing the D1' operator by the
36 * rank-one map pie*(D1*e)', i.e. by the PH renewal process with representation
37 * (pie, D0). Its stationary arrival rate is exact but its autocorrelation is gone,
38 * and a non-renewal arrival stream is the entire point of a MAP.
39 *
40 * WHAT THE `_t` OF (MAP_t/Ph_t/inf)^N IS. A `MAPt` or `PHt` carries a
41 * piecewise-constant (D0(t), D1(t)) schedule, and `kp_pair_at` returns the pair
42 * in force at time t -- the nominal pair for a process with no schedule, which
43 * is what the reference's `local_pair_at` returns in the same case. Three things
44 * follow from a schedule being present, and all three are consequences of the
45 * SAME fact, that a cyclic model has no fixed point:
46 *
47 * THE HORIZON. An unbounded timespan is resolved from the slowest rate, as
48 * before, but is then extended to at least ten full periods so that the
49 * trajectory has reached its periodic regime before anything is read off it.
50 *
51 * THE STEP CAP. LSODA picks its step for accuracy of the SOLUTION and will
52 * happily step over a whole segment of a schedule, integrating a rate that was
53 * never in force. `h_max` is capped at a quarter of the narrowest segment.
54 *
55 * THE STEADY-STATE ANSWER IS A TIME AVERAGE. The value at the horizon is an
56 * arbitrary point of the cycle, at which the source and the station throughput
57 * do not even agree. The metrics are instead the trapezoidal average over the
58 * last full period, on a mesh refined uniformly and BRACKETED at every segment
59 * boundary, so that no trapezoid interval straddles a jump in the arrival rate.
60 *
61 * A non-cyclic schedule has a fixed point again -- it is constant on its last
62 * segment -- so it takes none of the three, exactly as in the reference.
63 *
64 * State layout, station-major, arrival phases before service phases:
65 * u-block one per (EXT station, class): arrival MAP phase occupancy, sum 1
66 * x-block one per (queueing station, class): fluid count in each service phase
67 */
68
69#include <algorithm>
70#include <cmath>
71#include <cstddef>
72#include <limits>
73#include <string>
74#include <type_traits>
75#include <vector>
76
82#include "line/util/error.h"
83#include "line/util/lsoda.h"
84#include "line/util/matrix.h"
85
86namespace line {
87namespace fluid {
88
89/** One (station, class) block of the Ko-Pender state vector. */
90struct KpBlock {
91 std::size_t station = 0;
92 std::size_t cls = 0;
93 std::size_t offset = 0;
94 std::size_t nphases = 0;
95};
96
97/** The five event families of Ko-Pender (3.1)-(3.2). */
98enum class KpEventKind {
99 ArrivalPhase = 1, ///< A0: arrival-MAP phase change without an arrival
100 Arrival = 2, ///< A1: phase change WITH an arrival, into a service phase
101 ServicePhase = 3, ///< S: service phase change inside a station
102 Departure = 4, ///< D: completion leaving the network
103 Routed = 5 ///< R: completion routed onward
104};
105
106struct KpEvent {
108 std::size_t i = 0, c = 0; ///< source station and class
109 std::size_t k = 0, j = 0; ///< source and target phase of the modulating chain
110 std::size_t n = 0, l = 0; ///< destination station and class
111 std::size_t ip = 0; ///< destination entry phase
112 std::size_t off_src = 0; ///< offset of the source block
113 std::size_t off_dst = 0; ///< offset of the destination block
114 double weight = 1.0; ///< routing probability times entry-phase probability
115 /** The jump: -1 at `minus`, +1 at each `plus`; a phase change carries both. */
116 std::vector<std::size_t> minus, plus;
117};
118
119/** The transient the covariance equation produces, i.e. `getTranAvgVar`. */
121 std::vector<double> t;
122 std::vector<Matrix<double>> QVar; ///< per time point, (nstations x nclasses)
123 std::vector<Matrix<double>> Sigma; ///< per time point, (dim x dim)
124 /**
125 * `Sigma` AGGREGATED onto station-class pairs, per time point, (M*K)-by-(M*K)
126 * and indexed `ir = r*M + i`. Its diagonal is `QVar` and its off-diagonal
127 * entries carry the cross-station and cross-class terms `QVar` drops, in an
128 * index space a caller can use without knowing this method's phase layout:
129 * SolverENV's `meancov` coupling reads it and seeds the next stage through
130 * `FluidOptions::init_qlen`/`init_qcov`, which are in the same space.
131 */
132 std::vector<Matrix<double>> QCov;
133 /**
134 * The MEAN trajectory aggregated onto station-class pairs, per time point,
135 * (nstations x nclasses) -- the same series the steady table summarises. A
136 * caller that wants both moments on one grid (SolverENV's `meancov`) reads
137 * them here rather than re-deriving the block sums from `q`, which would
138 * need this method's phase layout.
139 */
140 std::vector<Matrix<double>> QN, UN, TN;
141 std::vector<std::vector<double>> q;
142};
143
144namespace kp_detail {
145
146/** The (D0, D1) pair of a station-class, lowered to double. */
147template <class T>
148void kp_pair(const qn::NetworkStruct<T>& sn, std::size_t i, std::size_t r, Matrix<double>& D0,
149 Matrix<double>& D1) {
150 const lang::Distrib<T>& d = sn.service[i][r];
151 const std::size_t n = d.D0.rows();
152 D0 = Matrix<double>(n, n, 0.0);
153 D1 = Matrix<double>(n, n, 0.0);
154 for (std::size_t a = 0; a < n; ++a)
155 for (std::size_t b = 0; b < n; ++b) {
156 D0(a, b) = num_traits<T>::to_double(d.D0(a, b));
157 D1(a, b) = num_traits<T>::to_double(d.D1(a, b));
158 }
159}
160
161/**
162 * The phase a job enters service in, normalised, with a uniform fallback when
163 * the process carries no usable arrival-instant vector.
164 */
165inline std::vector<double> kp_entry_vector(const std::vector<double>& pie, std::size_t h) {
166 double sum = 0.0;
167 bool ok = (pie.size() == h);
168 for (std::size_t a = 0; ok && a < h; ++a) {
169 if (!std::isfinite(pie[a])) ok = false;
170 sum += pie[a];
171 }
172 if (!ok || !(sum > 0.0)) return std::vector<double>(h, 1.0 / static_cast<double>(h));
173 std::vector<double> out(h, 0.0);
174 double tot = 0.0;
175 for (std::size_t a = 0; a < h; ++a) {
176 out[a] = std::max(0.0, pie[a]);
177 tot += out[a];
178 }
179 if (!(tot > 0.0)) return std::vector<double>(h, 1.0 / static_cast<double>(h));
180 for (std::size_t a = 0; a < h; ++a) out[a] /= tot;
181 return out;
182}
183
184/** `map_pie`: the arrival-instant phase distribution of (D0, D1). */
185inline std::vector<double> kp_pie(const Matrix<double>& D0, const Matrix<double>& D1) {
186 if (D0.rows() <= 1) return std::vector<double>{1.0};
187 mam::Map<double> m;
188 m.D0 = D0;
189 m.D1 = D1;
190 return mam::map_pie(m);
191}
192
193/**
194 * The stationary phase distribution of the modulating chain Q = D0 + D1, from
195 * `[Q'; ones] \ [zeros; 1]`.
196 *
197 * A SOURCE STARTS IN ITS STATIONARY PHASE, NOT IN A KNOWN ONE, which is why the
198 * initial covariance is diag(theta) - theta theta' rather than zero: zero would
199 * assert a known initial phase and understate the variance early on.
200 */
201inline std::vector<double> kp_stationary(const Matrix<double>& D0, const Matrix<double>& D1) {
202 const std::size_t h = D0.rows();
203 if (h == 1) return std::vector<double>{1.0};
204 // The least-squares system the reference solves: Q' theta = 0 with sum = 1.
205 Matrix<double> A(h + 1, h, 0.0);
206 for (std::size_t a = 0; a < h; ++a)
207 for (std::size_t b = 0; b < h; ++b) A(a, b) = D0(b, a) + D1(b, a);
208 for (std::size_t b = 0; b < h; ++b) A(h, b) = 1.0;
209 std::vector<double> rhs(h + 1, 0.0);
210 rhs[h] = 1.0;
211 // Normal equations: A'A theta = A'rhs, which is what MATLAB's backslash gives
212 // for an overdetermined system.
213 Matrix<double> AtA(h, h, 0.0);
214 std::vector<double> Atb(h, 0.0);
215 for (std::size_t a = 0; a < h; ++a) {
216 for (std::size_t b = 0; b < h; ++b) {
217 double acc = 0.0;
218 for (std::size_t e = 0; e <= h; ++e) acc += A(e, a) * A(e, b);
219 AtA(a, b) = acc;
220 }
221 double acc = 0.0;
222 for (std::size_t e = 0; e <= h; ++e) acc += A(e, a) * rhs[e];
223 Atb[a] = acc;
224 }
225 std::vector<std::size_t> piv = lu_factor(AtA);
226 lu_solve(AtA, piv, Atb);
227 double s = 0.0;
228 for (std::size_t a = 0; a < h; ++a) {
229 if (Atb[a] < 0.0) Atb[a] = 0.0;
230 s += Atb[a];
231 }
232 if (s > 0.0)
233 for (std::size_t a = 0; a < h; ++a) Atb[a] /= s;
234 return Atb;
235}
236
237/** One (station, class) schedule, lowered to double. */
238struct KpSchedule {
239 std::size_t station = 0, cls = 0;
240 std::vector<double> bp; ///< boundary vector, nseg + 1 long
241 std::vector<Matrix<double>> segD0, segD1;
242 bool cyclic = false;
243};
244
245/**
246 * `local_pair_at`: the (D0, D1) in force at time t.
247 *
248 * A CYCLIC schedule wraps the offset into [0, T); a NON-CYCLIC one returns the
249 * ZERO pair outside its own window, which is the reference's behaviour and is
250 * not a defect to fix -- a process whose schedule has not started, or has ended,
251 * produces no events, and substituting the nominal pair there would invent
252 * arrivals the model does not declare.
253 */
254inline void kp_pair_at(const std::vector<KpSchedule>& sched, const Matrix<double>& nomD0,
255 const Matrix<double>& nomD1, std::size_t i, std::size_t r, double t,
256 Matrix<double>& D0, Matrix<double>& D1) {
257 for (std::size_t e = 0; e < sched.size(); ++e) {
258 const KpSchedule& sc = sched[e];
259 if (sc.station != i || sc.cls != r) continue;
260 const double T0 = sc.bp.front(), T1 = sc.bp.back();
261 const double period = T1 - T0;
262 double offset = t - T0;
263 if (sc.cyclic) {
264 if (period > 0.0) {
265 offset = std::fmod(offset, period);
266 if (offset < 0.0) offset += period;
267 } else {
268 offset = 0.0;
269 }
270 } else if (offset < 0.0 || offset >= period) {
271 const std::size_t n = sc.segD0.front().rows();
272 D0 = Matrix<double>(n, n, 0.0);
273 D1 = Matrix<double>(n, n, 0.0);
274 return;
275 }
276 const double pos = T0 + offset;
277 std::size_t idx = sc.segD0.size() - 1;
278 for (std::size_t k = 1; k < sc.bp.size(); ++k)
279 if (pos < sc.bp[k]) {
280 idx = k - 1;
281 break;
282 }
283 D0 = sc.segD0[idx];
284 D1 = sc.segD1[idx];
285 return;
286 }
287 D0 = nomD0;
288 D1 = nomD1;
289}
290
291/** `local_summarise`: the trapezoidal average over `window`, or the last value. */
292inline double kp_summarise(const std::vector<double>& series, const std::vector<double>& t,
293 double w0, double w1, bool have_window) {
294 if (series.empty()) return 0.0;
295 if (!have_window) return series.back();
296 std::vector<std::size_t> idx;
297 for (std::size_t a = 0; a < t.size(); ++a)
298 if (t[a] >= w0 && t[a] <= w1) idx.push_back(a);
299 if (idx.size() < 2) return series.back();
300 double acc = 0.0;
301 for (std::size_t a = 0; a + 1 < idx.size(); ++a)
302 acc += 0.5 * (series[idx[a]] + series[idx[a + 1]]) * (t[idx[a + 1]] - t[idx[a]]);
303 const double span = t[idx.back()] - t[idx.front()];
304 return span > 0.0 ? acc / span : series.back();
305}
306
307} // namespace kp_detail
308
309/**
310 * The Ko-Pender solve, returning both the steady table and the covariance
311 * trajectory so that neither has to integrate twice.
312 */
313template <class T>
315 FluidKpTransient* tran,
316 const std::vector<double>& out_grid = std::vector<double>()) {
317 if (!std::is_same<T, double>::value)
318 throw UnsupportedError(
319 "solver_fluid_kp: the covariance equation is integrated with LSODA, whose coefficients "
320 "assume double precision; rerun with --arith double");
321
322 const std::size_t M = sn.nstations, K = sn.nclasses;
323 for (std::size_t r = 0; r < K; ++r)
324 if (std::isfinite(sn.classes[r].population))
325 throw UnsupportedError(
326 "solver_fluid_kp: the 'kp' method analyses the OPEN (MAP_t/Ph_t/inf)^N network of "
327 "Ko and Pender (2017); a closed class has no arrival process to modulate. Use "
328 "'closing' or 'matrix' for closed models");
329
330 // ---- blocks -----------------------------------------------------------
331 std::vector<KpBlock> ublocks, xblocks;
332 std::size_t off = 0;
333 std::vector<std::vector<std::size_t>> uof(M, std::vector<std::size_t>(K, 0));
334 std::vector<std::vector<std::size_t>> xof(M, std::vector<std::size_t>(K, 0));
335 std::vector<std::vector<bool>> is_u(M, std::vector<bool>(K, false));
336 std::vector<std::vector<bool>> is_x(M, std::vector<bool>(K, false));
337 for (std::size_t i = 0; i < M; ++i) {
338 const bool ext = sn.stations[i].sched == lang::SchedStrategy::EXT;
339 for (std::size_t r = 0; r < K; ++r) {
340 if (sn.disabled[i][r]) continue;
341 const std::size_t h = sn.service[i][r].D0.rows();
342 const double rate = num_traits<T>::to_double(sn.rates(i, r));
343 if (h == 0 || !std::isfinite(rate) || rate <= 0.0) continue;
344 KpBlock b;
345 b.station = i;
346 b.cls = r;
347 b.offset = off;
348 b.nphases = h;
349 if (ext) {
350 ublocks.push_back(b);
351 uof[i][r] = off;
352 is_u[i][r] = true;
353 } else {
354 xblocks.push_back(b);
355 xof[i][r] = off;
356 is_x[i][r] = true;
357 }
358 off += h;
359 }
360 }
361 const std::size_t dim = off;
362 if (ublocks.empty())
363 throw InputError(
364 "solver_fluid_kp: the 'kp' method needs at least one Source with an arrival process");
365
366 bool linear_model = true;
367 for (std::size_t b = 0; b < xblocks.size(); ++b) {
368 const std::size_t i = xblocks[b].station;
369 if (sn.stations[i].sched != lang::SchedStrategy::INF &&
370 std::isfinite(sn.stations[i].nservers))
371 linear_model = false;
372 }
373 // The reference warns here. A finite-server station makes the rate functions
374 // nonlinear, so the covariance is a linear noise approximation rather than the
375 // exact second moment; it is exact for infinite-server stations. There is no
376 // warning channel in this port, so the fact is recorded on the solution.
377 (void)linear_model;
378
379 // ---- the nominal pairs and entry-phase vectors -------------------------
380 std::vector<std::vector<Matrix<double>>> D0(M, std::vector<Matrix<double>>(K)),
381 D1(M, std::vector<Matrix<double>>(K));
382 std::vector<std::vector<std::vector<double>>> pie(M, std::vector<std::vector<double>>(K));
383 // The schedules, and the NOMINAL pair of everything else. `kp_pair` reads
384 // `Distrib::D0`/`D1`, which for a MAPt/PHt already hold the width-weighted
385 // time average -- `sn_schedule_nominal`'s first two outputs -- so the
386 // nominal arm needs no special case here.
387 std::vector<kp_detail::KpSchedule> sched;
388 for (std::size_t i = 0; i < M; ++i)
389 for (std::size_t r = 0; r < K; ++r) {
390 if (!is_u[i][r] && !is_x[i][r]) continue;
391 kp_detail::kp_pair(sn, i, r, D0[i][r], D1[i][r]);
392 // `pie` is the arrival-instant vector of the NOMINAL pair, and stays
393 // so under a schedule: it seeds a job's service phase, which is a
394 // property of the service process as a whole, and the reference's
395 // `nomPie` is built from `sn_schedule_nominal`'s nominal pair too.
396 pie[i][r] = kp_detail::kp_pie(D0[i][r], D1[i][r]);
397 if (!sn::sn_has_schedule(sn, i, r)) continue;
399 kp_detail::KpSchedule ks;
400 ks.station = i;
401 ks.cls = r;
402 ks.cyclic = sc.cyclic;
403 for (std::size_t a = 0; a < sc.breakpoints.size(); ++a)
404 ks.bp.push_back(num_traits<T>::to_double(sc.breakpoints[a]));
405 for (std::size_t k = 0; k < sc.segD0.size(); ++k) {
406 const std::size_t nph = sc.segD0[k].rows();
407 Matrix<double> A(nph, nph, 0.0), B(nph, nph, 0.0);
408 for (std::size_t a = 0; a < nph; ++a)
409 for (std::size_t b = 0; b < nph; ++b) {
410 A(a, b) = num_traits<T>::to_double(sc.segD0[k](a, b));
411 B(a, b) = num_traits<T>::to_double(sc.segD1[k](a, b));
412 }
413 ks.segD0.push_back(A);
414 ks.segD1.push_back(B);
415 }
416 sched.push_back(ks);
417 }
418
419 // The pairs in force at time t, for every block. Built once per right-hand
420 // side evaluation and handed to `rates`, so the Jacobian's 2*dim difference
421 // calls all see the SAME instant -- differencing across a segment boundary
422 // would report the jump in the schedule as a derivative in q.
423 std::vector<std::vector<Matrix<double>>> Dt0 = D0, Dt1 = D1;
424 const bool time_varying = !sched.empty();
425 const auto pairs_at = [&](double t) {
426 if (!time_varying) return;
427 for (std::size_t e = 0; e < sched.size(); ++e) {
428 const std::size_t i = sched[e].station, r = sched[e].cls;
429 kp_detail::kp_pair_at(sched, D0[i][r], D1[i][r], i, r, t, Dt0[i][r], Dt1[i][r]);
430 }
431 };
432
433 // Routing in stateful space, as `fluid_ode_system` reads it.
434 const std::size_t S = sn.nof_stateful();
435 const bool have_rt = sn.rt.rows() == S * K;
436 std::vector<std::size_t> sf(M, 0);
437 for (std::size_t i = 0; i < M; ++i) sf[i] = sn.stateful_of_station(i + 1) - 1;
438 const auto route = [&](std::size_t i, std::size_t c, std::size_t j, std::size_t l) -> double {
439 if (!have_rt) return 0.0;
440 return num_traits<T>::to_double(sn.rt(sf[i] * K + c, sf[j] * K + l));
441 };
442 // The probability that a completion at (i,r) LEAVES the network: sn.rt is
443 // closed through the Source, so any destination that is not a service block is
444 // an exit.
445 std::vector<std::vector<double>> pout(M, std::vector<double>(K, 0.0));
446 for (std::size_t i = 0; i < M; ++i)
447 for (std::size_t r = 0; r < K; ++r) {
448 if (!is_x[i][r]) continue;
449 double acc = 0.0;
450 for (std::size_t j = 0; j < M; ++j)
451 for (std::size_t l = 0; l < K; ++l)
452 if (!is_x[j][l]) acc += route(i, r, j, l);
453 pout[i][r] = acc;
454 }
455
456 // ---- events ------------------------------------------------------------
457 std::vector<KpEvent> ev;
458 const auto push = [&](KpEvent e) {
459 ev.push_back(e);
460 };
461 for (std::size_t b = 0; b < ublocks.size(); ++b) { // (A0)
462 const KpBlock& u = ublocks[b];
463 for (std::size_t k = 0; k < u.nphases; ++k)
464 for (std::size_t j = 0; j < u.nphases; ++j) {
465 if (k == j) continue;
466 KpEvent e;
468 e.i = u.station;
469 e.c = u.cls;
470 e.k = k;
471 e.j = j;
472 e.off_src = u.offset;
473 e.minus.push_back(u.offset + k);
474 e.plus.push_back(u.offset + j);
475 push(e);
476 }
477 }
478 for (std::size_t b = 0; b < ublocks.size(); ++b) { // (A1)
479 const KpBlock& u = ublocks[b];
480 for (std::size_t d = 0; d < xblocks.size(); ++d) {
481 const KpBlock& xb = xblocks[d];
482 const double p = route(u.station, u.cls, xb.station, xb.cls);
483 if (!(p > 0.0)) continue;
484 for (std::size_t k = 0; k < u.nphases; ++k)
485 for (std::size_t j = 0; j < u.nphases; ++j)
486 for (std::size_t ip = 0; ip < xb.nphases; ++ip) {
487 KpEvent e;
489 e.i = u.station;
490 e.c = u.cls;
491 e.k = k;
492 e.j = j;
493 e.n = xb.station;
494 e.l = xb.cls;
495 e.ip = ip;
496 e.off_src = u.offset;
497 e.off_dst = xb.offset;
498 e.weight = p * (ip < pie[xb.station][xb.cls].size()
499 ? pie[xb.station][xb.cls][ip]
500 : 0.0);
501 e.minus.push_back(u.offset + k);
502 e.plus.push_back(u.offset + j);
503 e.plus.push_back(xb.offset + ip);
504 push(e);
505 }
506 }
507 }
508 for (std::size_t b = 0; b < xblocks.size(); ++b) { // (S)
509 const KpBlock& xb = xblocks[b];
510 for (std::size_t p = 0; p < xb.nphases; ++p)
511 for (std::size_t q = 0; q < xb.nphases; ++q) {
512 if (p == q) continue;
513 KpEvent e;
515 e.i = xb.station;
516 e.c = xb.cls;
517 e.k = p;
518 e.j = q;
519 e.off_src = xb.offset;
520 e.minus.push_back(xb.offset + p);
521 e.plus.push_back(xb.offset + q);
522 push(e);
523 }
524 }
525 for (std::size_t b = 0; b < xblocks.size(); ++b) { // (D) and (R)
526 const KpBlock& xb = xblocks[b];
527 if (pout[xb.station][xb.cls] > 0.0)
528 for (std::size_t p = 0; p < xb.nphases; ++p) {
529 KpEvent e;
531 e.i = xb.station;
532 e.c = xb.cls;
533 e.k = p;
534 e.off_src = xb.offset;
535 e.weight = pout[xb.station][xb.cls];
536 e.minus.push_back(xb.offset + p);
537 push(e);
538 }
539 for (std::size_t d = 0; d < xblocks.size(); ++d) {
540 const KpBlock& nb = xblocks[d];
541 const double p = route(xb.station, xb.cls, nb.station, nb.cls);
542 if (!(p > 0.0)) continue;
543 for (std::size_t q = 0; q < xb.nphases; ++q)
544 for (std::size_t ip = 0; ip < nb.nphases; ++ip) {
545 KpEvent e;
547 e.i = xb.station;
548 e.c = xb.cls;
549 e.k = q;
550 e.n = nb.station;
551 e.l = nb.cls;
552 e.ip = ip;
553 e.off_src = xb.offset;
554 e.off_dst = nb.offset;
555 e.weight =
556 p * (ip < pie[nb.station][nb.cls].size() ? pie[nb.station][nb.cls][ip] : 0.0);
557 e.minus.push_back(xb.offset + q);
558 e.plus.push_back(nb.offset + ip);
559 push(e);
560 }
561 }
562 }
563 const std::size_t nev = ev.size();
564
565 // The server-capacity factor min(n,c)/n, shared by every class at a station.
566 const auto capacity = [&](const double* q, std::size_t i) -> double {
567 if (sn.stations[i].sched == lang::SchedStrategy::INF ||
568 !std::isfinite(sn.stations[i].nservers))
569 return 1.0;
570 double ni = 0.0;
571 for (std::size_t b = 0; b < xblocks.size(); ++b) {
572 if (xblocks[b].station != i) continue;
573 for (std::size_t p = 0; p < xblocks[b].nphases; ++p)
574 ni += std::max(q[xblocks[b].offset + p], 0.0);
575 }
576 const double c = sn.stations[i].nservers;
577 return (ni <= c) ? 1.0 : c / ni;
578 };
579
580 const auto rates = [&](const double* q, std::vector<double>& f) {
581 f.assign(nev, 0.0);
582 std::vector<double> cap(M, 1.0);
583 for (std::size_t i = 0; i < M; ++i) cap[i] = capacity(q, i);
584 for (std::size_t e = 0; e < nev; ++e) {
585 const KpEvent& s = ev[e];
586 const double mass = std::max(q[s.off_src + s.k], 0.0);
587 switch (s.kind) {
589 f[e] = Dt0[s.i][s.c](s.k, s.j) * mass;
590 break;
592 f[e] = Dt1[s.i][s.c](s.k, s.j) * s.weight * mass;
593 break;
595 f[e] = Dt0[s.i][s.c](s.k, s.j) * mass * cap[s.i];
596 break;
598 case KpEventKind::Routed: {
599 double rowsum = 0.0;
600 for (std::size_t b = 0; b < Dt1[s.i][s.c].cols(); ++b)
601 rowsum += Dt1[s.i][s.c](s.k, b);
602 f[e] = rowsum * s.weight * mass * cap[s.i];
603 break;
604 }
605 }
606 }
607 };
608 const auto apply_jumps = [&](const std::vector<double>& f, double* dq) {
609 for (std::size_t a = 0; a < dim; ++a) dq[a] = 0.0;
610 for (std::size_t e = 0; e < nev; ++e) {
611 if (f[e] == 0.0) continue;
612 for (std::size_t a = 0; a < ev[e].minus.size(); ++a) dq[ev[e].minus[a]] -= f[e];
613 for (std::size_t a = 0; a < ev[e].plus.size(); ++a) dq[ev[e].plus[a]] += f[e];
614 }
615 };
616
617 // ---- horizon -----------------------------------------------------------
618 double t0 = 0.0;
619 double tend = opt.timespan_end;
620 const bool unbounded = !std::isfinite(tend);
621 // The longest cycle among the CYCLIC schedules; zero when none is cyclic,
622 // and a non-cyclic schedule is constant on its last segment, so it has a
623 // fixed point and needs neither the extended horizon nor the averaging.
624 double period = 0.0;
625 for (std::size_t e = 0; e < sched.size(); ++e)
626 if (sched[e].cyclic) period = std::max(period, sched[e].bp.back() - sched[e].bp.front());
627 if (unbounded) {
628 double slow = std::numeric_limits<double>::infinity();
629 for (std::size_t i = 0; i < M; ++i)
630 for (std::size_t r = 0; r < K; ++r) {
631 const double rate = num_traits<T>::to_double(sn.rates(i, r));
632 if (std::isfinite(rate) && rate > 0.0) slow = std::min(slow, rate);
633 }
634 if (!std::isfinite(slow)) slow = 1.0;
635 tend = t0 + std::max(10.0, 30.0 / slow);
636 if (period > 0.0) tend = std::max(tend, t0 + 10.0 * period);
637 }
638
639 // ---- initial condition -------------------------------------------------
640 std::vector<double> z(dim + dim * dim, 0.0);
641 // The arrival phase is drawn from the stationary vector of the pair IN FORCE
642 // AT t0, not of the time average: a schedule that starts in a quiet segment
643 // starts in that segment's phase mix.
644 pairs_at(t0);
645 for (std::size_t b = 0; b < ublocks.size(); ++b) {
646 const KpBlock& u = ublocks[b];
647 const std::vector<double> theta =
648 kp_detail::kp_stationary(Dt0[u.station][u.cls], Dt1[u.station][u.cls]);
649 for (std::size_t a = 0; a < u.nphases; ++a) z[u.offset + a] = theta[a];
650 for (std::size_t a = 0; a < u.nphases; ++a)
651 for (std::size_t c2 = 0; c2 < u.nphases; ++c2)
652 z[dim + (u.offset + a) * dim + (u.offset + c2)] =
653 (a == c2 ? theta[a] : 0.0) - theta[a] * theta[c2];
654 }
655 // NOT `opt.init_sol`, which is laid out for the CLOSING state vector: the two
656 // can have the same length on the same model, so reading it here would let a
657 // closing-layout seed zero the source phase mass and with it the network.
658 //
659 // A WRONG-SIZED SEED IS REFUSED, not ignored. Dropping it would integrate from
660 // the default initial condition under the caller's name and return a plausible
661 // trajectory for a model the caller did not ask about.
662 if (!opt.kp_init_sol.empty()) {
663 if (opt.kp_init_sol.size() != dim)
664 throw InputError("solver_fluid_kp: config.kp_init_sol has " +
665 std::to_string(opt.kp_init_sol.size()) +
666 " entries but the 'kp' state vector of this model has " +
667 std::to_string(dim) +
668 ", laid out station-major over the (station, class) blocks. "
669 "It is NOT laid out like init_sol.");
670 for (std::size_t a = 0; a < dim; ++a) z[a] = opt.kp_init_sol[a];
671 }
672 // Companion seed for the covariance. A caller that carries a DISTRIBUTION across
673 // a handoff supplies the second moment beside the mean, so the next stage does
674 // not restart from a point mass it never had. Same layout as kp_init_sol.
675 if (opt.init_cov.rows() > 0 || opt.init_cov.cols() > 0) {
676 if (opt.init_cov.rows() != dim || opt.init_cov.cols() != dim)
677 throw InputError("solver_fluid_kp: config.init_cov is " +
678 std::to_string(opt.init_cov.rows()) + "x" +
679 std::to_string(opt.init_cov.cols()) +
680 " but the 'kp' state vector of this model has " +
681 std::to_string(dim) + " entries, so the covariance must be " +
682 std::to_string(dim) + "x" + std::to_string(dim) + ".");
683 double asym = 0.0, scale = 0.0;
684 for (std::size_t a = 0; a < dim; ++a)
685 for (std::size_t c2 = 0; c2 < dim; ++c2) {
686 const double d = opt.init_cov(a, c2) - opt.init_cov(c2, a);
687 asym += d * d;
688 scale += opt.init_cov(a, c2) * opt.init_cov(a, c2);
689 }
690 // Loose enough for the rounding of a covariance that was itself integrated,
691 // tight enough to catch a matrix that is simply not one.
692 if (std::sqrt(asym) > 1e-6 * std::max(1.0, std::sqrt(scale)))
693 throw InputError("solver_fluid_kp: config.init_cov must be symmetric.");
694 for (std::size_t a = 0; a < dim; ++a)
695 for (std::size_t c2 = 0; c2 < dim; ++c2)
696 z[dim + a * dim + c2] = opt.init_cov(a, c2);
697 }
698
699 // The LAYOUT-FREE spelling of the same initial condition: a mean per
700 // (station, class) pair and its covariance over those pairs, lifted here
701 // because this method owns the phase layout and no caller should have to.
702 //
703 // q0(b) = m_ir * pie_b
704 // Sigma0(b,b) = C(ir,ir) * pie_b pie_b' + m_ir * (diag(pie_b) - pie_b pie_b')
705 // Sigma0(b,d) = C(ir,js) * pie_b pie_d' (b != d)
706 //
707 // The second term of the diagonal block is the MULTINOMIAL split of a known
708 // total over the phases: given N_ir jobs present, their phases are iid pie_b,
709 // so the within-block covariance is N_ir*(diag(pie) - pie pie'). Dropping it
710 // asserts that every job's phase is known once the total is -- the
711 // zero-conditional-variance lift -- and understates every per-phase count.
712 const bool has_qlen = opt.init_qlen.rows() > 0 || opt.init_qlen.cols() > 0;
713 const bool has_qcov = opt.init_qcov.rows() > 0 || opt.init_qcov.cols() > 0;
714 if (has_qlen || has_qcov) {
715 if (!opt.kp_init_sol.empty() || opt.init_cov.rows() > 0 || opt.init_cov.cols() > 0)
716 throw InputError(
717 "solver_fluid_kp: config.init_qlen/init_qcov and config.kp_init_sol/init_cov are "
718 "two spellings of the same initial condition, the first over (station,class) "
719 "pairs and the second over this method's own phase layout. Supply one or the "
720 "other, not both.");
721 const std::size_t n = M * K;
722 Matrix<double> Q0sc(M, K, 0.0), C0sc(n, n, 0.0);
723 if (has_qlen) {
724 if (opt.init_qlen.rows() != M || opt.init_qlen.cols() != K)
725 throw InputError("solver_fluid_kp: config.init_qlen is " +
726 std::to_string(opt.init_qlen.rows()) + "x" +
727 std::to_string(opt.init_qlen.cols()) + " but the model has " +
728 std::to_string(M) + " stations and " + std::to_string(K) +
729 " classes.");
730 Q0sc = opt.init_qlen;
731 }
732 if (has_qcov) {
733 if (opt.init_qcov.rows() != n || opt.init_qcov.cols() != n)
734 throw InputError("solver_fluid_kp: config.init_qcov is " +
735 std::to_string(opt.init_qcov.rows()) + "x" +
736 std::to_string(opt.init_qcov.cols()) + " but the model has " +
737 std::to_string(n) +
738 " station-class pairs, so the covariance must be " +
739 std::to_string(n) + "x" + std::to_string(n) +
740 ", indexed r*" + std::to_string(M) + "+i.");
741 double asym = 0.0, scale = 0.0;
742 for (std::size_t a = 0; a < n; ++a)
743 for (std::size_t c2 = 0; c2 < n; ++c2) {
744 const double d = opt.init_qcov(a, c2) - opt.init_qcov(c2, a);
745 asym += d * d;
746 scale += opt.init_qcov(a, c2) * opt.init_qcov(a, c2);
747 }
748 if (std::sqrt(asym) > 1e-6 * std::max(1.0, std::sqrt(scale)))
749 throw InputError("solver_fluid_kp: config.init_qcov must be symmetric.");
750 C0sc = opt.init_qcov;
751 }
752 for (std::size_t b = 0; b < xblocks.size(); ++b) {
753 const KpBlock& xb = xblocks[b];
754 const std::vector<double> pib =
755 kp_detail::kp_entry_vector(pie[xb.station][xb.cls], xb.nphases);
756 const std::size_t irb = xb.cls * M + xb.station;
757 const double mirb = std::max(0.0, Q0sc(xb.station, xb.cls));
758 for (std::size_t a = 0; a < xb.nphases; ++a) z[xb.offset + a] = mirb * pib[a];
759 for (std::size_t a = 0; a < xb.nphases; ++a)
760 for (std::size_t c2 = 0; c2 < xb.nphases; ++c2)
761 z[dim + (xb.offset + a) * dim + (xb.offset + c2)] =
762 C0sc(irb, irb) * pib[a] * pib[c2] +
763 mirb * ((a == c2 ? pib[a] : 0.0) - pib[a] * pib[c2]);
764 for (std::size_t d = 0; d < xblocks.size(); ++d) {
765 if (d == b) continue;
766 const KpBlock& xd = xblocks[d];
767 const std::vector<double> pid =
768 kp_detail::kp_entry_vector(pie[xd.station][xd.cls], xd.nphases);
769 const std::size_t ird = xd.cls * M + xd.station;
770 for (std::size_t a = 0; a < xb.nphases; ++a)
771 for (std::size_t c2 = 0; c2 < xd.nphases; ++c2)
772 z[dim + (xb.offset + a) * dim + (xd.offset + c2)] =
773 C0sc(irb, ird) * pib[a] * pid[c2];
774 }
775 }
776 }
777
778 // ---- integrate ---------------------------------------------------------
779 // The Jacobian is taken by central differences ON THE ASSEMBLED RATES, so
780 // every capacity term is differentiated consistently with the drift actually
781 // integrated rather than with an algebraic derivative of a different function.
782 const LsodaRhs rhs = [&](double t, const double* zz, double* dz) {
783 pairs_at(t);
784 std::vector<double> f;
785 rates(zz, f);
786 apply_jumps(f, dz);
787 double qmax = 1.0;
788 for (std::size_t a = 0; a < dim; ++a) qmax = std::max(qmax, std::fabs(zz[a]));
789 const double hstep = 1e-6 * qmax;
790 Matrix<double> J(dim, dim, 0.0);
791 std::vector<double> qp(zz, zz + dim), fp, fm, dp(dim, 0.0), dm(dim, 0.0);
792 for (std::size_t m = 0; m < dim; ++m) {
793 const double keep = qp[m];
794 qp[m] = keep + hstep;
795 rates(qp.data(), fp);
796 apply_jumps(fp, dp.data());
797 qp[m] = keep - hstep;
798 rates(qp.data(), fm);
799 apply_jumps(fm, dm.data());
800 qp[m] = keep;
801 for (std::size_t a = 0; a < dim; ++a) J(a, m) = (dp[a] - dm[a]) / (2.0 * hstep);
802 }
803 // G = A diag(f) A', assembled from the jump lists.
804 Matrix<double> G(dim, dim, 0.0);
805 std::vector<double> col(dim, 0.0);
806 for (std::size_t e = 0; e < nev; ++e) {
807 if (f[e] == 0.0) continue;
808 std::fill(col.begin(), col.end(), 0.0);
809 for (std::size_t a = 0; a < ev[e].minus.size(); ++a) col[ev[e].minus[a]] -= 1.0;
810 for (std::size_t a = 0; a < ev[e].plus.size(); ++a) col[ev[e].plus[a]] += 1.0;
811 for (std::size_t a = 0; a < dim; ++a) {
812 if (col[a] == 0.0) continue;
813 for (std::size_t b = 0; b < dim; ++b)
814 if (col[b] != 0.0) G(a, b) += col[a] * f[e] * col[b];
815 }
816 }
817 for (std::size_t a = 0; a < dim; ++a)
818 for (std::size_t b = 0; b < dim; ++b) {
819 double acc = G(a, b);
820 for (std::size_t c2 = 0; c2 < dim; ++c2)
821 acc += J(a, c2) * zz[dim + c2 * dim + b] + zz[dim + a * dim + c2] * J(b, c2);
822 dz[dim + a * dim + b] = acc;
823 }
824 };
825
826 LsodaOptions lopt;
827 lopt.rtol = opt.tol;
828 lopt.atol = opt.tol * 1e-3;
829 lopt.h_max = (tend - t0) / 10.0;
830 // A step that crosses a whole segment integrates a rate that was never in
831 // force. Cap it at a quarter of the NARROWEST segment of any schedule.
832 if (period > 0.0) {
833 double narrowest = std::numeric_limits<double>::infinity();
834 for (std::size_t e = 0; e < sched.size(); ++e)
835 for (std::size_t k = 1; k < sched[e].bp.size(); ++k)
836 narrowest = std::min(narrowest, sched[e].bp[k] - sched[e].bp[k - 1]);
837 if (std::isfinite(narrowest) && narrowest > 0.0)
838 lopt.h_max = std::min(lopt.h_max, narrowest / 4.0);
839 }
840
841 // The output grid. Uniform over the whole horizon as before; when the answer
842 // is a period average the last cycle is refined and every segment boundary
843 // in it is BRACKETED, so no trapezoid interval straddles a jump.
844 const bool averaging = unbounded && period > 0.0;
845 const double w0 = averaging ? std::max(t0, tend - period) : t0;
846 std::vector<double> grid;
847 if (!out_grid.empty()) {
848 // A CALLER'S GRID REPLACES THE DEFAULT ONE, and does not merely add to it:
849 // SolverENV's coupling sums a Stieltjes integral over the points it asked
850 // for, and extra points would change the quadrature it is comparing across
851 // codebases. The horizon is still this solve's, so points outside it are
852 // dropped rather than extrapolated to.
853 for (std::size_t a = 0; a < out_grid.size(); ++a)
854 if (out_grid[a] >= t0 && out_grid[a] <= tend) grid.push_back(out_grid[a]);
855 std::sort(grid.begin(), grid.end());
856 grid.erase(std::unique(grid.begin(), grid.end()), grid.end());
857 if (grid.empty() || grid.front() > t0) grid.insert(grid.begin(), t0);
858 } else {
859 const std::size_t ngrid = 201;
860 for (std::size_t a = 0; a < ngrid; ++a)
861 grid.push_back(t0 + (tend - t0) * static_cast<double>(a) /
862 static_cast<double>(ngrid - 1));
863 if (averaging) {
864 const std::size_t nref = 2001;
865 for (std::size_t a = 0; a < nref; ++a)
866 grid.push_back(w0 + (tend - w0) * static_cast<double>(a) /
867 static_cast<double>(nref - 1));
868 std::vector<double> bounds;
869 for (std::size_t e = 0; e < sched.size(); ++e) {
870 const std::vector<double>& bp = sched[e].bp;
871 const double per = bp.back() - bp.front();
872 if (sched[e].cyclic && per > 0.0) {
873 const long kmax = static_cast<long>(std::ceil((tend - w0) / per)) + 2;
874 for (long kk = -1; kk <= kmax; ++kk)
875 for (std::size_t a = 0; a < bp.size(); ++a)
876 bounds.push_back(bp[a] + static_cast<double>(kk) * per);
877 } else {
878 for (std::size_t a = 0; a < bp.size(); ++a) bounds.push_back(bp[a]);
879 }
880 }
881 const double eps_b = std::max(1e-9, 1e-7 * (tend - w0));
882 for (std::size_t a = 0; a < bounds.size(); ++a) {
883 const double b = bounds[a];
884 if (!(b > w0 && b < tend)) continue;
885 grid.push_back(b - eps_b);
886 grid.push_back(b);
887 grid.push_back(b + eps_b);
888 }
889 }
890 std::sort(grid.begin(), grid.end());
891 grid.erase(std::remove_if(grid.begin(), grid.end(),
892 [&](double v) { return v < t0 || v > tend; }),
893 grid.end());
894 grid.erase(std::unique(grid.begin(), grid.end()), grid.end());
895 if (grid.empty() || grid.front() > t0) grid.insert(grid.begin(), t0);
896 }
897 const LsodaSolution sol = fluid_integrate_grid(rhs, z, grid, lopt);
898
899 // ---- metrics -----------------------------------------------------------
900 // A time-homogeneous model has a fixed point, so the steady-state answer is
901 // the value at the horizon. A CYCLIC schedule has none, so the answer is the
902 // trapezoidal average over the last full period; the value at the horizon
903 // would be an arbitrary point of the cycle, at which the source and station
904 // throughputs do not even agree.
905 const std::vector<double>& zend = sol.final_state();
906 FluidSolution out;
907 out.method = "kp";
908 out.iters = 1;
909 out.QN = Matrix<double>(M, K, 0.0);
910 out.UN = Matrix<double>(M, K, 0.0);
911 out.RN = Matrix<double>(M, K, 0.0);
912 out.TN = Matrix<double>(M, K, 0.0);
913 out.xvec.assign(zend.begin(), zend.begin() + dim);
914
915 const std::size_t nt = sol.t.size();
916 Matrix<double> QVar(M, K, 0.0);
917 if (tran != nullptr) {
918 tran->QN.assign(nt, Matrix<double>(M, K, 0.0));
919 tran->UN.assign(nt, Matrix<double>(M, K, 0.0));
920 tran->TN.assign(nt, Matrix<double>(M, K, 0.0));
921 }
922 for (std::size_t b = 0; b < xblocks.size(); ++b) {
923 const KpBlock& xb = xblocks[b];
924 std::vector<double> qser(nt, 0.0), user(nt, 0.0), tser(nt, 0.0);
925 double vend = 0.0;
926 for (std::size_t n = 0; n < nt; ++n) {
927 const std::vector<double>& zs = sol.y[n];
928 double q = 0.0;
929 for (std::size_t p = 0; p < xb.nphases; ++p) q += zs[xb.offset + p];
930 pairs_at(sol.t[n]);
931 const double cap = capacity(zs.data(), xb.station);
932 double tn = 0.0;
933 for (std::size_t p = 0; p < xb.nphases; ++p) {
934 double rowsum = 0.0;
935 for (std::size_t c2 = 0; c2 < Dt1[xb.station][xb.cls].cols(); ++c2)
936 rowsum += Dt1[xb.station][xb.cls](p, c2);
937 tn += rowsum * std::max(zs[xb.offset + p], 0.0) * cap;
938 }
939 const double c = sn.stations[xb.station].nservers;
940 qser[n] = q;
941 tser[n] = tn;
942 user[n] = (sn.stations[xb.station].sched == lang::SchedStrategy::INF ||
943 !std::isfinite(c))
944 ? q
945 : std::min(q, c) / c;
946 }
947 for (std::size_t p = 0; p < xb.nphases; ++p)
948 for (std::size_t p2 = 0; p2 < xb.nphases; ++p2)
949 vend += zend[dim + (xb.offset + p) * dim + (xb.offset + p2)];
950 if (tran != nullptr)
951 for (std::size_t n = 0; n < nt; ++n) {
952 tran->QN[n](xb.station, xb.cls) = qser[n];
953 tran->UN[n](xb.station, xb.cls) = user[n];
954 tran->TN[n](xb.station, xb.cls) = tser[n];
955 }
956 out.QN(xb.station, xb.cls) = kp_detail::kp_summarise(qser, sol.t, w0, tend, averaging);
957 out.UN(xb.station, xb.cls) = kp_detail::kp_summarise(user, sol.t, w0, tend, averaging);
958 out.TN(xb.station, xb.cls) = kp_detail::kp_summarise(tser, sol.t, w0, tend, averaging);
959 QVar(xb.station, xb.cls) = vend;
960 // TN is zero only to the integrator's accuracy: a class that never visits leaves
961 // a ~1e-20 residue in TN too, and a strict > 0 test then divides residue by residue.
962 if (out.TN(xb.station, xb.cls) > lang::GlobalConstants::Zero)
963 out.RN(xb.station, xb.cls) = out.QN(xb.station, xb.cls) / out.TN(xb.station, xb.cls);
964 }
965 for (std::size_t b = 0; b < ublocks.size(); ++b) {
966 const KpBlock& u = ublocks[b];
967 std::vector<double> aser(nt, 0.0);
968 for (std::size_t n = 0; n < nt; ++n) {
969 pairs_at(sol.t[n]);
970 double tn = 0.0;
971 for (std::size_t p = 0; p < u.nphases; ++p) {
972 double rowsum = 0.0;
973 for (std::size_t c2 = 0; c2 < Dt1[u.station][u.cls].cols(); ++c2)
974 rowsum += Dt1[u.station][u.cls](p, c2);
975 tn += rowsum * std::max(sol.y[n][u.offset + p], 0.0);
976 }
977 aser[n] = tn;
978 }
979 if (tran != nullptr)
980 for (std::size_t n = 0; n < nt; ++n) tran->TN[n](u.station, u.cls) = aser[n];
981 out.TN(u.station, u.cls) = kp_detail::kp_summarise(aser, sol.t, w0, tend, averaging);
982 }
983
984 // The covariance IS the answer here, so it is reported through the same
985 // `moments` channel the stationary closures use; `Sigma` is the full state
986 // covariance at the horizon, so cross-station terms survive.
988 rep.Sigma = Matrix<double>(dim, dim, 0.0);
989 for (std::size_t a = 0; a < dim; ++a)
990 for (std::size_t b = 0; b < dim; ++b) rep.Sigma(a, b) = zend[dim + a * dim + b];
991 rep.QVar = QVar;
992 rep.QStd = Matrix<double>(M, K, 0.0);
993 for (std::size_t i = 0; i < M; ++i)
994 for (std::size_t r = 0; r < K; ++r)
995 rep.QStd(i, r) = std::sqrt(std::max(0.0, QVar(i, r)));
996 rep.outer_iters = 1;
997 out.has_moments = true;
998 out.moments = rep;
999
1000 out.XN.assign(K, 0.0);
1001 out.CN.assign(K, 0.0);
1002 for (std::size_t r = 0; r < K; ++r) {
1003 const std::size_t rs = sn.classes[r].refstat;
1004 if (rs >= 1 && rs <= M) out.XN[r] = out.TN(rs - 1, r);
1005 double q = 0.0;
1006 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, r);
1007 if (out.XN[r] > 0.0) out.CN[r] = q / out.XN[r];
1008 }
1009
1010 if (tran != nullptr) {
1011 tran->t = sol.t;
1012 tran->q.clear();
1013 tran->QVar.clear();
1014 tran->Sigma.clear();
1015 tran->QCov.clear();
1016 for (std::size_t s = 0; s < sol.y.size(); ++s) {
1017 const std::vector<double>& zs = sol.y[s];
1018 tran->q.push_back(std::vector<double>(zs.begin(), zs.begin() + dim));
1019 Matrix<double> V(M, K, 0.0), Sg(dim, dim, 0.0);
1020 for (std::size_t a = 0; a < dim; ++a)
1021 for (std::size_t b = 0; b < dim; ++b) Sg(a, b) = zs[dim + a * dim + b];
1022 // The phase layout aggregated onto station-class pairs: summing a whole
1023 // block of the covariance is exactly Cov of the per-phase SUMS, so the
1024 // diagonal reproduces QVar and the off-diagonal entries carry the cross
1025 // terms it drops. The u-blocks are arrival phase indicators, not queue
1026 // lengths, so they contribute no row and no column.
1027 Matrix<double> C(M * K, M * K, 0.0);
1028 for (std::size_t b = 0; b < xblocks.size(); ++b) {
1029 const KpBlock& xb = xblocks[b];
1030 double v = 0.0;
1031 for (std::size_t p = 0; p < xb.nphases; ++p)
1032 for (std::size_t p2 = 0; p2 < xb.nphases; ++p2)
1033 v += Sg(xb.offset + p, xb.offset + p2);
1034 V(xb.station, xb.cls) = v;
1035 const std::size_t irb = xb.cls * M + xb.station;
1036 for (std::size_t d = 0; d < xblocks.size(); ++d) {
1037 const KpBlock& xd = xblocks[d];
1038 double acc = 0.0;
1039 for (std::size_t p = 0; p < xb.nphases; ++p)
1040 for (std::size_t p2 = 0; p2 < xd.nphases; ++p2)
1041 acc += Sg(xb.offset + p, xd.offset + p2);
1042 C(irb, xd.cls * M + xd.station) = acc;
1043 }
1044 }
1045 tran->QVar.push_back(V);
1046 tran->Sigma.push_back(Sg);
1047 tran->QCov.push_back(C);
1048 }
1049 }
1050 return out;
1051}
1052
1053/** Port of `solver_fluid_kp.m`: the steady table at the horizon. */
1054template <class T>
1058
1059/**
1060 * Port of `@@SolverFLD/getTranAvgVar`: the queue-length VARIANCE along the
1061 * trajectory, per station and class, plus the full state covariance.
1062 *
1063 * ONLY `kp` HAS THIS. Every other fluid method integrates the mean alone and
1064 * carries no second moment, so asking them for one is an error rather than a
1065 * misleading zero -- and `minnormal`'s covariance is STATIONARY, so it is not this
1066 * quantity either. A caller that left the horizon unbounded gets one resolved the
1067 * way `getTranAvg` resolves it, from the slowest rate in the model.
1068 */
1069template <class T>
1071 const FluidOptions& opt) {
1072 std::string m = opt.method;
1073 if (m.size() > 4 && m.compare(0, 4, "fld.") == 0) m = m.substr(4);
1074 if (m != "kp")
1075 throw UnsupportedError(
1076 "solver_fluid_tran_avg_var: getTranAvgVar needs method 'kp'; the other fluid methods "
1077 "integrate the mean only and carry no second moment");
1078 FluidKpTransient tran;
1079 FluidOptions o = opt;
1080 o.method = "kp";
1081 solver_fluid_kp_core(sn, o, &tran);
1082 return tran;
1083}
1084
1085} // namespace fluid
1086} // namespace line
1087
1088#endif // LINE_SOLVERS_FLUID_FLUID_KP_H
InputError(const std::string &what)
Definition error.h:39
UnsupportedError(const std::string &what)
Definition error.h:51
A network plus its refreshed NetworkStruct.
The exception types the port throws.
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.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
FluidKpTransient solver_fluid_tran_avg_var(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
Port of @@SolverFLD/getTranAvgVar: the queue-length VARIANCE along the trajectory,...
Definition fluid_kp.h:1070
FluidSolution solver_fluid_kp(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
Port of solver_fluid_kp.m: the steady table at the horizon.
Definition fluid_kp.h:1055
KpEventKind
The five event families of Ko-Pender (3.1)-(3.2).
Definition fluid_kp.h:98
@ Departure
D: completion leaving the network.
Definition fluid_kp.h:102
@ Routed
R: completion routed onward.
Definition fluid_kp.h:103
@ Arrival
A1: phase change WITH an arrival, into a service phase.
Definition fluid_kp.h:100
@ ArrivalPhase
A0: arrival-MAP phase change without an arrival.
Definition fluid_kp.h:99
@ ServicePhase
S: service phase change inside a station.
Definition fluid_kp.h:101
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 ...
FluidSolution solver_fluid_kp_core(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, FluidKpTransient *tran, const std::vector< double > &out_grid=std::vector< double >())
The Ko-Pender solve, returning both the steady table and the covariance trajectory so that neither ha...
Definition fluid_kp.h:314
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
ScheduleNominal< T > sn_schedule_nominal(const qn::NetworkStruct< T > &sn, std::size_t ist, std::size_t r)
Port of sn_schedule_nominal(sn, ist, r); ist and r are 0-based here.
bool sn_has_schedule(const qn::NetworkStruct< T > &sn, std::size_t ist, std::size_t r)
True when (ist, r) carries a MAPt / PHt / NHPP schedule.
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
void lu_solve(const Matrix< T > &LU, const std::vector< std::size_t > &piv, std::vector< T > &b)
Solve LUx = Pb in place on b, using the factors from lu_factor.
Definition lu.h:94
std::function< void(double t, const double *y, double *dydt)> LsodaRhs
The right-hand side dy/dt = f(t, y).
Definition lsoda.h:54
std::vector< std::size_t > lu_factor(Matrix< T > &A)
In-place LU of A (n x n).
Definition lu.h:48
A queueing network and its refreshed NetworkStruct.
Port of matlab/src/api/sn/sn_schedule_nominal.m: unpack the MAPt or PHt slot of sn....
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
double h_max
largest admissible step; 0 means no bound
Definition lsoda.h:78
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
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
The transient the covariance equation produces, i.e.
Definition fluid_kp.h:120
std::vector< Matrix< double > > QVar
per time point, (nstations x nclasses)
Definition fluid_kp.h:122
std::vector< Matrix< double > > Sigma
per time point, (dim x dim)
Definition fluid_kp.h:123
std::vector< Matrix< double > > QN
The MEAN trajectory aggregated onto station-class pairs, per time point, (nstations x nclasses) – the...
Definition fluid_kp.h:140
std::vector< double > t
Definition fluid_kp.h:121
std::vector< std::vector< double > > q
Definition fluid_kp.h:141
std::vector< Matrix< double > > QCov
Sigma AGGREGATED onto station-class pairs, per time point, (M*K)-by-(M*K) and indexed ir = r*M + i.
Definition fluid_kp.h:132
std::vector< Matrix< double > > UN
Definition fluid_kp.h:140
std::vector< Matrix< double > > TN
Definition fluid_kp.h:140
The second-order results of the moment-closure methods, i.e.
Matrix< double > Sigma
state-level covariance, on range(D)
Matrix< double > QStd
per station and class queue-length variance
Controls, defaulting to SolverOptions('Fluid') in the reference.
What the analyzer returns, in the same shape as the MVA solver's result.
bool has_moments
result.solverSpecific.moments: set only by minnormal and refined.
std::vector< double > XN
FluidMomentReport moments
std::vector< double > xvec
the converged fluid state
std::vector< double > CN
One (station, class) block of the Ko-Pender state vector.
Definition fluid_kp.h:90
std::size_t cls
Definition fluid_kp.h:92
std::size_t offset
Definition fluid_kp.h:93
std::size_t nphases
Definition fluid_kp.h:94
std::size_t station
Definition fluid_kp.h:91
double weight
routing probability times entry-phase probability
Definition fluid_kp.h:114
std::size_t c
source station and class
Definition fluid_kp.h:108
std::size_t ip
destination entry phase
Definition fluid_kp.h:111
KpEventKind kind
Definition fluid_kp.h:107
std::size_t l
destination station and class
Definition fluid_kp.h:110
std::size_t j
source and target phase of the modulating chain
Definition fluid_kp.h:109
std::size_t off_dst
offset of the destination block
Definition fluid_kp.h:113
std::vector< std::size_t > minus
The jump: -1 at minus, +1 at each plus; a phase change carries both.
Definition fluid_kp.h:116
std::vector< std::size_t > plus
Definition fluid_kp.h:116
std::size_t off_src
offset of the source block
Definition fluid_kp.h:112
Matrix< T > D0
The (D0,D1) pair when the type carries one directly.
Definition lang_types.h:853
static constexpr double Zero
Definition lang_types.h:762
What sn_schedule_nominal returns, in the reference's own output order.
std::vector< Matrix< T > > segD1
per-segment D1
std::vector< T > breakpoints
the boundary vector, nseg + 1 long
std::vector< Matrix< T > > segD0
per-segment D0, already in MAP form