LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_dae.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_DAE_H
6#define LINE_SOLVERS_FLUID_FLUID_DAE_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * The min-normal closure as a DIFFERENTIAL-ALGEBRAIC system: `solver_fluid_dae.m`.
12 *
13 * SOLVER_FLUID_MOMENTS already solves a differential system (the mean) coupled
14 * to an algebraic one (the covariance). It solves them by SUCCESSIVE
15 * SUBSTITUTION: integrate the mean to its fixed point at a held variance, solve
16 * the Lyapunov equation there, extract sigma2, repeat. This states the same
17 * closure as one system and solves it as one system. The closure itself is
18 * unchanged -- the drift, the rate factors and the Lyapunov equation are taken
19 * from FLUID_MOMENT_TERMS and FLUID_LYAPUNOV untouched -- only the way the
20 * coupled equations are discharged.
21 *
22 * STEADY STATE is an algebraic system, not an integration:
23 *
24 * 0 = D r(x, sigma2) drift residual, nstate rows
25 * 0 = C x - Nchain population conservation, one row per
26 * closed chain
27 * 0 = sigma2 - sigmaOf(x, sigma2) closure consistency, one row per
28 * closable station
29 *
30 * solved simultaneously by a damped projected Newton. Integrating a stable ODE
31 * until it stops moving is a poor way to solve f(x)=0: the cost is set by the
32 * slowest mode of the model rather than by the accuracy wanted, which is why
33 * the stiff models are expensive on the substitution route.
34 *
35 * TRANSIENT is an index-1 DAE with a SINGULAR MASS MATRIX, integrated by the
36 * vendored RODAS (third_party/rodas.hpp):
37 *
38 * d/dt x = D r(x, sigma2) differential rows
39 * 0 = C x - Nchain algebraic rows
40 *
41 * RODAS IS THE DEFAULT AND THE ONLY INTEGRATOR HERE. The rest of the fluid
42 * solver runs LSODA (line/util/lsoda.h) so that MATLAB, the JAR, Python and C++
43 * agree in the last digits, but LSODA integrates y' = f and cannot carry a
44 * singular mass matrix at all, so it is not a candidate for this path. RODAS is
45 * a Rosenbrock method of order (3)4 for M y' = f with singular M, and being a
46 * fixed sequence of six linear solves rather than an iteration it has no
47 * convergence history for a future port to diverge on -- which is what makes it
48 * the right choice for a route that the other three codebases do not have yet.
49 *
50 * WHY THE CONSTRAINT IS WRITTEN DIFFERENTLY IN THE TWO MODES. At a fixed point
51 * every flow already balances, so the differentiated form d/dt(Cx) = 0 is
52 * satisfied by anything and pins nothing; the steady state therefore uses
53 * `C x = N` directly. The transient needs the opposite: the mass matrix zeroes
54 * one row per chain and the constraint residual is written there, which is the
55 * index-1 form RODAS integrates.
56 *
57 * WHAT THE ALGEBRAIC CONSTRAINT BUYS. Population conservation otherwise holds
58 * only to integrator tolerance: it is a consequence of the drift (the rows of D
59 * sum to zero on a closed chain), never an equation. Writing it as a constraint
60 * also makes the Newton system solvable, because the drift Jacobian is singular
61 * along exactly the conserved directions -- the same singularity FLUID_LYAPUNOV
62 * works around by projecting onto range(D) -- so the constraint rows supply the
63 * missing rank instead of a pseudo-inverse hiding it.
64 *
65 * WHY THE COVARIANCE IS NOT A NEWTON UNKNOWN. Sigma is nstate^2 entries, so a
66 * Jacobian over it is quartic work -- strictly worse than the cubic Lyapunov
67 * solves it would replace. Sigma is LINEAR in itself for a held x, so it is
68 * eliminated by one Lyapunov solve per residual evaluation and only sigma2, M
69 * numbers, joins x in the unknown vector.
70 *
71 * @see fluid_moments.h - the closure this solves, and the substitution route
72 * @see third_party/rodas.hpp - the DAE integrator
73 */
74
75#include <algorithm>
76#include <cmath>
77#include <cstddef>
78#include <iostream>
79#include <limits>
80#include <string>
81#include <vector>
82
86#include "line/util/error.h"
87#include "line/util/lstsq.h"
88#include "line/util/lu.h"
89#include "line/util/matrix.h"
90#include "rodas.hpp"
91
92namespace line {
93namespace fluid {
94
95/**
96 * Finite capacity regions as linear admission constraints on the fluid state.
97 *
98 * EVERY FORM THE REGION CAN TAKE IS ONE FAMILY. A region carries a global job
99 * cap, per-class caps, a memory budget with per-class sizes, and an optional
100 * linear pair (A,b); all four are the same object once written against the
101 * state, `Arow x <= b` with Arow the per-class weight on the region's
102 * coordinates. Collapsing them here means the solver carries one mechanism
103 * rather than four.
104 *
105 * ONLY WAITQ IS A CONSTRAINT ON THIS DRIFT. Under a waiting queue a blocked job
106 * waits outside the region and is admitted later, so the population is
107 * conserved and only the admission FLOW is throttled. DROP destroys the job,
108 * BAS/BBS/RSRD hold a server upstream, and the retrial rules move it to an
109 * orbit: each changes the event set itself, so each needs a different drift
110 * rather than a constraint on this one.
111 */
113 Matrix<double> A; ///< (ncon x nstate)
114 Matrix<double> As; ///< (ncon x nstaging), see fluid_dae_extend
115 std::vector<double> b;
116 std::vector<std::size_t> region; ///< which region, npos for a station cap
117 std::vector<std::size_t> station; ///< which station, npos for a region cap
118 std::vector<std::size_t> klass_row; ///< which class, npos when several
119 std::vector<bool> staged; ///< true where the job waits in a room
120 std::vector<std::string> label;
121 std::vector<std::vector<bool> > member; ///< (nregions x nstate)
122 std::vector<std::size_t> coord_class;
123 std::size_t nregions = 0;
124 bool empty() const { return b.empty(); }
125 static std::size_t none() { return static_cast<std::size_t>(-1); }
126};
127
128/**
129 * The largest `row x` the population can produce, ignoring the coupling.
130 *
131 * An upper bound is what is wanted: too loose only costs a constraint that stays
132 * inactive, while too tight would discard a cap that does bind. THE BOUND IS PER
133 * CLASS, and it has to be -- the heaviest weight times the whole population never
134 * prunes a PER-CLASS cap set to its own class population, exactly the row the
135 * struct refresh derives at every station of every closed model.
136 */
137inline double fluid_dae_reach(const std::vector<double>& row,
138 const std::vector<std::size_t>& coord_class,
139 const std::vector<double>& njobs, std::size_t K) {
140 double total = 0.0;
141 for (std::size_t k = 0; k < K; ++k) {
142 double w = 0.0;
143 bool any = false;
144 for (std::size_t s = 0; s < row.size(); ++s)
145 if (coord_class[s] == k) { w = std::max(w, row[s]); any = true; }
146 if (!any || w <= 0.0) continue;
147 const double nk = (k < njobs.size()) ? njobs[k] : std::numeric_limits<double>::infinity();
148 if (!std::isfinite(nk)) return std::numeric_limits<double>::infinity();
149 total += w * nk;
150 }
151 return total;
152}
153
154template <typename T>
156 const FluidMomentTerms& terms) {
158 const std::size_t nstate = terms.nstate;
159 const std::size_t M = terms.class_block.size();
160 const std::size_t K = M ? terms.class_block[0].size() : 0;
161 const std::size_t NONE = FluidDaeConstraints::none();
162 con.nregions = sn.regions.size();
163 con.coord_class.assign(nstate, 0);
164 std::vector<std::size_t> coord_station(nstate, NONE);
165 for (std::size_t i = 0; i < M; ++i)
166 for (std::size_t k = 0; k < K; ++k)
167 for (std::size_t s : terms.class_block[i][k]) {
168 con.coord_class[s] = k;
169 coord_station[s] = i;
170 }
171 con.member.assign(std::max<std::size_t>(con.nregions, 1), std::vector<bool>(nstate, false));
172
173 const double UNB = -1.0;
174 std::vector<std::vector<double> > rows;
175 std::vector<std::size_t> rgs, sts, kls;
176 std::vector<bool> sdg;
177 for (std::size_t f = 0; f < con.nregions; ++f) {
178 const typename qn::NetworkStruct<T>::Region& R = sn.regions[f];
179 std::vector<bool> inR(nstate, false);
180 bool any_member = false;
181 for (std::size_t s = 0; s < nstate; ++s) {
182 const std::size_t i = coord_station[s];
183 if (i != NONE && i < R.members.size() && R.members[i]) { inR[s] = true; any_member = true; }
184 }
185 if (!any_member) continue;
186 con.member[f] = inR;
187
188 for (std::size_t k = 0; k < K && k < R.rule.size(); ++k)
190 throw UnsupportedError(
191 "fluid_dae_constraints: region " + std::to_string(f + 1) +
192 " applies a drop rule other than a waiting queue to class " +
193 std::to_string(k + 1) +
194 ". Only a waiting queue conserves the population and throttles the admission "
195 "flow, which is what an algebraic equation on this drift can express.");
196 for (std::size_t k = 0; k < K && k < R.weight.size(); ++k)
197 if (std::fabs(static_cast<double>(R.weight[k]) - 1.0) > 1e-12)
198 throw UnsupportedError(
199 "fluid_dae_constraints: region " + std::to_string(f + 1) +
200 " sets per-class admission weights, which decide WHICH blocked class enters "
201 "when capacity frees up. The throttle carries no such priority and would "
202 "ignore them silently.");
203
204 std::size_t member_row = 0;
205 for (std::size_t i = 0; i < R.members.size(); ++i)
206 if (R.members[i]) { member_row = i; break; }
207
208 // 1. region-global job cap: column K of `cap`
209 if (member_row < R.cap.size() && R.cap[member_row].size() > K) {
210 const double g = static_cast<double>(R.cap[member_row][K]);
211 if (std::isfinite(g) && g != UNB && g >= 0.0) {
212 std::vector<double> row(nstate, 0.0);
213 for (std::size_t s = 0; s < nstate; ++s) if (inR[s]) row[s] = 1.0;
214 rows.push_back(row); con.b.push_back(g);
215 rgs.push_back(f); sts.push_back(NONE); kls.push_back(NONE); sdg.push_back(true);
216 con.label.push_back("region " + std::to_string(f + 1) + " global job cap");
217 }
218 }
219 // 2. per-class job caps
220 if (member_row < R.cap.size())
221 for (std::size_t k = 0; k < K && k < R.cap[member_row].size(); ++k) {
222 const double c = static_cast<double>(R.cap[member_row][k]);
223 if (!std::isfinite(c) || c == UNB || c < 0.0) continue;
224 std::vector<double> row(nstate, 0.0);
225 bool any = false;
226 for (std::size_t s = 0; s < nstate; ++s)
227 if (inR[s] && con.coord_class[s] == k) { row[s] = 1.0; any = true; }
228 if (!any) continue;
229 rows.push_back(row); con.b.push_back(c);
230 rgs.push_back(f); sts.push_back(NONE); kls.push_back(k); sdg.push_back(true);
231 con.label.push_back("region " + std::to_string(f + 1) + " class " +
232 std::to_string(k + 1) + " job cap");
233 }
234 // 3. region-global memory budget, weighted by each class's size
235 if (member_row < R.maxmem.size()) {
236 const double mem = static_cast<double>(R.maxmem[member_row]);
237 if (std::isfinite(mem) && mem != UNB && mem >= 0.0) {
238 std::vector<double> row(nstate, 0.0);
239 bool any = false;
240 for (std::size_t s = 0; s < nstate; ++s) {
241 if (!inR[s]) continue;
242 const std::size_t k = con.coord_class[s];
243 const double w = (k < R.size.size()) ? static_cast<double>(R.size[k]) : 1.0;
244 if (w != 0.0) { row[s] = w; any = true; }
245 }
246 if (any) {
247 rows.push_back(row); con.b.push_back(mem);
248 rgs.push_back(f); sts.push_back(NONE); kls.push_back(NONE); sdg.push_back(true);
249 con.label.push_back("region " + std::to_string(f + 1) + " memory budget");
250 }
251 }
252 }
253 // 4. explicit linear constraints
254 for (std::size_t c = 0; c < R.lincon_A.rows(); ++c) {
255 std::vector<double> row(nstate, 0.0);
256 bool any = false;
257 for (std::size_t k = 0; k < K && k < R.lincon_A.cols(); ++k) {
258 const double a = static_cast<double>(R.lincon_A(c, k));
259 if (a == 0.0) continue;
260 for (std::size_t s = 0; s < nstate; ++s)
261 if (inR[s] && con.coord_class[s] == k) { row[s] = a; any = true; }
262 }
263 if (!any) continue;
264 rows.push_back(row);
265 con.b.push_back(static_cast<double>(R.lincon_b[c]));
266 rgs.push_back(f); sts.push_back(NONE); kls.push_back(NONE); sdg.push_back(true);
267 con.label.push_back("region " + std::to_string(f + 1) + " linear constraint " +
268 std::to_string(c + 1));
269 }
270 }
271
272 // 5. per-station buffers. A STATION CAP IS THE ONE-STATION CASE OF THE SAME
273 // ROW, and every fluid method other than this one ignores it outright: nothing
274 // in the fluid tree reads sn.cap or sn.classcap, so a capped station was
275 // integrated as an unbounded one and the table reported more jobs in the buffer
276 // than the buffer holds.
277 for (std::size_t i = 0; i < M; ++i) {
278 if (terms.is_ext[i]) continue; // a source holds no jobs
279 bool any_at = false;
280 for (std::size_t s = 0; s < nstate; ++s) any_at |= coord_station[s] == i;
281 if (!any_at) continue;
282 if (i < sn.cap.size()) {
283 const double g = static_cast<double>(sn.cap[i]);
284 if (std::isfinite(g) && g >= 0.0) {
285 std::vector<double> row(nstate, 0.0);
286 for (std::size_t s = 0; s < nstate; ++s) if (coord_station[s] == i) row[s] = 1.0;
287 rows.push_back(row); con.b.push_back(g);
288 rgs.push_back(NONE); sts.push_back(i); kls.push_back(NONE); sdg.push_back(false);
289 con.label.push_back("station " + std::to_string(i + 1) + " buffer");
290 }
291 }
292 if (i < sn.classcap.size())
293 for (std::size_t k = 0; k < K && k < sn.classcap[i].size(); ++k) {
294 const double c = static_cast<double>(sn.classcap[i][k]);
295 if (!std::isfinite(c) || c < 0.0) continue;
296 std::vector<double> row(nstate, 0.0);
297 bool any = false;
298 for (std::size_t s = 0; s < nstate; ++s)
299 if (coord_station[s] == i && con.coord_class[s] == k) { row[s] = 1.0; any = true; }
300 if (!any) continue;
301 rows.push_back(row); con.b.push_back(c);
302 rgs.push_back(NONE); sts.push_back(i); kls.push_back(k); sdg.push_back(false);
303 con.label.push_back("station " + std::to_string(i + 1) + " class " +
304 std::to_string(k + 1) + " buffer");
305 }
306 }
307
308 const std::size_t ncand = rows.size();
309 std::vector<bool> keep(ncand, true);
310 const std::vector<double> njobs = sn.njobs();
311
312 // A CAP THE POPULATION CANNOT REACH IS NOT A CAP, and dropping it here keeps it
313 // out of the active-set loop, where it would be tested on every pass and never
314 // bind while its multiplier stayed an unknown with no equation to pin it.
315 for (std::size_t c = 0; c < ncand; ++c)
316 if (fluid_dae_reach(rows[c], con.coord_class, njobs, K) <= con.b[c] + 1e-14)
317 keep[c] = false;
318
319 // A CAP ON COORDINATES THE IMMEDIATE REDUCTION FOLDED AWAY IS VACUOUS, NOT
320 // MALFORMED. `hide_immediate` stochastic-complements an Immediate-rate
321 // coordinate out of the event set -- the MMT transform's zero-service Join is
322 // one, until the fork-join fixed point gives it a synchronisation delay -- and
323 // the reduced drift then holds no mass there and has no event landing on it.
324 // Left in, such a row reaches `fluid_dae_gates` with no gating event and is
325 // reported as a limit the model cannot approach, which is the right message for
326 // a station that really does hold jobs and the wrong one here: this station
327 // holds none, so its buffer is satisfied identically.
328 for (std::size_t c = 0; c < ncand; ++c) {
329 if (!keep[c]) continue;
330 bool live = false;
331 for (std::size_t s = 0; s < nstate && !live; ++s)
332 if (rows[c][s] != 0.0 && !fluid_coord_eliminated(terms, s)) live = true;
333 if (!live) keep[c] = false;
334 }
335
336 // THE SAME ROW TWICE IS A SINGULAR NEWTON SYSTEM, not a redundancy the least
337 // squares absorbs: two identical rows both bind, each takes a multiplier, and
338 // nothing distinguishes them. The struct refresh derives classcap from cap, so a
339 // single-class model declares the station total and the class buffer as the same
340 // row. The TIGHTER bound survives; on a tie the region row does.
341 for (std::size_t c = 0; c < ncand; ++c) {
342 if (!keep[c]) continue;
343 for (std::size_t d = c + 1; d < ncand; ++d) {
344 if (!keep[d]) continue;
345 bool same = true;
346 for (std::size_t s = 0; s < nstate && same; ++s)
347 same = std::fabs(rows[c][s] - rows[d][s]) <= 1e-14;
348 if (!same) continue;
349 const bool takeover = con.b[d] < con.b[c] - 1e-14 ||
350 (std::fabs(con.b[d] - con.b[c]) <= 1e-14 && sdg[d] && !sdg[c]);
351 if (takeover) {
352 con.b[c] = con.b[d]; rgs[c] = rgs[d]; sts[c] = sts[d]; kls[c] = kls[d];
353 sdg[c] = sdg[d]; con.label[c] = con.label[d];
354 }
355 keep[d] = false;
356 }
357 }
358
359 // A ROW ITS OWN PER-CLASS ROWS ALREADY IMPLY IS RANK, NOT INFORMATION: a
360 // two-class station capped 3 and 3 also declares a total of 6, exactly the sum
361 // of the two class rows, and all three then bind together with rank 2. Only an
362 // exact implication is dropped, so a total TIGHTER than the sum of its parts
363 // survives.
364 for (std::size_t c = 0; c < ncand; ++c) {
365 if (!keep[c] || kls[c] != NONE) continue;
366 std::vector<std::size_t> classes;
367 for (std::size_t k = 0; k < K; ++k)
368 for (std::size_t s = 0; s < nstate; ++s)
369 if (con.coord_class[s] == k && coord_station[s] != NONE && rows[c][s] > 0.0) {
370 classes.push_back(k);
371 break;
372 }
373 if (classes.empty()) continue;
374 bool implied = true;
375 double budget = 0.0;
376 for (std::size_t ci = 0; ci < classes.size() && implied; ++ci) {
377 const std::size_t k = classes[ci];
378 std::size_t part = NONE;
379 for (std::size_t d = 0; d < ncand; ++d) {
380 if (!keep[d] || d == c || kls[d] != k || rgs[d] != rgs[c] || sts[d] != sts[c])
381 continue;
382 bool covers = true;
383 for (std::size_t s = 0; s < nstate && covers; ++s)
384 if (con.coord_class[s] == k && coord_station[s] != NONE &&
385 rows[c][s] > 0.0 && rows[d][s] < rows[c][s] - 1e-14)
386 covers = false;
387 if (covers) { part = d; break; }
388 }
389 if (part == NONE) implied = false;
390 else budget += con.b[part];
391 }
392 if (implied && budget <= con.b[c] + 1e-14) keep[c] = false;
393 }
394
395 std::vector<std::vector<double> > kept_rows;
396 std::vector<double> kept_b;
397 std::vector<std::string> kept_lab;
398 for (std::size_t c = 0; c < ncand; ++c) {
399 if (!keep[c]) continue;
400 kept_rows.push_back(rows[c]);
401 kept_b.push_back(con.b[c]);
402 kept_lab.push_back(con.label[c]);
403 con.region.push_back(rgs[c]);
404 con.station.push_back(sts[c]);
405 con.klass_row.push_back(kls[c]);
406 con.staged.push_back(sdg[c]);
407 }
408 con.b = kept_b;
409 con.label = kept_lab;
410 con.A = Matrix<double>(kept_rows.size(), nstate, 0.0);
411 for (std::size_t r = 0; r < kept_rows.size(); ++r)
412 for (std::size_t s = 0; s < nstate; ++s) con.A(r, s) = kept_rows[r][s];
413 con.As = Matrix<double>(kept_rows.size(), 0, 0.0);
414
415 // WHICH STATION RULES ARE A CONSTRAINT ON THIS DRIFT, and which are a different
416 // event set. THE RULE IS NOT WHAT DECIDES THE SEMANTICS -- the class type is,
417 // exactly as State.arrivalIsLost decides it for every other solver: a closed job
418 // is never lost (the upstream departure is disabled instead) and an open one is
419 // never held. So a waiting queue and a drop are BOTH constraints here; what is
420 // refused is the rules that add STATE. A rule is only a contradiction where the
421 // cap can BIND, so this runs on the surviving rows.
422 for (std::size_t c = 0; c < con.b.size(); ++c) {
423 const std::size_t i = con.station[c];
424 if (i == NONE || i >= sn.droprule.size()) continue;
425 for (std::size_t k = 0; k < K && k < sn.droprule[i].size(); ++k) {
426 bool weighs = false;
427 for (std::size_t s = 0; s < nstate; ++s)
428 if (coord_station[s] == i && con.coord_class[s] == k && con.A(c, s) > 0.0)
429 weighs = true;
430 if (!weighs) continue;
431 const lang::DropStrategy rule = sn.droprule[i][k];
433 throw UnsupportedError(
434 "fluid_dae_constraints: station " + std::to_string(i + 1) +
435 " applies a blocking or retrial rule to class " + std::to_string(k + 1) +
436 ", and its buffer binds. Only a waiting queue or a drop is a constraint on "
437 "this drift: BAS/BBS/RSRD add a blocked-server state to the upstream station "
438 "and the retrial rules add an orbit, so each needs a different drift rather "
439 "than an algebraic equation on this one.");
440 }
441 }
442 return con;
443}
444
445/**
446 * Which events each cap throttles, and what happens to the mass it stops.
447 *
448 * THE THREE ANSWERS ARE THE MODEL, AND THEY ARE NOT INTERCHANGEABLE:
449 * STAGED a finite capacity region under a waiting queue. The job COMPLETES
450 * upstream service and waits outside the region, which is what JMT and
451 * LDES simulate.
452 * HELD a station buffer reached by a CLOSED class. LINE refuses to lose a
453 * closed job and disables the upstream departure instead, so the job is
454 * still at the upstream station and the whole event is scaled.
455 * LOSS a station buffer reached by an OPEN class. The arrival fires and only
456 * the carried flow is admitted, so the ARRIVAL leg alone is scaled.
457 */
459 std::vector<std::vector<bool> > gate; ///< (ncon x nevents)
460 std::vector<std::vector<bool> > held;
461 std::vector<std::vector<bool> > loss;
462 Matrix<double> Dn, DnExt, Dp; ///< the jump matrix split in three
463 bool has = false;
464};
465
466template <typename T>
468 const FluidDaeConstraints& con) {
470 const std::size_t ncon = con.b.size(), nev = t.D.cols(), nstate = t.nstate;
471 g.gate.assign(ncon, std::vector<bool>(nev, false));
472 g.held.assign(ncon, std::vector<bool>(nev, false));
473 g.loss.assign(ncon, std::vector<bool>(nev, false));
474 if (ncon == 0) return g;
475 g.has = true;
476 g.Dn = Matrix<double>(nstate, nev, 0.0);
477 g.DnExt = Matrix<double>(nstate, nev, 0.0);
478 g.Dp = Matrix<double>(nstate, nev, 0.0);
479 std::vector<bool> ext_coord(nstate, false);
480 for (std::size_t i = 0; i < t.station_block.size(); ++i)
481 if (t.is_ext[i])
482 for (std::size_t s : t.station_block[i]) ext_coord[s] = true;
483 for (std::size_t s = 0; s < nstate; ++s)
484 for (std::size_t e = 0; e < nev; ++e) {
485 const double d = t.D(s, e);
486 // A LOST ARRIVAL IS RETURNED TO THE SOURCE POOL: the EXT coordinate is a
487 // normalisation and not a population, so scaling only the arrival leg of
488 // a lost event would unbalance its row by exactly the loss.
489 if (d < 0.0) { if (ext_coord[s]) g.DnExt(s, e) = d; else g.Dn(s, e) = d; }
490 if (d > 0.0) g.Dp(s, e) = d;
491 }
492 const double tol = 1e-7;
493 const std::vector<double> njobs = sn.njobs();
494 for (std::size_t c = 0; c < ncon; ++c) {
495 bool any = false;
496 for (std::size_t e = 0; e < nev; ++e) {
497 double delta = 0.0;
498 for (std::size_t s = 0; s < nstate; ++s) delta += con.A(c, s) * t.D(s, e);
499 if (delta <= tol) continue;
500 g.gate[c][e] = true;
501 any = true;
502 if (con.staged[c]) continue;
503 // the class is read off the coordinate the mass LANDS on, inside the
504 // capped station: a class switch on entry would otherwise ask the class
505 // the job is leaving behind whether it may be lost
506 std::size_t k = static_cast<std::size_t>(-1);
507 for (std::size_t s = 0; s < nstate; ++s)
508 if (g.Dp(s, e) > tol && con.A(c, s) > 0.0) { k = con.coord_class[s]; break; }
509 const bool isopen = k != static_cast<std::size_t>(-1) && k < njobs.size() &&
510 !std::isfinite(njobs[k]);
511 g.loss[c][e] = isopen;
512 g.held[c][e] = !isopen;
513 }
514 if (!any)
515 throw UnsupportedError(
516 "fluid_dae_gates: no event increases " + con.label[c] +
517 ", so the cap can never be approached and there is no admission flow for the "
518 "constraint to throttle. This is a malformed limit rather than a solvable one.");
519 }
520 return g;
521}
522
523
524/**
525 * The waiting room outside a capped region, as fluid coordinates.
526 *
527 * WHY THE CONSTRAINT ALONE IS NOT ENOUGH. Throttling a region's admission events
528 * does hold its population at the cap, but it holds it by slowing the UPSTREAM
529 * STATION'S COMPLETIONS -- an admission event IS that station finishing a job --
530 * so blocked mass piles up at a station it has already finished being served by.
531 * Where that station is a delay the error is visible as a broken Little's law.
532 * A waiting queue means the job COMPLETES upstream service and then waits; it is
533 * somewhere else, and the model needs somewhere else to put it.
534 *
535 * Each region gains one coordinate per class, and every admission splits in two:
536 * upstream -> staging at the nominal rate, so the upstream station empties
537 * exactly as it would with no region;
538 * staging -> region at theta_f * s_{f,c}, the throttled leg.
539 * The two jumps sum to the original, so only where the mass rests changes.
540 *
541 * ONE THROTTLE PER REGION, hence one binding cap per region: a region has a
542 * single admission control -- how fast its queue drains -- so it can satisfy
543 * exactly one equality.
544 */
546 std::size_t n = 0;
547 std::vector<std::size_t> region, klass; ///< per staging coordinate
548 std::vector<bool> adm; ///< per event: an admission?
549 std::vector<std::size_t> adm_region, adm_stage;
550 std::vector<std::vector<bool> > gated_by; ///< (ncon x n) which rows gate which room
551 Matrix<double> Dn, Dp; ///< negative and positive parts of D
552};
553
555 const FluidDaeConstraints& con) {
556 FluidDaeStaging stg;
557 const std::size_t nev = t.D.cols();
558 const std::size_t ncon = con.b.size();
559 stg.adm.assign(nev, false);
560 stg.adm_region.assign(nev, 0);
561 stg.adm_stage.assign(nev, 0);
562 stg.gated_by.assign(ncon, std::vector<bool>());
563 bool any_staged = false;
564 for (std::size_t c = 0; c < ncon; ++c) any_staged = any_staged || con.staged[c];
565 // A STATION BUFFER GETS NO ROOM, and that is not an omission: LINE disables the
566 // upstream departure rather than moving the job out, so the blocked mass is
567 // still at the upstream station and still counted there.
568 if (con.empty() || con.nregions == 0 || !any_staged) return stg;
569
570 const double tol = 1e-9;
571 stg.Dn = Matrix<double>(t.nstate, nev, 0.0);
572 stg.Dp = Matrix<double>(t.nstate, nev, 0.0);
573 for (std::size_t s = 0; s < t.nstate; ++s)
574 for (std::size_t e = 0; e < nev; ++e) {
575 const double d = t.D(s, e);
576 if (d < 0.0) stg.Dn(s, e) = d;
577 if (d > 0.0) stg.Dp(s, e) = d;
578 }
579
580 std::vector<bool> staged_region(con.nregions, false);
581 for (std::size_t c = 0; c < ncon; ++c)
582 if (con.staged[c] && con.region[c] < con.nregions) staged_region[con.region[c]] = true;
583
584 const std::size_t K = t.class_block.empty() ? 0 : t.class_block[0].size();
585 std::vector<std::vector<std::size_t> > idx(con.nregions, std::vector<std::size_t>(K, 0));
586 for (std::size_t f = 0; f < con.nregions; ++f) {
587 if (f >= con.member.size() || !staged_region[f]) continue;
588 for (std::size_t e = 0; e < nev; ++e) {
589 // net change of this region's population: positive means the event
590 // brings mass in from outside, which is what the queue feeds
591 double delta = 0.0;
592 for (std::size_t s = 0; s < t.nstate; ++s)
593 if (con.member[f][s]) delta += t.D(s, e);
594 if (delta <= tol) continue;
595 // the class is read off the coordinate the mass LANDS on, inside the
596 // region: a class switch on entry would otherwise stage the job under
597 // the class it is leaving behind
598 std::size_t k = K;
599 for (std::size_t s = 0; s < t.nstate; ++s)
600 if (con.member[f][s] && stg.Dp(s, e) > tol) { k = con.coord_class[s]; break; }
601 if (k >= K) continue;
602 if (idx[f][k] == 0) {
603 stg.region.push_back(f);
604 stg.klass.push_back(k);
605 idx[f][k] = stg.region.size(); // 1-based, 0 means none
606 }
607 stg.adm[e] = true;
608 stg.adm_region[e] = f;
609 stg.adm_stage[e] = idx[f][k] - 1;
610 }
611 }
612 stg.n = stg.region.size();
613
614 // WHICH ROWS GATE WHICH ROOM. A region-global cap gates every room of its
615 // region, a per-class cap only the room of its class. A room gated by several
616 // ACTIVE rows drains at the harmonic composition of their rates, which is what
617 // lets two caps of one region bind at once.
618 stg.gated_by.assign(ncon, std::vector<bool>(stg.n, false));
619 for (std::size_t c = 0; c < ncon; ++c) {
620 if (!con.staged[c] || con.region[c] >= con.nregions) continue;
621 for (std::size_t j = 0; j < stg.n; ++j) {
622 if (stg.region[j] != con.region[c]) continue;
623 double w = 0.0;
624 for (std::size_t s = 0; s < t.nstate; ++s)
625 if (con.member[con.region[c]][s] && con.coord_class[s] == stg.klass[j])
626 w = std::max(w, con.A(c, s));
627 stg.gated_by[c][j] = w > 0.0;
628 }
629 }
630 return stg;
631}
632
633/**
634 * Extend every cap to the staging coordinates that hold mass INSIDE it.
635 *
636 * A waiting room is outside the region it feeds, which is the whole point of it --
637 * but it is not outside every OTHER limit. Where two regions overlap, an admission
638 * into the inner one is an INTERNAL move of the outer one: the job leaves a station
639 * of the outer region, waits, and re-enters a station of the same outer region,
640 * never having left it. Counting only the state coordinates would take that mass
641 * out of the outer cap for as long as it waits, and the Newton system that results
642 * is inconsistent rather than merely inexact.
643 */
645 const FluidMomentTerms& t) {
646 const std::size_t ncon = con.b.size();
647 con.As = Matrix<double>(ncon, stg.n, 0.0);
648 if (ncon == 0 || stg.n == 0) return;
649 const std::size_t nev = t.D.cols();
650 const double tol = 1e-9;
651 std::vector<std::vector<std::size_t> > feeds(stg.n);
652 std::vector<std::size_t> dest(stg.n, static_cast<std::size_t>(-1));
653 for (std::size_t e = 0; e < nev; ++e) {
654 if (!stg.adm[e]) continue;
655 const std::size_t j = stg.adm_stage[e];
656 for (std::size_t s = 0; s < t.nstate; ++s) {
657 if (t.D(s, e) < -tol &&
658 std::find(feeds[j].begin(), feeds[j].end(), s) == feeds[j].end())
659 feeds[j].push_back(s);
660 if (dest[j] == static_cast<std::size_t>(-1) && t.D(s, e) > tol &&
661 con.member[stg.region[j]][s])
662 dest[j] = s;
663 }
664 }
665 for (std::size_t c = 0; c < ncon; ++c)
666 for (std::size_t j = 0; j < stg.n; ++j) {
667 if (dest[j] == static_cast<std::size_t>(-1) || feeds[j].empty()) continue;
668 const double w = con.A(c, dest[j]);
669 if (w <= 0.0) continue;
670 bool all_inside = true;
671 for (std::size_t a = 0; a < feeds[j].size() && all_inside; ++a)
672 all_inside = con.A(c, feeds[j][a]) > 0.0;
673 if (all_inside) con.As(c, j) = w;
674 }
675}
676
677/** The two legs of every event under the active caps, and the waiting rooms. */
679 std::vector<double> rup; ///< the rate each event FIRES at
680 std::vector<double> rin; ///< the rate mass LANDS at
681 std::vector<double> ds; ///< the derivative of each room
682 std::vector<double> drain; ///< each room's total outflow
683};
684
685/**
686 * Shared by the steady-state residual and the transient right-hand side so that
687 * the two solve the SAME model and not two spellings of it. What differs is only
688 * what a STAGED cap's multiplier means: a drain RATE for the steady state, where
689 * the room mass is pinned by the cap; the admitted FLOW for the transient, because
690 * at the instant a region fills its room is EMPTY and a rate times zero mass cannot
691 * hold the cap. The two rules agree at a fixed point, where the room mass is
692 * proportional to its inflow.
693 *
694 * A held or lost cap composes as a PRODUCT of fractions either way, which is what
695 * independent blocking gives and what keeps every active cap present in the
696 * Jacobian. Several staged caps gating one room compose HARMONICALLY, because the
697 * waits a job serves in turn add.
698 */
700 const FluidDaeStaging& stg, const FluidDaeConstraints& con,
701 const std::vector<std::size_t>& active,
702 const std::vector<double>& sg, const std::vector<double>& r,
703 const std::vector<double>& mult, bool staged_flow) {
704 const std::size_t nev = r.size(), ns = stg.n, nact = active.size();
705 FluidDaeLegs out;
706 std::vector<double> whole(nev, 1.0), entry(nev, 1.0);
707 std::vector<double> inv_theta(ns, 0.0), flow_of(ns, 0.0);
708 std::vector<bool> throttled(ns, false);
709 for (std::size_t k = 0; k < nact; ++k) {
710 const std::size_t c = active[k];
711 const double m = mult[k];
712 if (con.staged[c]) {
713 for (std::size_t j = 0; j < ns; ++j)
714 if (stg.gated_by[c][j]) {
715 throttled[j] = true;
716 if (!staged_flow)
717 inv_theta[j] += (m > 1e-14) ? 1.0 / m
718 : std::numeric_limits<double>::infinity();
719 }
720 } else {
721 for (std::size_t e = 0; e < nev; ++e) {
722 if (gates.held[c][e]) whole[e] *= m;
723 if (gates.loss[c][e]) entry[e] *= m;
724 }
725 }
726 }
727 std::vector<bool> split(nev, false);
728 for (std::size_t e = 0; e < nev; ++e)
729 if (ns && stg.adm[e] && throttled[stg.adm_stage[e]]) split[e] = true;
730
731 out.rup.assign(nev, 0.0);
732 out.rin.assign(nev, 0.0);
733 for (std::size_t e = 0; e < nev; ++e) {
734 // A staged event is NOT suppressed upstream even when a held cap gates it:
735 // the job completes upstream service into the waiting room, so the held
736 // fraction moves to the room's exit leg below.
737 out.rup[e] = r[e] * (split[e] ? 1.0 : whole[e]);
738 out.rin[e] = out.rup[e] * entry[e];
739 }
740 std::vector<double> inflow(ns, 0.0), R(ns, 0.0);
741 for (std::size_t e = 0; e < nev; ++e)
742 if (split[e]) {
743 inflow[stg.adm_stage[e]] += out.rup[e];
744 R[stg.adm_stage[e]] += out.rup[e];
745 }
746 if (staged_flow) {
747 for (std::size_t k = 0; k < nact; ++k) {
748 const std::size_t c = active[k];
749 if (!con.staged[c]) continue;
750 double mass = 0.0, tot = 0.0;
751 std::size_t nrooms = 0;
752 for (std::size_t j = 0; j < ns; ++j)
753 if (stg.gated_by[c][j]) { mass += sg[j]; tot += inflow[j]; ++nrooms; }
754 if (nrooms == 0) continue;
755 for (std::size_t j = 0; j < ns; ++j) {
756 if (!stg.gated_by[c][j]) continue;
757 double w;
758 if (mass > 1e-8) w = sg[j] / mass;
759 else if (tot > 1e-8) w = inflow[j] / tot;
760 else w = 1.0 / static_cast<double>(nrooms);
761 flow_of[j] += mult[k] * w;
762 }
763 }
764 } else {
765 for (std::size_t j = 0; j < ns; ++j) {
766 const double theta = inv_theta[j] > 0.0 ? 1.0 / inv_theta[j] : 0.0;
767 flow_of[j] = theta * sg[j];
768 }
769 }
770 out.drain = flow_of;
771 std::vector<double> left(ns, 0.0);
772 for (std::size_t e = 0; e < nev; ++e) {
773 if (!split[e]) continue;
774 const std::size_t j = stg.adm_stage[e];
775 const double q = R[j] > 1e-8 ? flow_of[j] * out.rup[e] / R[j] : 0.0;
776 // the held fraction gates the room's EXIT: what it stops stays in the room.
777 // The lost fraction gates the ARRIVAL: that mass leaves and is destroyed.
778 out.rin[e] = q * whole[e] * entry[e];
779 left[j] += q * whole[e];
780 }
781 for (std::size_t j = 0; j < ns; ++j)
782 if (R[j] > 1e-8) out.drain[j] = left[j];
783 out.ds.assign(ns, 0.0);
784 for (std::size_t j = 0; j < ns; ++j)
785 // a waiting room with no cap above it must be EMPTY, not merely balanced
786 out.ds[j] = throttled[j] ? inflow[j] - out.drain[j] : sg[j];
787 return out;
788}
789
790/** Population conservation, one row per CLOSED chain, in state space. */
792 Matrix<double> C; ///< (nchain x nstate)
793 std::vector<double> N; ///< chain populations
794};
795
796/**
797 * The conserved chains as equations.
798 *
799 * An open chain has no conserved population and contributes nothing. The EXT
800 * coordinates are excluded because the closing representation holds unit mass
801 * there as a normalisation constant, not as a job count.
802 */
803template <typename T>
805 const FluidMomentTerms& terms,
806 const FluidDaeStaging& stg) {
807 const std::size_t nstate = terms.nstate;
808 const std::size_t M = terms.class_block.size();
809 const std::size_t K = M ? terms.class_block[0].size() : 0;
810
811 std::vector<std::size_t> coord_class(nstate, 0), coord_station(nstate, 0);
812 for (std::size_t i = 0; i < M; ++i)
813 for (std::size_t k = 0; k < K; ++k)
814 for (std::size_t s : terms.class_block[i][k]) {
815 coord_class[s] = k;
816 coord_station[s] = i;
817 }
818
819 std::vector<std::vector<double> > rows;
820 std::vector<double> nvec;
821 const std::size_t nchains = sn.chains.size();
822 const std::vector<double> njobs = sn.njobs();
823 for (std::size_t ch = 0; ch < nchains; ++ch) {
824 std::vector<std::size_t> inch;
825 double Nch = 0.0;
826 bool finite = true;
827 for (std::size_t k = 0; k < K; ++k) {
828 if (k >= sn.chains[ch].size() || !sn.chains[ch][k]) continue;
829 inch.push_back(k);
830 const double nj = (k < njobs.size()) ? njobs[k] : 0.0;
831 if (!std::isfinite(nj)) finite = false;
832 Nch += nj;
833 }
834 if (inch.empty() || !finite || Nch <= 0.0) continue; // open chain
835 std::vector<double> row(nstate + stg.n, 0.0);
836 bool any = false;
837 for (std::size_t s = 0; s < nstate; ++s) {
838 if (terms.is_ext[coord_station[s]]) continue;
839 if (std::find(inch.begin(), inch.end(), coord_class[s]) == inch.end()) continue;
840 row[s] = 1.0;
841 any = true;
842 }
843 // A JOB IN THE WAITING QUEUE IS STILL IN THE CHAIN.
844 for (std::size_t j = 0; j < stg.n; ++j)
845 if (std::find(inch.begin(), inch.end(), stg.klass[j]) != inch.end())
846 row[nstate + j] = 1.0;
847 if (!any) continue;
848 rows.push_back(row);
849 nvec.push_back(Nch);
850 }
851
853 out.C = Matrix<double>(rows.size(), nstate + stg.n, 0.0);
854 for (std::size_t r = 0; r < rows.size(); ++r)
855 for (std::size_t s = 0; s < nstate + stg.n; ++s) out.C(r, s) = rows[r][s];
856 out.N = nvec;
857 return out;
858}
859
860
861
862/** Options that only the DAE route reads. */
864 /**
865 * The simultaneous solve carries one Lyapunov solve per residual evaluation
866 * and takes a finite-difference Jacobian over nstate + nclosable unknowns,
867 * so its cost is cubic per evaluation and quartic overall. That is
868 * affordable at the scale the moment methods run at, but the crossover is
869 * lower than the 200 `moment_maxstate` permits, so it gets its own limit.
870 */
871 std::size_t maxstate = 100;
872 std::size_t newton_max = 50;
873 /**
874 * Largest covariance dimension integrated ALONGSIDE the mean on the
875 * transient. The covariance adds nc^2 differential states and the Jacobian
876 * is formed by finite differences over all of them, so the cost grows as
877 * nc^4. Above this the mean is still integrated as a DAE -- population
878 * conservation stays an algebraic equation -- but the variance is held at
879 * its stationary value, which is what `minnormal` does for the whole of its
880 * transient anyway. Twin of `options.config.dae_maxcov`.
881 */
882 std::size_t maxcov = 25;
883};
884
885/**
886 * The controls the DAE route actually reads: the struct a caller pinned, with
887 * whatever `options.config` set on top of it.
888 *
889 * WHY THIS EXISTS. `dae_maxstate` and `dae_maxcov` are `options.config` entries
890 * in MATLAB, the JAR and native Python -- they are what a user reaches for to
891 * raise a refusal -- and they arrive here on `FluidOptions`, while the route
892 * reads `FluidDaeOptions`. Without this step the fields were unreachable from
893 * every ordinary entry point (`solver_fluid_run_analyzer` hands the defaults), so the
894 * two limits were fixed at 100 and 25 whatever the options said. Zero on
895 * `FluidOptions` means NOT SET, so an explicit struct still wins where the
896 * options are silent.
897 *
898 * THE NEWTON CAP IS NOT A KNOB BUT A DIVERGENCE. `max(50, iter_max)` is what
899 * all three references solve with, and `iter_max` defaults to 200, so leaving
900 * it at 50 here was not a missing option: it was a different solver, one that
901 * refuses a fixed point the reference reaches on its 60th step.
902 */
904 const FluidDaeOptions& dopt) {
905 FluidDaeOptions d = dopt;
906 if (opt.dae_maxstate > 0) d.maxstate = opt.dae_maxstate;
907 if (opt.dae_maxcov > 0) d.maxcov = opt.dae_maxcov;
908 if (opt.iter_max > d.newton_max) d.newton_max = opt.iter_max;
909 return d;
910}
911
912/**
913 * Stations whose variance enters the drift.
914 *
915 * A delay or a source has no min() to close. A station that cannot fill its
916 * servers has min(n,c) = n on its whole support, so closing it is not an
917 * improvement but an error (see FLUID_MOMENT_TERMS). Those are held at zero and
918 * are not unknowns, which keeps the Newton system as small as the closure is.
919 */
920inline std::vector<std::size_t> fluid_dae_closable(const FluidMomentTerms& t) {
921 std::vector<std::size_t> idx;
922 const std::size_t M = t.station_block.size();
923 for (std::size_t i = 0; i < M; ++i) {
924 if (t.is_ext[i] || t.min_exact[i] || t.station_block[i].empty()) continue;
925 if (t.sys.sched[i] == lang::SchedStrategy::INF) continue;
926 if (!std::isfinite(t.S[i])) continue;
927 idx.push_back(i);
928 }
929 return idx;
930}
931
932/** Project a state-level covariance onto the per-station variances the drift reads. */
933inline std::vector<double> fluid_dae_sigma_from(const Matrix<double>& Sigma,
934 const FluidMomentTerms& t,
935 const std::vector<std::size_t>& cidx) {
936 std::vector<double> s2(t.station_block.size(), 0.0);
937 for (std::size_t j = 0; j < cidx.size(); ++j) {
938 const std::vector<std::size_t>& blk = t.station_block[cidx[j]];
939 double acc = 0.0;
940 for (std::size_t a = 0; a < blk.size(); ++a)
941 for (std::size_t b = 0; b < blk.size(); ++b) acc += Sigma(blk[a], blk[b]);
942 s2[cidx[j]] = std::max(0.0, acc);
943 }
944 return s2;
945}
946
947/** Everything the residual needs, gathered so the Newton can stay generic. */
949 const FluidMomentTerms* terms = nullptr;
950 const FluidDaeConservation* cons = nullptr;
951 const FluidDaeConstraints* con = nullptr;
952 const FluidDaeStaging* stg = nullptr;
953 const FluidDaeGates* gates = nullptr;
954 std::vector<std::size_t> cidx; ///< closable stations
955 std::vector<std::size_t> active; ///< binding capacity constraints
956 std::size_t nstate = 0;
957 /**
958 * The tangent space of the caps that CLAMP, or an empty matrix. A cap that
959 * holds the job upstream or loses it fixes its own combination of the state
960 * for as long as it binds, so that combination does not fluctuate: WITHOUT the
961 * projection the Lyapunov solve has no solution at all, because with the
962 * multiplier held constant the drift along the constrained direction is
963 * neutral. A STAGED cap is not clamped -- its room is a state and supplies the
964 * restoring force -- and must not be projected.
965 */
967 /** u = [x; staging; sigma2; mult] */
968 std::size_t nstaging() const { return stg ? stg->n : 0; }
969};
970
971/**
972 * Orthogonal projector onto the subspace the CLAMPING caps leave free:
973 * `I - R'(RR')^-1 R` over the covariance coordinates. Empty when no active cap
974 * clamps, which is every model without a station buffer and every region model.
975 */
977 const std::vector<std::size_t>& active,
978 const FluidMomentTerms& t) {
979 std::vector<std::size_t> rows;
980 for (std::size_t k = 0; k < active.size(); ++k)
981 if (!con.staged[active[k]]) rows.push_back(active[k]);
982 if (rows.empty()) return Matrix<double>(0, 0, 0.0);
983 const std::size_t nc = t.cov_idx.size();
984 Matrix<double> R(rows.size(), nc, 0.0);
985 bool any = false;
986 for (std::size_t i = 0; i < rows.size(); ++i)
987 for (std::size_t j = 0; j < nc; ++j) {
988 R(i, j) = con.A(rows[i], t.cov_idx[j]);
989 if (std::fabs(R(i, j)) > 1e-14) any = true;
990 }
991 if (!any) return Matrix<double>(0, 0, 0.0);
992 // (R R')^-1 by a small Gauss-Jordan: the block is one row per clamping cap
993 const std::size_t m = rows.size();
994 Matrix<double> G(m, 2 * m, 0.0);
995 for (std::size_t a = 0; a < m; ++a) {
996 for (std::size_t b = 0; b < m; ++b) {
997 double acc = 0.0;
998 for (std::size_t j = 0; j < nc; ++j) acc += R(a, j) * R(b, j);
999 G(a, b) = acc;
1000 }
1001 G(a, m + a) = 1.0;
1002 }
1003 for (std::size_t a = 0; a < m; ++a) {
1004 std::size_t piv = a;
1005 for (std::size_t b = a + 1; b < m; ++b)
1006 if (std::fabs(G(b, a)) > std::fabs(G(piv, a))) piv = b;
1007 if (std::fabs(G(piv, a)) < 1e-300) return Matrix<double>(0, 0, 0.0);
1008 if (piv != a)
1009 for (std::size_t j = 0; j < 2 * m; ++j) std::swap(G(a, j), G(piv, j));
1010 const double d = G(a, a);
1011 for (std::size_t j = 0; j < 2 * m; ++j) G(a, j) /= d;
1012 for (std::size_t b = 0; b < m; ++b) {
1013 if (b == a) continue;
1014 const double f = G(b, a);
1015 if (f == 0.0) continue;
1016 for (std::size_t j = 0; j < 2 * m; ++j) G(b, j) -= f * G(a, j);
1017 }
1018 }
1019 Matrix<double> T(nc, nc, 0.0);
1020 for (std::size_t i = 0; i < nc; ++i) T(i, i) = 1.0;
1021 for (std::size_t i = 0; i < nc; ++i)
1022 for (std::size_t j = 0; j < nc; ++j) {
1023 double acc = 0.0;
1024 for (std::size_t a = 0; a < m; ++a)
1025 for (std::size_t b = 0; b < m; ++b) acc += R(a, i) * G(a, m + b) * R(b, j);
1026 T(i, j) -= acc;
1027 }
1028 return T;
1029}
1030
1031/**
1032 * The coupled algebraic system, stacked: drift, conservation, closure consistency.
1033 *
1034 * Returns false when the closure cannot be evaluated at this iterate -- the
1035 * Lyapunov solve throws on a non-hyperbolic fixed point -- so that the line
1036 * search can back off rather than the whole solve failing.
1037 *
1038 * THE UNKNOWNS ARE [x; staging; sigma2; mult], with MULT one multiplier per ACTIVE
1039 * cap: a FRACTION of the admissions allowed for a cap that holds the job upstream
1040 * or loses it, the RATE its waiting room drains at for one that stages it.
1041 */
1042inline bool fluid_dae_residual(const FluidDaeSystem& sysd, const std::vector<double>& u,
1043 std::vector<double>& G, std::vector<double>* rates_out,
1044 bool rethrow = false, std::vector<double>* fire_out = nullptr) {
1045 const FluidMomentTerms& t = *sysd.terms;
1046 const std::size_t n = sysd.nstate, ns = sysd.nstaging();
1047 const std::size_t ncl = sysd.cidx.size(), nact = sysd.active.size();
1048 const std::vector<double> x(u.begin(), u.begin() + n);
1049 const std::vector<double> sg(u.begin() + n, u.begin() + n + ns);
1050
1051 FluidClosure cl;
1052 cl.sigma2.assign(t.station_block.size(), 0.0);
1053 cl.cov.assign(t.station_block.size(), Matrix<double>(0, 0, 0.0));
1054 // Clamped below only. FLUID_DAE_NEWTON projects the iterate into the
1055 // feasible box, so this is a guard rather than the mechanism: clamping alone
1056 // would flatten the Jacobian at the boundary and pin the unknown there.
1057 for (std::size_t j = 0; j < ncl; ++j)
1058 cl.sigma2[sysd.cidx[j]] = std::max(0.0, u[n + ns + j]);
1059 std::vector<double> mult(nact, 0.0);
1060 for (std::size_t k = 0; k < nact; ++k) mult[k] = std::max(0.0, u[n + ns + ncl + k]);
1061
1062 std::vector<double> r;
1063 FluidDaeLegs lg;
1064 Matrix<double> Sigma(0, 0, 0.0);
1065 try {
1066 r = fluid_moment_rates(t, x, cl);
1067 lg = fluid_dae_legs(t, *sysd.gates, *sysd.stg, *sysd.con, sysd.active, sg, r, mult, false);
1068 const Matrix<double> A = fluid_drift_jacobian(t, x, cl);
1069 // THE FIRING RATE, not the nominal one: a cap that holds the job upstream
1070 // suppresses the event itself, so the diffusion counts what happens.
1071 const Matrix<double>* cT = sysd.clampT.rows() ? &sysd.clampT : nullptr;
1072 Sigma = fluid_moment_lyapunov(t, A, lg.rup, cT);
1073 } catch (const std::exception&) {
1074 // The line search reads `false` as "back off", but the FIRST evaluation
1075 // has nowhere to back off to: there the underlying reason -- a
1076 // non-hyperbolic fixed point, or LAPACK missing under the Lyapunov
1077 // solve -- is the answer and must not be replaced by a generic one.
1078 if (rethrow) throw;
1079 return false;
1080 }
1081 const std::vector<double> s2new = fluid_dae_sigma_from(Sigma, t, sysd.cidx);
1082
1083 const std::size_t nev = r.size();
1084 std::vector<double> drift(n, 0.0);
1085 for (std::size_t s = 0; s < n; ++s) {
1086 double acc = 0.0;
1087 for (std::size_t e = 0; e < nev; ++e) {
1088 if (!sysd.gates->has) acc += t.D(s, e) * lg.rup[e];
1089 else
1090 // THE THREE LEGS: the removal leaves at the rate the event FIRES and
1091 // the arrival lands at the rate mass actually ARRIVES. A LOST arrival
1092 // is returned to the source pool rather than destroyed -- the EXT
1093 // coordinate is a normalisation and its row a real equation -- while
1094 // a job lost leaving a REAL station is destroyed.
1095 acc += sysd.gates->Dn(s, e) * lg.rup[e] + sysd.gates->DnExt(s, e) * lg.rin[e] +
1096 sysd.gates->Dp(s, e) * lg.rin[e];
1097 }
1098 drift[s] = acc;
1099 }
1100
1101 G.clear();
1102 G.insert(G.end(), drift.begin(), drift.end());
1103 G.insert(G.end(), lg.ds.begin(), lg.ds.end());
1104 const Matrix<double>& C = sysd.cons->C;
1105 for (std::size_t c = 0; c < C.rows(); ++c) {
1106 double acc = 0.0;
1107 for (std::size_t s = 0; s < n; ++s) acc += C(c, s) * x[s];
1108 for (std::size_t j = 0; j < ns; ++j) acc += C(c, n + j) * sg[j];
1109 G.push_back(acc - sysd.cons->N[c]);
1110 }
1111 for (std::size_t j = 0; j < ncl; ++j)
1112 G.push_back(cl.sigma2[sysd.cidx[j]] - s2new[sysd.cidx[j]]);
1113 // THE UNDIFFERENTIATED CONSTRAINT, on purpose: at a fixed point every flow
1114 // already balances, so d/dt(Ax)=0 is satisfied by anything and pins no
1115 // multiplier. A x = b does pin it, through the fixed point's dependence on it.
1116 for (std::size_t k = 0; k < nact; ++k) {
1117 double acc = 0.0;
1118 for (std::size_t s = 0; s < n; ++s) acc += sysd.con->A(sysd.active[k], s) * x[s];
1119 for (std::size_t j = 0; j < ns; ++j) acc += sysd.con->As(sysd.active[k], j) * sg[j];
1120 G.push_back(acc - sysd.con->b[sysd.active[k]]);
1121 }
1122
1123 // RATES_OUT is the flow that actually CROSSES each event, which is what the
1124 // throughput table reports; FIRE_OUT the rate the event fires at.
1125 if (rates_out) *rates_out = lg.rin;
1126 if (fire_out) *fire_out = lg.rup;
1127 return true;
1128}
1129
1130/** Onto the feasible box: the state is free, the variances are not. */
1131inline void fluid_dae_project(std::vector<double>& u, std::size_t nfree) {
1132 for (std::size_t i = nfree; i < u.size(); ++i) u[i] = std::max(0.0, u[i]);
1133}
1134
1135/** What the Newton reports about where it stopped. */
1137 std::size_t iters = 0;
1138 double residual = std::numeric_limits<double>::infinity();
1139 bool converged = false;
1140};
1141
1142/**
1143 * Damped PROJECTED Newton with a finite-difference Jacobian and an Armijo
1144 * backtrack on the residual norm.
1145 *
1146 * The step is solved in least squares: the drift block is rank deficient by
1147 * exactly the number of conserved chains, and the constraint rows restore that
1148 * rank, so the stacked system is consistent and overdetermined rather than
1149 * square.
1150 *
1151 * WHY PROJECTED, AND NOT MERELY CLAMPED. The unknowns past NFREE are variances,
1152 * which may not go negative, and the residual reads them through max(0,.).
1153 * Clamping inside the residual alone is a trap: once an iterate goes negative
1154 * the residual stops depending on it, so the finite-difference column is exactly
1155 * zero, there is no derivative to climb back on, and the unknown is pinned at
1156 * the boundary for good. Projecting the ITERATE keeps every evaluation inside
1157 * the box, where the forward difference across max(0,.) is live even at zero.
1158 */
1159inline FluidDaeNewtonInfo fluid_dae_newton(const FluidDaeSystem& sysd, std::vector<double>& u,
1160 double tol, std::size_t maxit) {
1161 FluidDaeNewtonInfo info;
1162 const std::size_t nfree = sysd.nstate;
1163 fluid_dae_project(u, nfree);
1164 std::vector<double> G;
1165 fluid_dae_residual(sysd, u, G, nullptr, /*rethrow=*/true);
1166 double resnorm = 0.0;
1167 for (double g : G) resnorm = std::max(resnorm, std::fabs(g));
1168
1169 const std::size_t nun = u.size(), m = G.size();
1170 while (resnorm >= tol && info.iters < maxit) {
1171 ++info.iters;
1172 Matrix<double> J(m, nun, 0.0);
1173 std::vector<double> Gp;
1174 for (std::size_t j = 0; j < nun; ++j) {
1175 const double h = std::max(1e-7 * std::fabs(u[j]), 1e-9);
1176 std::vector<double> up = u;
1177 up[j] += h;
1178 if (!fluid_dae_residual(sysd, up, Gp, nullptr)) continue; // column left at zero
1179 for (std::size_t i = 0; i < m; ++i) J(i, j) = (Gp[i] - G[i]) / h;
1180 }
1181 std::vector<double> du;
1182 try {
1183 du = lstsq(J, G).x;
1184 } catch (const std::exception&) {
1185 break;
1186 }
1187 for (double& d : du) d = -d;
1188
1189 double lam = 1.0;
1190 bool stepped = false;
1191 for (int ls = 0; ls < 25; ++ls) {
1192 std::vector<double> un(nun);
1193 for (std::size_t j = 0; j < nun; ++j) un[j] = u[j] + lam * du[j];
1194 fluid_dae_project(un, nfree); // project BEFORE evaluating, so the
1195 std::vector<double> Gn; // accepted point and its residual agree
1196 if (fluid_dae_residual(sysd, un, Gn, nullptr)) {
1197 double rn = 0.0;
1198 bool ok = true;
1199 for (double g : Gn) {
1200 if (!std::isfinite(g)) { ok = false; break; }
1201 rn = std::max(rn, std::fabs(g));
1202 }
1203 if (ok && rn < resnorm * (1.0 - 1e-4 * lam)) {
1204 u = un; G = Gn; resnorm = rn;
1205 stepped = true;
1206 break;
1207 }
1208 }
1209 lam *= 0.5;
1210 }
1211 if (!stepped) break; // no descent along this direction
1212 }
1213 info.residual = resnorm;
1214 info.converged = resnorm < tol;
1215 return info;
1216}
1217
1218
1219namespace detail {
1220
1221/**
1222 * The transient DAE, as RODAS sees it.
1223 *
1224 * RODAS's callbacks are plain C function pointers with no user-data channel,
1225 * so the system under integration is reached through this pointer. That is
1226 * consistent with the vendored solver itself, whose upstream COMMON blocks make
1227 * it single-integration-at-a-time regardless.
1228 */
1229struct FluidDaeTransient {
1230 const FluidMomentTerms* terms = nullptr;
1231 const FluidDaeConservation* cons = nullptr;
1232 FluidClosure closure; ///< the stationary variance, when held
1233 std::vector<std::size_t> alg_row; ///< one differential row per chain, replaced
1234 std::size_t nstate = 0;
1235 /**
1236 * THE COVARIANCE, INTEGRATED RATHER THAN HELD. `withcov` selects between the
1237 * two: below `maxcov` the nc x nc block on `cov_idx` joins the state as
1238 * ordinary DIFFERENTIAL rows (mass 1, unlike the one algebraic row per
1239 * chain) and advances by dS = A S + S A' + D diag(r) D', so the drift is
1240 * read at the variance the trajectory actually HAS at each instant. Above
1241 * it the block is dropped and `closure` supplies the stationary variance
1242 * instead -- which is what `minnormal` uses for the whole of its transient,
1243 * so the fallback is the reference's own and not a different closure.
1244 */
1245 bool withcov = false;
1246 std::vector<std::size_t> cov_idx;
1247 std::size_t nc = 0;
1248 std::vector<std::size_t> closable; ///< stations whose variance enters the drift
1249 /// The output grid, and the states RODAS's dense output was read at. The
1250 /// cursor is the next grid point still owed a value; it only advances, so a
1251 /// step that spans several grid points fills them all in one call.
1252 std::vector<double> grid;
1253 std::size_t cursor = 0;
1254 std::vector<std::vector<double> > out;
1255
1256 /**
1257 * THE CAPS, AND THE MODE THIS SEGMENT IS IN. Under a cap the transient is a
1258 * HYBRID DAE: the system switches every time a cap starts or stops binding, so
1259 * the horizon is covered by SEGMENTS, each one index-1 with a fixed binding set,
1260 * ending at a located crossing. The multiplier of each cap is an ALGEBRAIC
1261 * unknown carried in the state with a zero mass row, and its equation is the
1262 * DIFFERENTIATED constraint d/dt(A x + As s) = 0 -- the undifferentiated form is
1263 * index 2, which RODAS does not solve. A staged cap's unknown is the admitted
1264 * FLOW, not the drain rate the steady state solves for: at the instant a region
1265 * fills its room is EMPTY, and a rate times zero mass cannot hold the cap.
1266 */
1267 const FluidDaeConstraints* con = nullptr;
1268 const FluidDaeGates* gates = nullptr;
1269 const FluidDaeStaging* stg = nullptr;
1270 std::vector<std::size_t> active;
1271 std::vector<char> armed; ///< a staged cap whose room has really filled
1272 std::vector<char> gated_rooms;
1273 std::vector<double> inert; ///< one for a fraction, zero for a flow
1274 std::size_t ncon = 0, nstg = 0, o_sg = 0, o_m = 0, o_cov = 0;
1275 /// the event functions at the last accepted step, and the crossing located
1276 std::vector<double> gprev;
1277 int hit = -1;
1278 double hit_t = 0.0;
1279 std::vector<double> hit_z;
1280};
1281
1282/**
1283 * The event functions, all of them: a cap that is NOT active is watched for
1284 * REACHING its bound, one that IS active for stopping to bind -- a fraction above
1285 * one, or a room that has emptied.
1286 *
1287 * ARMED IS A LATCH, and it has to be: a staged cap activates with an EMPTY room, so
1288 * "the room emptied" is true at the instant it starts binding and the release would
1289 * fire immediately. The latch only ever goes false to true.
1290 */
1291inline std::vector<double> fluid_dae_cap_events(const FluidDaeTransient& S,
1292 const double* z) {
1293 std::vector<double> g(S.ncon, 1.0);
1294 for (std::size_t c = 0; c < S.ncon; ++c) {
1295 const bool is_active =
1296 std::find(S.active.begin(), S.active.end(), c) != S.active.end();
1297 if (is_active) {
1298 if (S.con->staged[c]) {
1299 double mass = 0.0;
1300 for (std::size_t j = 0; j < S.nstg; ++j)
1301 if (S.stg->gated_by[c][j]) mass += z[S.o_sg + j];
1302 g[c] = S.armed[c] ? mass : 1.0;
1303 } else {
1304 g[c] = 1.0 - z[S.o_m + c];
1305 }
1306 } else {
1307 double val = 0.0;
1308 for (std::size_t s = 0; s < S.nstate; ++s) val += S.con->A(c, s) * z[s];
1309 for (std::size_t j = 0; j < S.nstg; ++j) val += S.con->As(c, j) * z[S.o_sg + j];
1310 g[c] = S.con->b[c] - val;
1311 }
1312 }
1313 return g;
1314}
1315inline FluidDaeTransient*& fluid_dae_active() {
1316 static FluidDaeTransient* p = nullptr;
1317 return p;
1318}
1319
1320/** The closure to read the drift at: the trajectory's own variance, or the held one. */
1321inline FluidClosure fluid_dae_closure_at(const FluidDaeTransient& S,
1322 const rodas_impl::doublereal* y) {
1323 if (!S.withcov) return S.closure;
1324 FluidClosure cl;
1325 cl.sigma2.assign(S.terms->station_block.size(), 0.0);
1326 cl.cov.assign(S.terms->station_block.size(), Matrix<double>(0, 0, 0.0));
1327 Matrix<double> Sigma(S.terms->nstate, S.terms->nstate, 0.0);
1328 for (std::size_t a = 0; a < S.nc; ++a)
1329 for (std::size_t b = 0; b < S.nc; ++b)
1330 // the equation preserves symmetry; rounding does not
1331 Sigma(S.cov_idx[a], S.cov_idx[b]) =
1332 0.5 * (y[S.o_cov + a * S.nc + b] + y[S.o_cov + b * S.nc + a]);
1333 const std::vector<double> s2 = fluid_dae_sigma_from(Sigma, *S.terms, S.closable);
1334 for (std::size_t i = 0; i < s2.size(); ++i) cl.sigma2[i] = s2[i];
1335 return cl;
1336}
1337
1338/** M y' = f: the drift, with the algebraic rows carrying the constraint residual. */
1339inline int fluid_dae_fcn(rodas_impl::integer*, rodas_impl::doublereal*,
1340 rodas_impl::doublereal* y, rodas_impl::doublereal* f,
1341 rodas_impl::doublereal*, rodas_impl::integer*) {
1342 FluidDaeTransient& S = *fluid_dae_active();
1343 const std::vector<double> x(y, y + S.nstate);
1344 const FluidClosure cl = fluid_dae_closure_at(S, y);
1345 std::vector<double> dx;
1346 FluidDaeLegs lg;
1347 if (S.ncon == 0) {
1348 dx = fluid_moment_drift(*S.terms, x, cl);
1349 } else {
1350 const std::vector<double> sg(y + S.o_sg, y + S.o_sg + S.nstg);
1351 std::vector<double> mact(S.active.size(), 0.0);
1352 for (std::size_t k = 0; k < S.active.size(); ++k) mact[k] = y[S.o_m + S.active[k]];
1353 const std::vector<double> r = fluid_moment_rates(*S.terms, x, cl);
1354 lg = fluid_dae_legs(*S.terms, *S.gates, *S.stg, *S.con, S.active, sg, r, mact, true);
1355 dx.assign(S.nstate, 0.0);
1356 for (std::size_t s = 0; s < S.nstate; ++s) {
1357 double acc = 0.0;
1358 for (std::size_t e = 0; e < r.size(); ++e)
1359 acc += S.gates->Dn(s, e) * lg.rup[e] + S.gates->DnExt(s, e) * lg.rin[e] +
1360 S.gates->Dp(s, e) * lg.rin[e];
1361 dx[s] = acc;
1362 }
1363 }
1364 for (std::size_t i = 0; i < S.nstate; ++i) f[i] = dx[i];
1365 const Matrix<double>& C = S.cons->C;
1366 for (std::size_t c = 0; c < C.rows(); ++c) {
1367 double acc = 0.0;
1368 for (std::size_t s = 0; s < S.nstate; ++s) acc += C(c, s) * x[s];
1369 for (std::size_t j = 0; j < S.nstg; ++j) acc += C(c, S.nstate + j) * y[S.o_sg + j];
1370 f[S.alg_row[c]] = acc - S.cons->N[c]; // the mass matrix zeroed this row
1371 }
1372 if (S.ncon) {
1373 for (std::size_t j = 0; j < S.nstg; ++j)
1374 f[S.o_sg + j] = S.gated_rooms[j] ? lg.ds[j] : y[S.o_sg + j];
1375 for (std::size_t c = 0; c < S.ncon; ++c) {
1376 const bool is_active =
1377 std::find(S.active.begin(), S.active.end(), c) != S.active.end();
1378 if (!is_active) {
1379 f[S.o_m + c] = y[S.o_m + c] - S.inert[c];
1380 continue;
1381 }
1382 double acc = 0.0;
1383 for (std::size_t s = 0; s < S.nstate; ++s) acc += S.con->A(c, s) * dx[s];
1384 for (std::size_t j = 0; j < S.nstg; ++j) acc += S.con->As(c, j) * lg.ds[j];
1385 f[S.o_m + c] = acc;
1386 }
1387 }
1388 if (S.withcov) {
1389 // dS = A S + S A' + D diag(r) D', on the coordinates that carry a real
1390 // population. ORDINARY DIFFERENTIAL ROWS: only the algebraic rows above are
1391 // zeroed by the mass matrix, so these stay at 1.
1392 const Matrix<double> A = fluid_drift_jacobian(*S.terms, x, cl);
1393 const std::vector<double> r =
1394 S.ncon ? lg.rup : fluid_moment_rates(*S.terms, x, cl);
1395 for (std::size_t a = 0; a < S.nc; ++a)
1396 for (std::size_t b = 0; b < S.nc; ++b) {
1397 double acc = 0.0;
1398 for (std::size_t l = 0; l < S.nc; ++l)
1399 acc += A(S.cov_idx[a], S.cov_idx[l]) * y[S.o_cov + l * S.nc + b]
1400 + y[S.o_cov + a * S.nc + l] * A(S.cov_idx[b], S.cov_idx[l]);
1401 for (std::size_t e = 0; e < r.size(); ++e)
1402 acc += S.terms->D(S.cov_idx[a], e) * r[e] * S.terms->D(S.cov_idx[b], e);
1403 f[S.o_cov + a * S.nc + b] = acc;
1404 }
1405 }
1406 return 0;
1407}
1408
1409/** Analytic Jacobian: the drift's, with the algebraic rows replaced by C. */
1410inline int fluid_dae_jac(rodas_impl::integer*, rodas_impl::doublereal*,
1411 rodas_impl::doublereal* y, rodas_impl::doublereal* dfy,
1412 rodas_impl::integer* ldfy, rodas_impl::doublereal*,
1413 rodas_impl::integer*) {
1414 FluidDaeTransient& S = *fluid_dae_active();
1415 const std::vector<double> x(y, y + S.nstate);
1416 const Matrix<double> A = fluid_drift_jacobian(*S.terms, x, S.closure);
1417 const int ld = *ldfy;
1418 for (std::size_t i = 0; i < S.nstate; ++i)
1419 for (std::size_t j = 0; j < S.nstate; ++j) dfy[i + j * ld] = A(i, j);
1420 const Matrix<double>& C = S.cons->C;
1421 for (std::size_t c = 0; c < C.rows(); ++c)
1422 for (std::size_t j = 0; j < S.nstate; ++j) dfy[S.alg_row[c] + j * ld] = C(c, j);
1423 return 0;
1424}
1425
1426/** The singular mass matrix, banded with MLMAS=MUMAS=0, i.e. the diagonal. */
1427inline int fluid_dae_mas(rodas_impl::integer* n, rodas_impl::doublereal* am,
1428 rodas_impl::integer* lmas, rodas_impl::doublereal*,
1429 rodas_impl::integer*) {
1430 FluidDaeTransient& S = *fluid_dae_active();
1431 const int ld = *lmas;
1432 for (int j = 0; j < *n; ++j) am[0 + j * ld] = 1.0;
1433 for (std::size_t c = 0; c < S.alg_row.size(); ++c) am[0 + S.alg_row[c] * ld] = 0.0;
1434 // a room with no cap above it is held EMPTY by an algebraic row, and every
1435 // multiplier is algebraic: its equation is the differentiated constraint
1436 for (std::size_t j = 0; j < S.nstg; ++j)
1437 if (!S.gated_rooms[j]) am[0 + (S.o_sg + j) * ld] = 0.0;
1438 for (std::size_t c = 0; c < S.ncon; ++c) am[0 + (S.o_m + c) * ld] = 0.0;
1439 return 0;
1440}
1441
1442/**
1443 * The trajectory, read off RODAS's own dense output rather than off its steps.
1444 *
1445 * A Rosenbrock method chooses its step from the local error, so its accepted
1446 * points are wherever the stiffness put them and never the ones a caller asked
1447 * for. `contro_` is the third-order interpolant RODAS carries for exactly this,
1448 * valid over the step just accepted, so asking for an arbitrary grid costs no
1449 * extra step and no interpolation of the caller's own.
1450 *
1451 * THE FIRST CALL IS MADE BEFORE ANY STEP (`rodas.f` calls SOLOUT once with
1452 * XOLD = X = t0 and NACCPT = 0), so `cont` holds no coefficients yet and the
1453 * state at that point is `y` itself. Reading `contro_` there would interpolate
1454 * uninitialised work space.
1455 */
1456inline int fluid_dae_solout(rodas_impl::integer* nr, rodas_impl::doublereal* xold,
1457 rodas_impl::doublereal* x, rodas_impl::doublereal* y,
1458 rodas_impl::doublereal* cont, rodas_impl::integer* lrc,
1459 rodas_impl::integer*, rodas_impl::doublereal*,
1460 rodas_impl::integer*, rodas_impl::integer* irtrn) {
1461 FluidDaeTransient& S = *fluid_dae_active();
1462 const std::size_t nz = S.o_cov + (S.withcov ? S.nc * S.nc : 0);
1463 double stop_at = std::numeric_limits<double>::infinity();
1464 if (S.ncon && *nr > 1) {
1465 // arm a staged release only once its room has really filled
1466 for (std::size_t k = 0; k < S.active.size(); ++k) {
1467 const std::size_t c = S.active[k];
1468 if (!S.con->staged[c] || S.armed[c]) continue;
1469 double mass = 0.0;
1470 for (std::size_t j = 0; j < S.nstg; ++j)
1471 if (S.stg->gated_by[c][j]) mass += y[S.o_sg + j];
1472 if (mass > 1e-8) S.armed[c] = 1;
1473 }
1474 const std::vector<double> gcur = fluid_dae_cap_events(S, y);
1475 int cross = -1;
1476 for (std::size_t c = 0; c < S.ncon; ++c)
1477 if (S.gprev.size() == S.ncon && S.gprev[c] > 0.0 && gcur[c] <= 0.0) {
1478 cross = static_cast<int>(c);
1479 break;
1480 }
1481 S.gprev = gcur;
1482 if (cross >= 0) {
1483 // BISECT ON THE INTERPOLANT rather than on the integration: RODAS carries
1484 // a third-order one over the step it has just accepted, so locating the
1485 // crossing costs no extra step and the state at it is the integrator's.
1486 double lo = *xold;
1487 double hi = *x;
1488 std::vector<double> ymid(nz, 0.0);
1489 for (int bit = 0; bit < 60; ++bit) {
1490 const double mid = 0.5 * (lo + hi);
1491 for (std::size_t i = 0; i < nz; ++i) {
1492 rodas_impl::integer ii = static_cast<rodas_impl::integer>(i + 1);
1493 double tq = mid;
1494 ymid[i] = rodas_impl::contro_(&ii, &tq, cont, lrc);
1495 }
1496 const std::vector<double> gmid = fluid_dae_cap_events(S, ymid.data());
1497 if (gmid[static_cast<std::size_t>(cross)] > 0.0) lo = mid;
1498 else hi = mid;
1499 if (hi - lo <= 1e-12 * std::max(1.0, std::fabs(hi))) break;
1500 }
1501 std::vector<double> yhit(nz, 0.0);
1502 for (std::size_t i = 0; i < nz; ++i) {
1503 rodas_impl::integer ii = static_cast<rodas_impl::integer>(i + 1);
1504 double tq = hi;
1505 yhit[i] = rodas_impl::contro_(&ii, &tq, cont, lrc);
1506 }
1507 S.hit = cross;
1508 S.hit_t = hi;
1509 S.hit_z = yhit;
1510 stop_at = hi;
1511 }
1512 } else if (S.ncon) {
1513 S.gprev = fluid_dae_cap_events(S, y);
1514 }
1515 // THE OVERSHOOTING STEP IS NOT REPORTED: grid points beyond the located
1516 // crossing belong to the NEXT segment, which starts from it.
1517 while (S.cursor < S.grid.size() && S.grid[S.cursor] <= *x + 1e-13 &&
1518 S.grid[S.cursor] <= stop_at + 1e-13) {
1519 double tq = S.grid[S.cursor];
1520 std::vector<double> xs(nz, 0.0);
1521 for (std::size_t i = 0; i < nz; ++i) {
1522 if (*nr <= 1) {
1523 xs[i] = y[i];
1524 } else {
1525 rodas_impl::integer ii = static_cast<rodas_impl::integer>(i + 1);
1526 xs[i] = rodas_impl::contro_(&ii, &tq, cont, lrc);
1527 }
1528 }
1529 S.out.push_back(xs);
1530 ++S.cursor;
1531 }
1532 // RODAS READS THE STOP THROUGH IRTRN, not through the return value: rodas.f's
1533 // SOLOUT is a subroutine and the f2c translation keeps that convention, so a
1534 // located crossing has to be written into the argument or the integration walks
1535 // straight past the cap it just found.
1536 if (S.hit >= 0 && irtrn) *irtrn = -1;
1537 return 0;
1538}
1539inline int fluid_dae_dfx(rodas_impl::integer*, rodas_impl::doublereal*,
1540 rodas_impl::doublereal*, rodas_impl::doublereal*,
1541 rodas_impl::doublereal*, rodas_impl::integer*) { return 0; }
1542
1543} // namespace detail
1544
1545/**
1546 * Lift a (station, class) covariance onto the closing-state phase layout: the
1547 * inverse of the aggregation `FluidTranPoint::QCov` performs. The lift is
1548 * MULTINOMIAL,
1549 *
1550 * Sigma0(b,b) = C(ir,ir) * pie_b pie_b' + m_ir * (diag(pie_b) - pie_b pie_b')
1551 * Sigma0(b,d) = C(ir,js) * pie_b pie_d' (b != d)
1552 *
1553 * with `m_ir` the mass x0 holds in block (i, r) and `pie` its phase split, so the
1554 * second moment starts from the same mean AND the same phase proportions as the
1555 * drift. The second term of the diagonal block is the variance of splitting a
1556 * known total over the phases: given m_ir jobs present their phases are iid pie,
1557 * so the within-block covariance is m_ir*(diag(pie) - pie pie'). Dropping it
1558 * asserts that every job's phase is known once the total is, and understates the
1559 * variance of every per-phase count.
1560 *
1561 * @param C0sc the entry covariance over station-class pairs, indexed r*M + i
1562 * @param x0 the initial state in the closing layout
1563 * @param terms the moment terms carrying the phase blocks
1564 * @return the lifted covariance, nstate-by-nstate
1565 */
1567 const std::vector<double>& x0,
1568 const FluidMomentTerms& terms) {
1569 const std::size_t M = terms.class_block.size();
1570 const std::size_t K = M ? terms.class_block[0].size() : 0;
1571 Matrix<double> Sigma0(terms.nstate, terms.nstate, 0.0);
1572 std::vector<std::vector<double> > pie(M * K);
1573 std::vector<double> mval(M * K, 0.0);
1574 for (std::size_t i = 0; i < M; ++i)
1575 for (std::size_t r = 0; r < K; ++r) {
1576 const std::vector<std::size_t>& blk = terms.class_block[i][r];
1577 if (blk.empty()) continue;
1578 double m = 0.0;
1579 for (std::size_t b = 0; b < blk.size(); ++b) m += x0[blk[b]];
1580 mval[r * M + i] = std::max(m, 0.0);
1581 std::vector<double> p(blk.size(), 0.0);
1582 if (m > 0.0)
1583 for (std::size_t b = 0; b < blk.size(); ++b) p[b] = x0[blk[b]] / m;
1584 // NO MASS MEANS NO VARIANCE TO PLACE: a nonnegative count with mean
1585 // zero is zero on every path, so the block stays empty whatever the
1586 // carried covariance claims about it, and p is left at zero.
1587 pie[r * M + i] = p;
1588 }
1589 for (std::size_t i = 0; i < M; ++i)
1590 for (std::size_t r = 0; r < K; ++r) {
1591 const std::vector<std::size_t>& bi = terms.class_block[i][r];
1592 if (bi.empty()) continue;
1593 const std::size_t irb = r * M + i;
1594 const std::vector<double>& pb = pie[irb];
1595 for (std::size_t j = 0; j < M; ++j)
1596 for (std::size_t sc = 0; sc < K; ++sc) {
1597 const std::vector<std::size_t>& bj = terms.class_block[j][sc];
1598 if (bj.empty()) continue;
1599 const std::size_t ird = sc * M + j;
1600 const std::vector<double>& pd = pie[ird];
1601 const double c = C0sc(irb, ird);
1602 for (std::size_t a = 0; a < bi.size(); ++a)
1603 for (std::size_t b = 0; b < bj.size(); ++b)
1604 Sigma0(bi[a], bj[b]) = c * pb[a] * pd[b];
1605 }
1606 for (std::size_t a = 0; a < bi.size(); ++a)
1607 for (std::size_t b = 0; b < bi.size(); ++b)
1608 Sigma0(bi[a], bi[b]) +=
1609 mval[irb] * ((a == b ? pb[a] : 0.0) - pb[a] * pb[b]);
1610 }
1611 for (std::size_t a = 0; a < terms.nstate; ++a)
1612 for (std::size_t b = a + 1; b < terms.nstate; ++b) {
1613 const double v = 0.5 * (Sigma0(a, b) + Sigma0(b, a));
1614 Sigma0(a, b) = v;
1615 Sigma0(b, a) = v;
1616 }
1617 return Sigma0;
1618}
1619
1620/**
1621 * Integrate the closure as an index-1 DAE, with RODAS, and report the state at
1622 * every point of `grid`.
1623 *
1624 * One differential equation per closed chain is redundant -- the rows of D sum
1625 * to zero there -- so one is REPLACED by the constraint rather than added to it.
1626 * The row dropped is the one carrying the most mass at t=0, which keeps the
1627 * algebraic equation away from a coordinate that is identically zero. Chains
1628 * partition the classes, so no index is claimed twice.
1629 *
1630 * `grid` must be increasing and end at the horizon; the trajectory comes from
1631 * RODAS's own dense output (`detail::fluid_dae_solout`), so the grid costs no
1632 * extra step. The LAST entry is also the return value of the integration
1633 * proper, which is what a steady-state caller asking for a single point wants.
1634 *
1635 * @return one state vector per grid point, in grid order
1636 */
1637inline std::vector<std::vector<double> > fluid_dae_integrate(
1638 const FluidMomentTerms& terms, const FluidDaeConservation& cons,
1639 const FluidClosure& closure, const std::vector<double>& x0,
1640 const std::vector<double>& grid, double tol, bool withcov = false,
1641 const std::vector<std::size_t>& closable = std::vector<std::size_t>(),
1642 const Matrix<double>& init_sigma = Matrix<double>(0, 0, 0.0)) {
1643 using namespace rodas_impl;
1644 const std::size_t n = terms.nstate;
1645
1646 detail::FluidDaeTransient S;
1647 S.terms = &terms;
1648 S.cons = &cons;
1649 S.closure = closure;
1650 S.nstate = n;
1651 S.withcov = withcov;
1652 S.cov_idx = terms.cov_idx;
1653 S.nc = terms.cov_idx.size();
1654 S.closable = closable;
1655 if (S.nc == 0) S.withcov = false;
1656 S.o_sg = n;
1657 S.o_m = n;
1658 S.o_cov = n;
1659 const std::size_t nz = n + (S.withcov ? S.nc * S.nc : 0);
1660 for (std::size_t c = 0; c < cons.C.rows(); ++c) {
1661 std::size_t best = n;
1662 double bestv = -1.0;
1663 for (std::size_t s = 0; s < n; ++s) {
1664 if (cons.C(c, s) == 0.0) continue;
1665 bool taken = false;
1666 for (std::size_t d = 0; d < S.alg_row.size(); ++d)
1667 if (S.alg_row[d] == s) taken = true;
1668 if (taken) continue;
1669 if (x0[s] > bestv) { bestv = x0[s]; best = s; }
1670 }
1671 if (best == n) throw NumericError("fluid_dae_integrate: a chain has no free coordinate");
1672 S.alg_row.push_back(best);
1673 }
1674 if (grid.empty()) throw InputError("fluid_dae_integrate: the output grid is empty");
1675 for (std::size_t j = 1; j < grid.size(); ++j)
1676 if (!(grid[j] > grid[j - 1]))
1677 throw InputError("fluid_dae_integrate: the output grid must be increasing");
1678 S.grid = grid;
1679 detail::fluid_dae_active() = &S;
1680
1681 // LWORK per the documented formula N*(LJAC+LMAS+LE1+14)+20 with a full
1682 // Jacobian (LJAC=LE1=N) and a diagonal mass matrix (LMAS=1). The 14 already
1683 // covers CONT's 4*N, so dense output needs no extra room.
1684 const integer N = static_cast<integer>(nz);
1685 const std::size_t lwork = nz * (nz + 1 + nz + 14) + 20 + 32;
1686 const std::size_t liwork = nz + 20 + 32;
1687 std::vector<doublereal> y(nz, 0.0), work(lwork, 0.0), rpar(1, 0.0);
1688 for (std::size_t i = 0; i < n && i < x0.size(); ++i) y[i] = x0[i];
1689 // Sigma(0) = 0 is the COLD-START initialisation, and for a cold start it is
1690 // also the physically right one: the population at t=0 is then a known
1691 // deterministic state, so it has no variance. C x0 = N holds by construction,
1692 // so the algebraic rows are satisfied at t=0 and RODAS needs no separate
1693 // consistency solve. A STAGE ENTERED FROM A RANDOM PREDECESSOR IS NOT SUCH A
1694 // STATE: `init_sigma` carries the entry covariance it actually arrived with,
1695 // already lifted onto this layout by `fluid_dae_lift_qcov`.
1696 if (S.withcov && init_sigma.rows() == n && init_sigma.cols() == n)
1697 for (std::size_t a = 0; a < S.nc; ++a)
1698 for (std::size_t b = 0; b < S.nc; ++b)
1699 y[S.o_cov + a * S.nc + b] = init_sigma(S.cov_idx[a], S.cov_idx[b]);
1700 std::vector<integer> iwork(liwork, 0), ipar(1, 0);
1701 doublereal rtol = tol, atol = tol * 1e-2, x = 0.0, xend = grid.back(), h = 1e-6;
1702 // THE ANALYTIC JACOBIAN IS THE DRIFT'S, so it is only the whole Jacobian
1703 // while the covariance is HELD. Once the nc^2 covariance rows join the
1704 // state they have derivatives of their own, and supplying a Jacobian that
1705 // is right on one block and zero on the other is worse than supplying none:
1706 // RODAS would take it as exact. So the covariance run differences it
1707 // numerically, which is also what ode15s does for the same rows in the
1708 // MATLAB reference.
1709 integer itol = 0, ifcn = 0, ijac = S.withcov ? 0 : 1, mljac = N, mujac = N, idfx = 0;
1710 integer imas = 1, mlmas = 0, mumas = 0, iout = 1, idid = 0;
1711 integer lw = static_cast<integer>(lwork), liw = static_cast<integer>(liwork);
1712
1713 try {
1714 rodas_(const_cast<integer*>(&N), (U_fp)detail::fluid_dae_fcn, &ifcn, &x, y.data(),
1715 &xend, &h, &rtol, &atol, &itol,
1716 (U_fp)detail::fluid_dae_jac, &ijac, &mljac, &mujac,
1717 (U_fp)detail::fluid_dae_dfx, &idfx,
1718 (U_fp)detail::fluid_dae_mas, &imas, &mlmas, &mumas,
1719 (U_fp)detail::fluid_dae_solout, &iout,
1720 work.data(), &lw, iwork.data(), &liw, rpar.data(), ipar.data(), &idid);
1721 } catch (...) {
1722 // The active pointer is a global, so an exception thrown out of a
1723 // callback must not leave it dangling for the next integration.
1724 detail::fluid_dae_active() = nullptr;
1725 throw;
1726 }
1727 detail::fluid_dae_active() = nullptr;
1728
1729 if (idid != 1)
1730 throw NumericError("fluid_dae_integrate: RODAS returned idid=" + std::to_string(idid));
1731 // The horizon itself is filled from `y` rather than from the interpolant:
1732 // RODAS lands exactly on XEND, and the last accepted step's dense output is
1733 // evaluated at its own right endpoint, where the two agree to rounding.
1734 if (S.out.size() + 1 == grid.size()) S.out.push_back(std::vector<double>(y.begin(), y.end()));
1735 if (S.out.size() != grid.size())
1736 throw NumericError("fluid_dae_integrate: RODAS reported " + std::to_string(S.out.size()) +
1737 " of the " + std::to_string(grid.size()) + " requested output points");
1738 S.out.back().assign(y.begin(), y.end());
1739 return S.out;
1740}
1741
1742/**
1743 * The multipliers that hold the active caps at this state, by small Newton.
1744 *
1745 * Each active cap contributes one equation, d/dt(A x + As s) = 0, and one unknown
1746 * -- a fraction for a cap that holds or loses, an admitted flow for one that stages
1747 * -- so the system is square and small. This is what makes an event RESTART
1748 * consistent: RODAS needs the algebraic unknowns to satisfy their equations at the
1749 * initial point of an index-1 DAE. It is also the FEASIBILITY test at an
1750 * activation: a fraction above one means the cap would have to admit more than
1751 * arrives, so it is not binding after all.
1752 */
1753inline std::vector<double> fluid_dae_hold_multipliers(
1754 const FluidMomentTerms& terms, const FluidDaeGates& gates, const FluidDaeStaging& stg,
1755 const FluidDaeConstraints& con, const std::vector<std::size_t>& active,
1756 const std::vector<double>& x, const std::vector<double>& sg, const FluidClosure& cl,
1757 const std::vector<double>& m0, bool& ok) {
1758 std::vector<double> m = m0;
1759 ok = true;
1760 if (active.empty()) return m;
1761 const std::size_t n = terms.nstate;
1762 auto resid = [&](const std::vector<double>& mm) {
1763 const std::vector<double> r = fluid_moment_rates(terms, x, cl);
1764 const FluidDaeLegs lg = fluid_dae_legs(terms, gates, stg, con, active, sg, r, mm, true);
1765 std::vector<double> dx(n, 0.0);
1766 for (std::size_t s = 0; s < n; ++s) {
1767 double acc = 0.0;
1768 for (std::size_t e = 0; e < r.size(); ++e)
1769 acc += gates.Dn(s, e) * lg.rup[e] + gates.DnExt(s, e) * lg.rin[e] +
1770 gates.Dp(s, e) * lg.rin[e];
1771 dx[s] = acc;
1772 }
1773 std::vector<double> F(active.size(), 0.0);
1774 for (std::size_t k = 0; k < active.size(); ++k) {
1775 double acc = 0.0;
1776 for (std::size_t s = 0; s < n; ++s) acc += con.A(active[k], s) * dx[s];
1777 for (std::size_t j = 0; j < stg.n; ++j) acc += con.As(active[k], j) * lg.ds[j];
1778 F[k] = acc;
1779 }
1780 return F;
1781 };
1782 auto inf_norm = [](const std::vector<double>& v) {
1783 double a = 0.0;
1784 for (double e : v) a = std::max(a, std::fabs(e));
1785 return a;
1786 };
1787 std::vector<double> F = resid(m);
1788 for (int it = 0; it < 40; ++it) {
1789 if (inf_norm(F) < std::max(1e-12, 1e-10 * (inf_norm(m) + 1.0))) break;
1790 Matrix<double> J(F.size(), m.size(), 0.0);
1791 for (std::size_t j = 0; j < m.size(); ++j) {
1792 const double h = std::max(1e-7 * std::fabs(m[j]), 1e-9);
1793 std::vector<double> mp = m;
1794 mp[j] += h;
1795 const std::vector<double> Fp = resid(mp);
1796 for (std::size_t i = 0; i < F.size(); ++i) J(i, j) = (Fp[i] - F[i]) / h;
1797 }
1798 std::vector<double> step;
1799 try {
1800 step = lstsq(J, F).x;
1801 } catch (const std::exception&) {
1802 break;
1803 }
1804 double lam = 1.0;
1805 bool stepped = false;
1806 for (int ls = 0; ls < 20; ++ls) {
1807 std::vector<double> mn(m.size(), 0.0);
1808 for (std::size_t j = 0; j < m.size(); ++j) mn[j] = std::max(0.0, m[j] - lam * step[j]);
1809 const std::vector<double> Fn = resid(mn);
1810 if (inf_norm(Fn) < inf_norm(F)) {
1811 m = mn;
1812 F = Fn;
1813 stepped = true;
1814 break;
1815 }
1816 lam *= 0.5;
1817 }
1818 if (!stepped) break;
1819 }
1820 ok = inf_norm(F) < 1e-6;
1821 return m;
1822}
1823
1824/** What a hybrid transient did, beside the trajectory. */
1826 double t = 0.0;
1827 std::size_t row = 0;
1828 int kind = 0; ///< 0 release, 1 activate, 2 a crossing the cap could not hold
1829};
1830
1831/**
1832 * The transient UNDER CAPS: one index-1 DAE per segment, restarted at every located
1833 * crossing. See FluidDaeTransient for why the multiplier is an algebraic unknown and
1834 * why a staged cap's is a flow rather than a rate.
1835 */
1836inline std::vector<std::vector<double> > fluid_dae_integrate_hybrid(
1837 const FluidMomentTerms& terms, const FluidDaeConservation& cons,
1838 const FluidDaeConstraints& con, const FluidDaeGates& gates, const FluidDaeStaging& stg,
1839 const FluidClosure& closure, const std::vector<double>& x0In,
1840 const std::vector<double>& grid, double tol, std::vector<FluidDaeSwitch>* switches = nullptr) {
1841 using namespace rodas_impl;
1842 const std::size_t n = terms.nstate;
1843 const std::size_t ncon = con.b.size(), nstg = stg.n;
1844
1845 detail::FluidDaeTransient S;
1846 S.terms = &terms;
1847 S.cons = &cons;
1848 S.closure = closure;
1849 S.nstate = n;
1850 S.withcov = false;
1851 S.cov_idx = terms.cov_idx;
1852 S.nc = terms.cov_idx.size();
1853 S.con = &con;
1854 S.gates = &gates;
1855 S.stg = &stg;
1856 S.ncon = ncon;
1857 S.nstg = nstg;
1858 S.o_sg = n;
1859 S.o_m = n + nstg;
1860 S.o_cov = n + nstg + ncon;
1861 S.armed.assign(std::max<std::size_t>(ncon, 1), 0);
1862 S.inert.assign(ncon, 1.0);
1863 for (std::size_t c = 0; c < ncon; ++c)
1864 if (con.staged[c]) S.inert[c] = 0.0; // a flow is inert at zero
1865 const std::size_t nz = S.o_cov;
1866
1867 // A STATE ABOVE A CAP IS NOT A STATE THE MODEL CAN BE IN: holding the cap from
1868 // there would freeze the violation for the whole horizon. Start on the cap, and
1869 // MOVE the excess rather than dropping it -- conservation is an algebraic row,
1870 // so an initial state that does not satisfy it is an inconsistent initialisation
1871 // and no index-1 solver may be handed one.
1872 std::vector<double> x0 = x0In;
1873 x0.resize(n, 0.0);
1874 std::vector<double> sg0(nstg, 0.0), excess(std::max<std::size_t>(ncon, 1), 0.0);
1875 std::vector<std::size_t> over;
1876 for (std::size_t c = 0; c < ncon; ++c) {
1877 double val = 0.0, tot = 0.0;
1878 for (std::size_t s = 0; s < n; ++s) {
1879 val += con.A(c, s) * x0[s];
1880 if (con.A(c, s) > 0.0) tot += x0[s];
1881 }
1882 if (val > con.b[c] + std::max(1e-9, tol) && con.b[c] > 0.0) {
1883 excess[c] = tot * (1.0 - con.b[c] / val);
1884 for (std::size_t s = 0; s < n; ++s)
1885 if (con.A(c, s) > 0.0) x0[s] *= con.b[c] / val;
1886 over.push_back(c);
1887 }
1888 }
1889 // THE INITIAL MODE: a cap the initial state sits ON is already binding, so the
1890 // first segment must carry it. Feasibility decides.
1891 std::vector<double> m_init = S.inert;
1892 if (!over.empty()) {
1893 std::vector<double> m0(over.size(), 0.0);
1894 for (std::size_t a = 0; a < over.size(); ++a) m0[a] = con.staged[over[a]] ? 0.0 : 1.0;
1895 bool ok = false;
1896 const std::vector<double> mm = fluid_dae_hold_multipliers(terms, gates, stg, con, over,
1897 x0, sg0, closure, m0, ok);
1898 bool feasible = ok;
1899 for (std::size_t a = 0; a < over.size(); ++a)
1900 if (!con.staged[over[a]] && mm[a] > 1.0 + 1e-9) feasible = false;
1901 if (feasible) {
1902 S.active = over;
1903 for (std::size_t a = 0; a < over.size(); ++a) m_init[over[a]] = mm[a];
1904 }
1905 }
1906 for (std::size_t oi = 0; oi < over.size(); ++oi) {
1907 const std::size_t c = over[oi];
1908 if (excess[c] <= 0.0) continue;
1909 std::size_t rooms = 0;
1910 const bool staged_active =
1911 con.staged[c] && std::find(S.active.begin(), S.active.end(), c) != S.active.end();
1912 if (staged_active)
1913 for (std::size_t j = 0; j < nstg; ++j)
1914 if (stg.gated_by[c][j]) ++rooms;
1915 if (rooms) {
1916 for (std::size_t j = 0; j < nstg; ++j)
1917 if (stg.gated_by[c][j]) sg0[j] += excess[c] / static_cast<double>(rooms);
1918 continue;
1919 }
1920 // otherwise onto the coordinates that FEED the capped stations, which is
1921 // where a held job waits
1922 std::vector<bool> pool(n, false);
1923 bool any_feeder = false;
1924 std::vector<bool> feeders(n, false);
1925 for (std::size_t e = 0; e < terms.D.cols(); ++e) {
1926 if (!gates.gate[c][e]) continue;
1927 for (std::size_t s = 0; s < n; ++s)
1928 if (gates.Dn(s, e) < 0.0 || gates.DnExt(s, e) < 0.0) feeders[s] = true;
1929 }
1930 for (std::size_t s = 0; s < n; ++s) {
1931 pool[s] = con.A(c, s) <= 0.0;
1932 any_feeder = any_feeder || (pool[s] && feeders[s]);
1933 }
1934 if (any_feeder)
1935 for (std::size_t s = 0; s < n; ++s) pool[s] = pool[s] && feeders[s];
1936 double w = 0.0;
1937 std::size_t cnt = 0;
1938 for (std::size_t s = 0; s < n; ++s)
1939 if (pool[s]) { w += x0[s]; ++cnt; }
1940 if (w > 1e-8) {
1941 for (std::size_t s = 0; s < n; ++s)
1942 if (pool[s]) x0[s] += excess[c] * x0[s] / w;
1943 } else if (cnt) {
1944 for (std::size_t s = 0; s < n; ++s)
1945 if (pool[s]) x0[s] += excess[c] / static_cast<double>(cnt);
1946 }
1947 }
1948 for (std::size_t k = 0; k < S.active.size(); ++k) {
1949 const std::size_t c = S.active[k];
1950 if (!con.staged[c]) continue;
1951 double mass = 0.0;
1952 for (std::size_t j = 0; j < nstg; ++j)
1953 if (stg.gated_by[c][j]) mass += sg0[j];
1954 if (mass > 1e-8) S.armed[c] = 1;
1955 }
1956
1957 for (std::size_t c = 0; c < cons.C.rows(); ++c) {
1958 std::size_t best = n;
1959 double bestv = -1.0;
1960 for (std::size_t s = 0; s < n; ++s) {
1961 if (cons.C(c, s) == 0.0) continue;
1962 bool taken = false;
1963 for (std::size_t d = 0; d < S.alg_row.size(); ++d)
1964 if (S.alg_row[d] == s) taken = true;
1965 if (taken) continue;
1966 if (x0[s] > bestv) { bestv = x0[s]; best = s; }
1967 }
1968 if (best == n)
1969 throw NumericError("fluid_dae_integrate_hybrid: a chain has no free coordinate");
1970 S.alg_row.push_back(best);
1971 }
1972 if (grid.empty()) throw InputError("fluid_dae_integrate_hybrid: the output grid is empty");
1973 S.grid = grid;
1974
1975 std::vector<double> z(nz, 0.0);
1976 for (std::size_t s = 0; s < n; ++s) z[s] = x0[s];
1977 for (std::size_t j = 0; j < nstg; ++j) z[S.o_sg + j] = sg0[j];
1978 for (std::size_t c = 0; c < ncon; ++c) z[S.o_m + c] = m_init[c];
1979
1980 double tcur = 0.0;
1981 const double tend = grid.back();
1982 const std::size_t seg_max = 4 * ncon + 8;
1983 for (std::size_t seg = 0; seg < seg_max; ++seg) {
1984 S.gated_rooms.assign(nstg, 0);
1985 for (std::size_t k = 0; k < S.active.size(); ++k)
1986 if (con.staged[S.active[k]])
1987 for (std::size_t j = 0; j < nstg; ++j)
1988 if (stg.gated_by[S.active[k]][j]) S.gated_rooms[j] = 1;
1989 S.hit = -1;
1990 S.hit_z.clear();
1991 S.gprev.clear();
1992 detail::fluid_dae_active() = &S;
1993
1994 const integer N = static_cast<integer>(nz);
1995 const std::size_t lwork = nz * (nz + 1 + nz + 14) + 20 + 32;
1996 const std::size_t liwork = nz + 20 + 32;
1997 std::vector<doublereal> y(z.begin(), z.end()), work(lwork, 0.0), rpar(1, 0.0);
1998 std::vector<integer> iwork(liwork, 0), ipar(1, 0);
1999 doublereal rtol = tol, atol = tol * 1e-2, x = tcur, xend = tend, h = 1e-6;
2000 // THE JACOBIAN IS DIFFERENCED, not the drift's: under a cap the rows carry
2001 // the room and multiplier equations too, and a Jacobian right on one block
2002 // and zero on the others is worse than none -- RODAS would take it as exact.
2003 integer itol = 0, ifcn = 0, ijac = 0, mljac = N, mujac = N, idfx = 0;
2004 integer imas = 1, mlmas = 0, mumas = 0, iout = 1, idid = 0;
2005 integer lw = static_cast<integer>(lwork), liw = static_cast<integer>(liwork);
2006 try {
2007 rodas_(const_cast<integer*>(&N), (U_fp)detail::fluid_dae_fcn, &ifcn, &x, y.data(),
2008 &xend, &h, &rtol, &atol, &itol,
2009 (U_fp)detail::fluid_dae_jac, &ijac, &mljac, &mujac,
2010 (U_fp)detail::fluid_dae_dfx, &idfx,
2011 (U_fp)detail::fluid_dae_mas, &imas, &mlmas, &mumas,
2012 (U_fp)detail::fluid_dae_solout, &iout,
2013 work.data(), &lw, iwork.data(), &liw, rpar.data(), ipar.data(), &idid);
2014 } catch (...) {
2015 detail::fluid_dae_active() = nullptr;
2016 throw;
2017 }
2018 detail::fluid_dae_active() = nullptr;
2019 if (idid != 1 && idid != 2 && S.hit < 0)
2020 throw NumericError("fluid_dae_integrate_hybrid: RODAS returned idid=" +
2021 std::to_string(static_cast<long>(idid)));
2022 if (S.hit < 0) break; // the horizon was reached with this set binding
2023
2024 const std::size_t c = static_cast<std::size_t>(S.hit);
2025 tcur = S.hit_t;
2026 z = S.hit_z;
2027 const std::vector<double> xh(z.begin(), z.begin() + n);
2028 const std::vector<double> sgh(z.begin() + S.o_sg, z.begin() + S.o_sg + nstg);
2029 const bool was_active =
2030 std::find(S.active.begin(), S.active.end(), c) != S.active.end();
2031 if (was_active) {
2032 S.active.erase(std::remove(S.active.begin(), S.active.end(), c), S.active.end());
2033 S.armed[c] = 0;
2034 z[S.o_m + c] = S.inert[c];
2035 if (switches) switches->push_back(FluidDaeSwitch{tcur, c, 0});
2036 } else {
2037 std::vector<std::size_t> trial = S.active;
2038 trial.push_back(c);
2039 std::sort(trial.begin(), trial.end());
2040 std::vector<double> m0(trial.size(), 0.0);
2041 for (std::size_t a = 0; a < trial.size(); ++a)
2042 m0[a] = std::find(S.active.begin(), S.active.end(), trial[a]) != S.active.end()
2043 ? z[S.o_m + trial[a]]
2044 : (con.staged[trial[a]] ? 0.0 : 1.0);
2045 bool ok = false;
2046 const std::vector<double> mm = fluid_dae_hold_multipliers(
2047 terms, gates, stg, con, trial, xh, sgh, closure, m0, ok);
2048 bool feasible = ok;
2049 for (std::size_t a = 0; a < trial.size(); ++a)
2050 if (!con.staged[trial[a]] && mm[a] > 1.0 + 1e-9) feasible = false;
2051 if (!feasible) {
2052 if (switches) switches->push_back(FluidDaeSwitch{tcur, c, 2});
2053 break; // a crossing whose cap cannot be held
2054 }
2055 S.active = trial;
2056 for (std::size_t a = 0; a < trial.size(); ++a) z[S.o_m + trial[a]] = mm[a];
2057 if (switches) switches->push_back(FluidDaeSwitch{tcur, c, 1});
2058 }
2059 if (tcur >= tend - 1e-12) break;
2060 }
2061
2062 // Whatever the switching did not reach is reported at the last state, so the
2063 // grid the caller asked for always comes back full.
2064 while (S.out.size() < grid.size()) S.out.push_back(z);
2065 std::vector<std::vector<double> > out;
2066 for (std::size_t i = 0; i < S.out.size(); ++i)
2067 out.push_back(std::vector<double>(S.out[i].begin(), S.out[i].begin() + n));
2068 return out;
2069}
2070
2071/**
2072 * The metrics of one state, read exactly as the steady-state table reads them.
2073 *
2074 * Shared by the fixed point and by every point of a trajectory so that the two
2075 * cannot drift: `solver_fluid_transient` reuses `fluid_closing_metrics` for the
2076 * same reason, and the last point of a long enough run has to reproduce the
2077 * table or one of them is reading the drift differently.
2078 */
2079inline void fluid_dae_metrics(const FluidMomentTerms& terms, const std::vector<double>& x,
2080 const FluidClosure& cl, Matrix<double>& QN, Matrix<double>& UN,
2082 const std::vector<double>* rates = nullptr) {
2083 const std::size_t M = terms.station_block.size();
2084 const std::size_t K = M ? terms.class_block[0].size() : 0;
2085 // RATES, when given, is the flow that actually CROSSES each event: a cap that
2086 // holds the job upstream suppresses that station's departures, and reading the
2087 // nominal vector here would report a throughput the model does not carry.
2088 const std::vector<double> r = rates ? *rates : fluid_moment_rates(terms, x, cl);
2089 const std::vector<double> g = fluid_moment_factors(terms, x, cl);
2090 QN = Matrix<double>(M, K, 0.0);
2091 UN = Matrix<double>(M, K, 0.0);
2092 RN = Matrix<double>(M, K, 0.0);
2093 TN = Matrix<double>(M, K, 0.0);
2094 for (std::size_t i = 0; i < M; ++i)
2095 for (std::size_t k = 0; k < K; ++k) {
2096 const std::vector<std::size_t>& blk = terms.class_block[i][k];
2097 if (blk.empty()) continue;
2098 double q = 0.0, gg = 0.0;
2099 for (std::size_t s : blk) { q += x[s]; gg += g[s]; }
2100 QN(i, k) = q;
2101 UN(i, k) = (terms.sys.sched[i] == lang::SchedStrategy::INF) ? q : gg / terms.S[i];
2102 double tn = 0.0;
2103 for (std::size_t e = 0; e < r.size(); ++e)
2104 if (terms.ev_is_departure[e] && terms.ev_station[e] == i && terms.ev_class[e] == k)
2105 tn += r[e];
2106 TN(i, k) = tn;
2107 // TN is zero only to the integrator's accuracy: a class that never visits leaves
2108 // a ~1e-20 residue in TN too, and a strict > 0 test then divides residue by residue.
2109 if (tn > lang::GlobalConstants::Zero) RN(i, k) = q / tn;
2110 }
2111}
2112
2113namespace detail {
2114
2115/**
2116 * The t=0 state of the DAE, in the layout `terms` indexes.
2117 *
2118 * `solver_fluid_dae.m:821-828` takes the first row of the SEED TRAJECTORY, with
2119 * the comment that this is certainly in the closing state layout while
2120 * `options.init_sol` need not be. The port's seed carries no trajectory -- only
2121 * its fixed point -- but the two are the same vector by construction:
2122 * `fluid_dispatch` starts from `opt.init_sol` when the caller gave one and from
2123 * `fluid_default_initsol` otherwise, so that is what the seed's first row held.
2124 *
2125 * `C x0 = N` therefore holds at t=0, which is what makes the algebraic rows
2126 * consistent from the start and lets RODAS begin without a projection step.
2127 */
2128template <class T>
2129inline std::vector<double> fluid_dae_init_state(const qn::NetworkStruct<T>& sn,
2130 const FluidMomentTerms& terms,
2131 const FluidOptions& opt) {
2132 std::vector<double> x0 = opt.init_sol.empty()
2133 ? detail::fluid_default_initsol(sn, terms.sys.layout)
2134 : opt.init_sol;
2135 if (x0.size() != terms.nstate)
2136 throw InputError("solver_fluid_dae: the initial condition has " +
2137 std::to_string(x0.size()) + " entries where the closure state has " +
2138 std::to_string(terms.nstate));
2139 return x0;
2140}
2141
2142} // namespace detail
2143
2144/**
2145 * `solver_fluid_dae.m`: the min-normal closure solved as one system.
2146 *
2147 * Backs options.method = "dae". The answer is the same closure `minnormal`
2148 * computes; what differs is that the mean and the variance are solved
2149 * simultaneously rather than alternated, and that population conservation is an
2150 * equation rather than a consequence of the drift.
2151 */
2152template <class T>
2154 const FluidDaeOptions& dopt_in = FluidDaeOptions()) {
2155 const FluidDaeOptions dopt = fluid_dae_options(opt, dopt_in);
2156 const FluidMomentTerms terms = fluid_moment_terms(sn, opt);
2157 const std::size_t n = terms.nstate;
2158 const std::size_t M = terms.station_block.size();
2159 const std::size_t K = M ? terms.class_block[0].size() : 0;
2160
2161 if (n > dopt.maxstate)
2162 throw UnsupportedError(
2163 "solver_fluid_dae: the dae method solves a " + std::to_string(n) +
2164 "-unknown algebraic system with a finite-difference Jacobian, above the limit of " +
2165 std::to_string(dopt.maxstate) +
2166 " set by options.config.dae_maxstate. Raise it, or use options.method='minnormal' for "
2167 "the same closure by successive substitution.");
2168 // DPS and GPS close on the covariance BETWEEN a station's class coordinates,
2169 // not on the station total, so their closure state is a matrix block rather
2170 // than the scalar this solves for. `minnormal` carries those blocks through
2171 // its outer iteration; refusing is better than silently dropping them.
2172 for (std::size_t i = 0; i < M; ++i)
2173 if (terms.sys.sched[i] == lang::SchedStrategy::DPS ||
2175 throw UnsupportedError(
2176 "solver_fluid_dae: the dae method closes on the per-station variance only, and "
2177 "DPS/GPS close on the covariance between their class coordinates. Use "
2178 "options.method='minnormal'.");
2179
2180 // Caps first: staging depends on them, and conservation must count the blocked
2181 // mass staging holds. GATES decides, per cap and per event, where the mass a
2182 // cap stops actually goes -- held upstream, lost, or staged.
2184 const FluidDaeGates gates = fluid_dae_gates(sn, terms, con);
2185 const FluidDaeStaging stg = fluid_dae_staging(terms, con);
2186 fluid_dae_extend(con, stg, terms);
2187 const FluidDaeConservation cons = fluid_dae_conservation(sn, terms, stg);
2188 const std::size_t ncon = con.b.size();
2189
2190 // C*D vanishes on a conserving event set, which is the structural fact that
2191 // makes the drift Jacobian singular; a leak here would mean the constraint
2192 // contradicts the drift rather than completing it.
2193 for (std::size_t c = 0; c < cons.C.rows(); ++c)
2194 for (std::size_t e = 0; e < terms.D.cols(); ++e) {
2195 double acc = 0.0;
2196 for (std::size_t s = 0; s < n; ++s) acc += cons.C(c, s) * terms.D(s, e);
2197 if (std::fabs(acc) > 1e-7)
2198 throw NumericError(
2199 "solver_fluid_dae: the event set does not conserve a closed chain");
2200 }
2201
2202 FluidDaeSystem sysd;
2203 sysd.terms = &terms;
2204 sysd.cons = &cons;
2205 sysd.con = &con;
2206 sysd.stg = &stg;
2207 sysd.gates = &gates;
2208 sysd.cidx = fluid_dae_closable(terms);
2209 sysd.nstate = n;
2210
2211 // The seed: Newton needs a point in the basin, not an answer. One
2212 // first-order solve supplies it, at the cost of the single integration this
2213 // method exists to avoid repeating twenty times.
2214 FluidOptions mo = opt;
2215 mo.method = "closing";
2216 mo.closure = FluidClosure();
2217 mo.timespan_end = std::numeric_limits<double>::infinity();
2218 const FluidSolution seed = detail::fluid_dispatch(sn, mo);
2219 std::vector<double> xcur(seed.xvec.begin(), seed.xvec.end());
2220 xcur.resize(n, 0.0);
2221
2222 // THE VARIANCE IS SEEDED POSITIVE, which is why this route needs no kink
2223 // probe. sigma2 = 0 is where min(n,c) has no derivative and a saturated
2224 // model's first-order fixed point sits exactly there. The station mean is an
2225 // O(N) starting value in the right units and costs no Lyapunov solve.
2226 std::vector<double> s2seed(sysd.cidx.size(), 0.0);
2227 for (std::size_t j = 0; j < sysd.cidx.size(); ++j) {
2228 double acc = 0.0;
2229 for (std::size_t sIdx : terms.station_block[sysd.cidx[j]]) acc += xcur[sIdx];
2230 s2seed[j] = std::max(1e-8, acc);
2231 }
2232
2233 const double tol = (opt.tol > 0.0 && std::isfinite(opt.tol)) ? opt.tol : 1e-8;
2234 FluidDaeNewtonInfo info;
2235 std::vector<double> u, sgv(stg.n, 0.0), mult;
2236
2237 // ACTIVE SET. A capacity constraint is an inequality, and an inequality has
2238 // no residual to hand a Newton solver -- only the caps that actually bind
2239 // become equations. Each pass is a complete simultaneous solve, so the loop
2240 // iterates over WHICH caps bind, not over the closure.
2241 //
2242 // ONE MULTIPLIER PER ACTIVE ROW, not per region. A region's waiting room used
2243 // to carry a single drain rate, so two caps of one region were two equalities
2244 // against one control and the case was refused by name. A room gated by
2245 // several active rows now drains at the harmonic composition of their rates
2246 // and a held cap composes as a product of fractions.
2247 //
2248 // THE SET STARTS EMPTY, and the first pass therefore asks for the UNCONSTRAINED
2249 // fixed point -- which is what makes the answer of a model whose caps bind
2250 // independent of how far outside the seed happened to land. It is also not
2251 // always solvable: an overloaded M/M/1/K has NO equilibrium without its cap, so
2252 // that pass fails rather than converging, and the caps the iterate violates are
2253 // seeded into the set instead (below). Doing that up front for every model
2254 // looked cheaper and changed answers: a region of 8 over two identical queues
2255 // settled 7.43/0.57 from the clipped seed and 4/4 from the unconstrained one.
2256 bool seeded_from_failure = false;
2257
2258 // the last iterate that WAS a fixed point, and the answer read off it: a pass
2259 // that fails to converge leaves an iterate that is not a fixed point of
2260 // anything, and reading the next active set off it is how one bad pass turns
2261 // into a walk through unrelated capacity combinations
2262 std::vector<double> x_ok = xcur, s2_ok = s2seed;
2263 bool have_best = false, best_feasible = false;
2264 std::vector<double> best_x, best_sg, best_s2, best_mult;
2265 std::vector<std::size_t> best_active;
2266 FluidDaeNewtonInfo best_info;
2267 bool clamped = false, best_clamped = false;
2268 const std::size_t aset_max = std::max<std::size_t>(4, 2 * ncon + 2);
2269 for (std::size_t aset = 0; aset < aset_max; ++aset) {
2270 bool has_clamp = false;
2271 for (std::size_t k = 0; k < sysd.active.size(); ++k)
2272 has_clamp = has_clamp || !con.staged[sysd.active[k]];
2273
2274 // Seed each waiting room with the mass that does not fit, and every
2275 // multiplier at unity -- an unthrottled fraction for a held or lost cap, and
2276 // the drain rate the region route has always started from.
2277 std::vector<double> sg0(stg.n, 0.0);
2278 for (std::size_t k = 0; k < sysd.active.size(); ++k) {
2279 const std::size_t c = sysd.active[k];
2280 if (!con.staged[c]) continue;
2281 double cur = 0.0;
2282 for (std::size_t sIdx = 0; sIdx < n; ++sIdx) cur += con.A(c, sIdx) * xcur[sIdx];
2283 const double excess = std::max(0.0, cur - con.b[c]);
2284 std::size_t cnt = 0;
2285 for (std::size_t j = 0; j < stg.n; ++j) if (stg.gated_by[c][j]) ++cnt;
2286 if (cnt)
2287 for (std::size_t j = 0; j < stg.n; ++j)
2288 if (stg.gated_by[c][j]) sg0[j] = excess / static_cast<double>(cnt);
2289 }
2290 std::vector<double> u0;
2291 u0.assign(xcur.begin(), xcur.end());
2292 u0.insert(u0.end(), sg0.begin(), sg0.end());
2293 u0.insert(u0.end(), s2seed.begin(), s2seed.end());
2294 u0.insert(u0.end(), sysd.active.size(), 1.0);
2295
2296 // THE CLAMPED COVARIANCE IS A FALLBACK, NOT THE DEFAULT. A cap that holds or
2297 // loses fixes its own combination of the state, so the honest linear-noise
2298 // approximation puts no fluctuation there -- but the truth is neither that
2299 // nor the unprojected variance: the population under a cap follows a
2300 // TRUNCATED distribution. The unprojected solve is what every other fluid
2301 // method computes, so it runs first; the projection is tried only when the
2302 // unprojected system has no stationary covariance at all, which is the
2303 // neutral case an overloaded loss station produces. Deciding once per PASS
2304 // matters: a projection switching between iterates would give Newton a
2305 // discontinuous system.
2306 bool solved = false;
2307 bool nohyp = false;
2308 // WHETHER THE FAILURE WAS THE NON-HYPERBOLIC ONE, kept apart from the
2309 // message. Rethrowing every Newton failure as a plain NumericError below
2310 // erases exactly the distinction FluidNonHyperbolicError exists to carry:
2311 // the runner's fallback ladder catches THAT type and only that type, so a
2312 // model whose fixed point is merely neutral would reach the caller as a
2313 // hard error instead of walking to the next rung.
2314 bool nohyp_typed = false;
2315 std::string nohyp_what;
2316 for (int attempt = 0; attempt < (has_clamp ? 2 : 1); ++attempt) {
2317 sysd.clampT = attempt == 1 ? fluid_dae_clamp_tangent(con, sysd.active, terms)
2318 : Matrix<double>(0, 0, 0.0);
2319 u = u0;
2320 try {
2321 info = fluid_dae_newton(sysd, u, tol, dopt.newton_max);
2322 } catch (const FluidNonHyperbolicError& e) {
2323 if (attempt == 1 || !has_clamp) {
2324 nohyp = true;
2325 nohyp_typed = true;
2326 nohyp_what = e.what();
2327 break;
2328 }
2329 continue;
2330 } catch (const std::exception& e) {
2331 if (attempt == 1 || !has_clamp) {
2332 nohyp = true;
2333 nohyp_what = e.what();
2334 break;
2335 }
2336 continue;
2337 }
2338 clamped = attempt == 1;
2339 solved = true;
2340 if (info.converged) break;
2341 }
2342 if (nohyp) {
2343 // THE UNCONSTRAINED FIXED POINT NEED NOT EXIST. An overloaded open
2344 // station has no equilibrium until its buffer bounds it, so the pass
2345 // that asks for one fails and the caps the iterate violates -- or left
2346 // infinite, which no comparison catches -- are seeded into the active
2347 // set instead. Once: a second failure with caps already bound is the
2348 // model's answer and not a starting point to improve.
2349 std::vector<std::size_t> cand;
2350 for (std::size_t c = 0; c < ncon; ++c) {
2351 if (std::find(sysd.active.begin(), sysd.active.end(), c) != sysd.active.end())
2352 continue;
2353 double acc = 0.0;
2354 bool bad = false;
2355 for (std::size_t s = 0; s < n; ++s) {
2356 if (con.A(c, s) > 0.0 && !std::isfinite(xcur[s])) bad = true;
2357 acc += con.A(c, s) * xcur[s];
2358 }
2359 if (bad || acc > con.b[c] + std::max(1e-9, tol)) cand.push_back(c);
2360 }
2361 if (seeded_from_failure || cand.empty()) {
2362 if (nohyp_typed) throw FluidNonHyperbolicError(nohyp_what);
2363 throw NumericError(nohyp_what);
2364 }
2365 for (std::size_t ci = 0; ci < cand.size(); ++ci) {
2366 const std::size_t c = cand[ci];
2367 double val = 0.0;
2368 std::size_t cnt = 0;
2369 for (std::size_t s = 0; s < n; ++s) {
2370 val += con.A(c, s) * xcur[s];
2371 if (con.A(c, s) > 0.0) ++cnt;
2372 }
2373 if (!cnt || !(con.b[c] > 0.0)) continue;
2374 if (std::isfinite(val) && val > con.b[c]) {
2375 const double f = con.b[c] / val;
2376 for (std::size_t s = 0; s < n; ++s)
2377 if (con.A(c, s) > 0.0) xcur[s] *= f;
2378 } else if (!std::isfinite(val)) {
2379 for (std::size_t s = 0; s < n; ++s)
2380 if (con.A(c, s) > 0.0) xcur[s] = con.b[c] / static_cast<double>(cnt);
2381 }
2382 }
2383 for (std::size_t s = 0; s < n; ++s)
2384 if (!std::isfinite(xcur[s])) xcur[s] = 0.0;
2385 for (std::size_t ci = 0; ci < cand.size(); ++ci) sysd.active.push_back(cand[ci]);
2386 std::sort(sysd.active.begin(), sysd.active.end());
2387 sysd.active.erase(std::unique(sysd.active.begin(), sysd.active.end()),
2388 sysd.active.end());
2389 seeded_from_failure = true;
2390 continue;
2391 }
2392 if (!solved) throw NumericError("solver_fluid_dae: the closure could not be evaluated");
2393 xcur.assign(u.begin(), u.begin() + n);
2394 sgv.assign(u.begin() + n, u.begin() + n + stg.n);
2395 for (std::size_t j = 0; j < sysd.cidx.size(); ++j)
2396 s2seed[j] = std::max(0.0, u[n + stg.n + j]);
2397 mult.assign(u.begin() + n + stg.n + sysd.cidx.size(), u.end());
2398 if (ncon == 0) break;
2399
2400 std::vector<double> slack(ncon, 0.0);
2401 bool feasible = true;
2402 for (std::size_t c = 0; c < ncon; ++c) {
2403 double acc = 0.0;
2404 for (std::size_t sIdx = 0; sIdx < n; ++sIdx) acc += con.A(c, sIdx) * xcur[sIdx];
2405 for (std::size_t j = 0; j < stg.n; ++j) acc += con.As(c, j) * sgv[j];
2406 slack[c] = con.b[c] - acc;
2407 if (slack[c] < -std::max(1e-9, tol)) feasible = false;
2408 }
2409 if (info.converged) {
2410 x_ok = xcur;
2411 s2_ok = s2seed;
2412 have_best = true;
2413 best_x = xcur; best_sg = sgv; best_s2 = s2seed; best_mult = mult;
2414 best_active = sysd.active; best_info = info; best_clamped = clamped;
2415 best_feasible = feasible;
2416 }
2417 std::vector<std::size_t> violated;
2418 for (std::size_t c = 0; c < ncon; ++c) {
2419 if (std::find(sysd.active.begin(), sysd.active.end(), c) != sysd.active.end()) continue;
2420 if (slack[c] < -std::max(1e-9, tol)) violated.push_back(c);
2421 }
2422 // THE RELEASE SIGNAL IS THE MULTIPLIER'S OWN UNITS, and the two kinds do not
2423 // share them. A held or lost cap throttles by a FRACTION, so one that came
2424 // back above one was holding the flow down for no reason. A staged cap
2425 // throttles by a RATE, which has no such scale -- there the signal is a
2426 // waiting room with no blocked mass at all.
2427 std::vector<std::size_t> released;
2428 for (std::size_t k = 0; k < sysd.active.size(); ++k) {
2429 const std::size_t c = sysd.active[k];
2430 if (con.staged[c]) {
2431 double held = 0.0;
2432 std::size_t rooms = 0;
2433 for (std::size_t j = 0; j < stg.n; ++j)
2434 if (stg.gated_by[c][j]) { held += sgv[j]; ++rooms; }
2435 if (!rooms || held < 1e-9) released.push_back(c);
2436 } else if (mult[k] > 1.0 + std::max(1e-9, tol)) {
2437 released.push_back(c);
2438 }
2439 }
2440 if (!info.converged) {
2441 // A FAILED PASS SAYS NOTHING ABOUT WHICH CAPS BIND. Its release signal is
2442 // still information, but its state is not, so no row is ADDED from it and
2443 // the next pass restarts from the last point that was a fixed point.
2444 violated.clear();
2445 xcur = x_ok;
2446 s2seed = s2_ok;
2447 if (released.empty()) break;
2448 }
2449 if (violated.empty() && released.empty()) break;
2450 std::vector<std::size_t> next;
2451 for (std::size_t a : sysd.active)
2452 if (std::find(released.begin(), released.end(), a) == released.end()) next.push_back(a);
2453 for (std::size_t v : violated) next.push_back(v);
2454 std::sort(next.begin(), next.end());
2455 next.erase(std::unique(next.begin(), next.end()), next.end());
2456 sysd.active = next;
2457 }
2458 // A CONVERGED POINT BEATS THE LAST ITERATE. The loop can end on a pass that did
2459 // not converge -- a cap the closure cannot hold at any multiplier is added,
2460 // fails and is released for as long as the loop runs.
2461 if (have_best && !info.converged) {
2462 xcur = best_x; sgv = best_sg; s2seed = best_s2; mult = best_mult;
2463 sysd.active = best_active; info = best_info; clamped = best_clamped;
2464 // THIS PORT THROWS WHERE THE REFERENCE WARNS, and only because it has no
2465 // warning channel: MATLAB, the JAR and native Python report the converged
2466 // point beside a warning naming the caps it exceeds. Returning a
2467 // cap-violating point silently is the one option none of them takes.
2468 if (!best_feasible)
2469 throw NumericError(
2470 "solver_fluid_dae: no fixed point of the closure satisfies every cap. The closure "
2471 "wants more jobs there than the cap allows and no admission multiplier holds it. "
2472 "Use SolverCTMC, SolverJMT, SolverSSA or SolverLDES for this model.");
2473 }
2474 sysd.clampT = clamped ? fluid_dae_clamp_tangent(con, sysd.active, terms)
2475 : Matrix<double>(0, 0, 0.0);
2476
2477 std::vector<double> x = xcur;
2478 FluidClosure cl;
2479 cl.sigma2.assign(M, 0.0);
2480 cl.cov.assign(M, Matrix<double>(0, 0, 0.0));
2481 for (std::size_t j = 0; j < sysd.cidx.size(); ++j)
2482 cl.sigma2[sysd.cidx[j]] = std::max(0.0, u[n + stg.n + j]);
2483
2484 // The transient is the same closure integrated as an index-1 DAE, held at
2485 // the variance the steady state converged to -- so the trajectory and the
2486 // table are read off one drift rather than two.
2487 //
2488 // THE INITIAL CONDITION IS THE MODEL'S, NOT THE SEED'S ANSWER. `seed` is the
2489 // first-order solve run to its fixed point, so `seed.xvec` is a STEADY
2490 // STATE; starting the integration there makes every horizon report the
2491 // answer it already had and hides the trajectory entirely. The reference
2492 // takes `xfall(1,:)`, the first row of that seed's own trajectory, which is
2493 // the t=0 state -- and that is `fluid_default_initsol`, the initial
2494 // condition the seed run itself started from, in the layout `terms` indexes.
2495 if (std::isfinite(opt.timespan_end) && opt.timespan_end > 0.0) {
2496 const std::vector<double> x0 = detail::fluid_dae_init_state(sn, terms, opt);
2497 const std::vector<double> grid(1, opt.timespan_end);
2498 // Held at the converged variance HERE, and only here: this call wants
2499 // the state at one horizon to read a table off, not a path, so the
2500 // covariance rows would be integrated and thrown away.
2501 //
2502 // UNDER A CAP THIS IS A HYBRID DAE, integrated segment by segment with the
2503 // binding set updated at each located crossing: what the steady state
2504 // settles once with an active-set loop, the trajectory settles again at
2505 // every fill and every drain.
2506 const std::vector<double> zend =
2507 con.empty()
2508 ? fluid_dae_integrate(terms, cons, cl, x0, grid, tol).back()
2509 : fluid_dae_integrate_hybrid(terms, cons, con, gates, stg, cl, x0, grid, tol)
2510 .back();
2511 x.assign(zend.begin(), zend.begin() + n);
2512 }
2513
2514 // THE FIRING RATES, and the same covariance treatment the converged pass used:
2515 // reading the nominal vector here would count admissions a held cap suppressed,
2516 // and dropping the clamp would ask for a covariance the constrained fixed point
2517 // does not have (an overloaded loss station is neutral along its cap).
2518 const std::vector<double> rnom = fluid_moment_rates(terms, x, cl);
2519 const FluidDaeLegs lgf =
2520 fluid_dae_legs(terms, gates, stg, con, sysd.active, sgv, rnom, mult, false);
2521 const std::vector<double> r = lgf.rin;
2522 const Matrix<double> A = fluid_drift_jacobian(terms, x, cl);
2523 const Matrix<double>* cTf = sysd.clampT.rows() ? &sysd.clampT : nullptr;
2524 const Matrix<double> Sigma = fluid_moment_lyapunov(terms, A, lgf.rup, cTf);
2525
2526 FluidSolution out;
2527 fluid_dae_metrics(terms, x, cl, out.QN, out.UN, out.RN, out.TN, &r);
2528
2529 // UTILIZATION MUST BE READ FROM THE FLOW THAT ACTUALLY CROSSES once a cap
2530 // binds. Away from a constraint the in-service fluid sum(g)/s and the carried
2531 // utilization T/(mu s) are the same number, because the drift balances; under
2532 // an ACTIVE cap they are not -- the multiplier throttles the departures (TN)
2533 // and leaves the in-service fluid alone, so the two columns disagreed: a closed
2534 // tandem capped at 1 reported Util 0.688 at a station whose own Tput/mu was
2535 // 0.550, and the exact answer is neither. Every other solver reports the
2536 // CARRIED utilization (Util = X*D, the utilization law), and SolverCTMC gives
2537 // 0.444 for the same station, so that is the convention the throttled point has
2538 // to keep. It is applied HERE and not inside fluid_dae_metrics because that
2539 // helper is shared with every point of the trajectory, whose active set is not
2540 // this one. Unconstrained runs are bit-identical: the loop is entered only when
2541 // the active set is non-empty. see _kb/06-solver-catalog.md
2542 if (!sysd.active.empty()) {
2543 for (std::size_t i = 0; i < M; ++i) {
2544 if (terms.sys.sched[i] == lang::SchedStrategy::INF || terms.is_ext[i]) continue;
2545 for (std::size_t k = 0; k < K; ++k) {
2546 if (terms.class_block[i][k].empty()) continue;
2547 const double mu = num_traits<T>::to_double(sn.rates(i, k));
2548 if (std::isfinite(mu) && mu > 0.0)
2549 out.UN(i, k) = out.TN(i, k) / (mu * terms.S[i]);
2550 }
2551 }
2552 }
2553 out.CN.assign(K, 0.0);
2554 out.XN.assign(K, 0.0);
2555 for (std::size_t k = 0; k < K; ++k) {
2556 const std::size_t rs = (k < sn.classes.size()) ? sn.classes[k].refstat : 0;
2557 if (rs >= 1 && rs <= M) out.XN[k] = out.TN(rs - 1, k);
2558 double q = 0.0;
2559 for (std::size_t i = 0; i < M; ++i) q += out.QN(i, k);
2560 if (out.XN[k] > 0.0) out.CN[k] = q / out.XN[k];
2561 }
2562
2563 out.xvec = x;
2564 out.iters = info.iters + seed.iters;
2565 out.method = "dae";
2566 out.closure = cl;
2567 out.has_moments = true;
2568 out.moments.Sigma = Sigma;
2569 out.moments.sigma2 = cl.sigma2;
2570 out.moments.outer_iters = info.iters;
2571 out.moments.class_block = terms.class_block;
2572 out.moments.QVar = Matrix<double>(M, K, 0.0);
2573 out.moments.QStd = Matrix<double>(M, K, 0.0);
2574 for (std::size_t i = 0; i < M; ++i)
2575 for (std::size_t k = 0; k < K; ++k) {
2576 const std::vector<std::size_t>& blk = terms.class_block[i][k];
2577 double acc = 0.0;
2578 for (std::size_t a = 0; a < blk.size(); ++a)
2579 for (std::size_t b = 0; b < blk.size(); ++b) acc += Sigma(blk[a], blk[b]);
2580 out.moments.QVar(i, k) = std::max(0.0, acc);
2581 out.moments.QStd(i, k) = std::sqrt(out.moments.QVar(i, k));
2582 }
2583 return out;
2584}
2585
2586/**
2587 * `@@SolverFLD/getTranAvg` for the DAE route: the metrics ALONG the trajectory.
2588 *
2589 * The counterpart of `solver_fluid_transient` (solver_fluid.h), and different
2590 * from it in exactly the way this method is different. That one forces the
2591 * method to `closing` -- the reference does the same, because matrix and the
2592 * smoothed variants are steady-state devices -- and integrates y' = f with
2593 * LSODA, so population conservation holds only to integrator tolerance. This
2594 * integrates M y' = f with a SINGULAR M, so conservation is an algebraic
2595 * equation satisfied at every reported point rather than a quantity that drifts.
2596 *
2597 * THE VARIANCE IS HELD AT ITS STATIONARY VALUE, which is the narrowing the port
2598 * makes against `solver_fluid_dae.m`. The reference integrates the covariance
2599 * alongside the mean when the closable state is small enough (nc <= dae_maxcov)
2600 * and holds it above that; this always holds it. What is held is the variance
2601 * the steady-state Newton converged to, so the trajectory and the table are read
2602 * off ONE drift -- and a held variance is what `minnormal` uses for the whole of
2603 * its own transient anyway, so this is the reference's own fallback, not a
2604 * different closure.
2605 *
2606 * The steady state is solved FIRST and is not optional: it is where that
2607 * variance comes from. A caller that wants only the trajectory still pays for
2608 * the fixed point.
2609 */
2610template <class T>
2611std::vector<FluidTranPoint> solver_fluid_dae_transient(
2612 const qn::NetworkStruct<T>& sn, const FluidOptions& opt, double t_end,
2613 std::size_t points = 101, const std::vector<double>& out_grid = std::vector<double>(),
2614 const FluidDaeOptions& dopt_in = FluidDaeOptions()) {
2615 if (!(t_end > 0.0)) throw InputError("solver_fluid_dae_transient: t_end must be positive");
2616 if (points < 2) throw InputError("solver_fluid_dae_transient: need at least two output points");
2617 const FluidDaeOptions dopt = fluid_dae_options(opt, dopt_in);
2618
2619 FluidOptions os = opt;
2620 os.timespan_end = std::numeric_limits<double>::infinity();
2621 const FluidMomentTerms terms = fluid_moment_terms(sn, os);
2623 const FluidDaeGates gates = fluid_dae_gates(sn, terms, con);
2624 const FluidDaeStaging stg = fluid_dae_staging(terms, con);
2625 fluid_dae_extend(con, stg, terms);
2626 const FluidDaeConservation cons = fluid_dae_conservation(sn, terms, stg);
2627
2628 // The steady state, for the variance the trajectory STARTS from -- and, above
2629 // `maxcov`, is held at. Asked for over an infinite horizon so the solve is
2630 // the algebraic one and this does not recurse into an integration.
2631 const FluidSolution ss = solver_fluid_dae(sn, os, dopt);
2632
2633 // THE COVARIANCE IS INTEGRATED ALONGSIDE THE MEAN where it is small enough
2634 // to afford, which is what makes this and `kp` the only fluid methods with a
2635 // time-varying second moment; `minnormal` evaluates its whole transient at
2636 // the single STATIONARY variance. The cost is the reason for the cap: nc^2
2637 // extra differential states differenced numerically is nc^4 work. Above it
2638 // the mean is still integrated as a DAE -- conservation stays an equation --
2639 // and the variance falls back to the stationary one, which is the
2640 // reference's own fallback rather than a different closure.
2641 const std::vector<std::size_t> closable = fluid_dae_closable(terms);
2642 const std::size_t nc = terms.cov_idx.size();
2643 const bool withcov = nc > 0 && nc <= dopt.maxcov;
2644
2645 std::vector<double> grid = out_grid;
2646 if (grid.empty()) {
2647 grid.resize(points);
2648 for (std::size_t j = 0; j < points; ++j)
2649 grid[j] = t_end * static_cast<double>(j) / static_cast<double>(points - 1);
2650 }
2651 // t=0 is the initial condition, which no integrator has to be asked for.
2652 const bool has_zero = !grid.empty() && grid.front() <= 0.0;
2653 std::vector<double> inner(grid.begin() + (has_zero ? 1 : 0), grid.end());
2654
2655 const double tol = (opt.tol > 0.0 && std::isfinite(opt.tol)) ? opt.tol : 1e-8;
2656 const std::vector<double> x0 = detail::fluid_dae_init_state(sn, terms, opt);
2657 // THE ENTRY COVARIANCE OF A CARRIED DISTRIBUTION. SolverEnv's `meancov`
2658 // coupling hands the entry second moment over the (station, class) pairs as
2659 // `config.init_qcov`, indexed ir = r*M + i, the same spelling
2660 // `FluidTranPoint::QCov` reports back. Without it every stage restarts from
2661 // Sigma(0) = 0, which asserts the switch delivered a known deterministic
2662 // population, and the DRIFT reads that variance -- the closure is evaluated at
2663 // the sigma2 derived from Sigma -- so the mean is integrated from the
2664 // understatement as well.
2665 //
2666 // `config.init_qlen` is NOT read here, and is not silently dropped either: it
2667 // is the MEAN of that same entry distribution, and this method already starts
2668 // from it, through `init_sol`. Taking the block sums of x0 rather than of
2669 // init_qlen also guarantees that the second moment starts from the same mean
2670 // the drift does.
2671 Matrix<double> Sigma0(0, 0, 0.0);
2672 if (opt.init_qcov.rows() > 0 || opt.init_qcov.cols() > 0) {
2673 const std::size_t M0 = terms.class_block.size();
2674 const std::size_t K0 = M0 ? terms.class_block[0].size() : 0;
2675 const std::size_t npair = M0 * K0;
2676 if (opt.init_qcov.rows() != npair || opt.init_qcov.cols() != npair)
2677 throw InputError("solver_fluid_dae_transient: config.init_qcov is " +
2678 std::to_string(opt.init_qcov.rows()) + "x" +
2679 std::to_string(opt.init_qcov.cols()) + " but the model has " +
2680 std::to_string(npair) +
2681 " station-class pairs, so the covariance must be " +
2682 std::to_string(npair) + "x" + std::to_string(npair) +
2683 ", indexed r*" + std::to_string(M0) + "+i.");
2684 double asym = 0.0, scale = 0.0;
2685 for (std::size_t a = 0; a < npair; ++a)
2686 for (std::size_t b = 0; b < npair; ++b) {
2687 const double d = opt.init_qcov(a, b) - opt.init_qcov(b, a);
2688 asym = std::max(asym, std::abs(d));
2689 scale = std::max(scale, std::abs(opt.init_qcov(a, b)));
2690 }
2691 if (asym > 1e-6 * std::max(1.0, scale))
2692 throw InputError("solver_fluid_dae_transient: config.init_qcov must be symmetric.");
2693 // A HELD VARIANCE HAS NOWHERE TO PUT A CARRIED ONE, and the capped hybrid
2694 // run holds it for the whole horizon. Saying so beats dropping the seed in
2695 // silence, which would report a covariance the run never used -- and it is
2696 // a WARNING rather than a refusal because SolverEnv offers `init_qcov` to
2697 // every `dae` stage of a `meancov` run: refusing here would turn a capped
2698 // stage into a failed environment, where the reference warns once per
2699 // iteration and keeps its answer.
2700 if (!withcov || !con.empty())
2701 std::cerr << "[LINE] Warning: config.init_qcov was supplied, but the transient "
2702 "covariance is held at its stationary value on this model (above "
2703 "config.dae_maxcov, or under a finite-capacity cap, where the hybrid "
2704 "run holds it across every located crossing), so the carried entry "
2705 "covariance cannot be used.\n";
2706 else
2707 Sigma0 = fluid_dae_lift_qcov(opt.init_qcov, x0, terms);
2708 }
2709 const std::size_t nz = terms.nstate + (withcov ? nc * nc : 0);
2710 std::vector<std::vector<double> > xs;
2711 if (has_zero) {
2712 std::vector<double> z0(nz, 0.0);
2713 for (std::size_t i = 0; i < terms.nstate; ++i) z0[i] = x0[i];
2714 if (withcov && Sigma0.rows() == terms.nstate)
2715 for (std::size_t a = 0; a < nc; ++a)
2716 for (std::size_t b = 0; b < nc; ++b)
2717 z0[terms.nstate + a * nc + b] =
2718 Sigma0(terms.cov_idx[a], terms.cov_idx[b]);
2719 xs.push_back(z0);
2720 }
2721 if (!inner.empty()) {
2722 // UNDER A CAP THIS IS A HYBRID DAE, integrated segment by segment with the
2723 // binding set updated at each located crossing. The covariance is held there
2724 // rather than integrated: a segment restart would have to carry it across
2725 // the switch, and what the reference holds above `maxcov` it holds here for
2726 // the whole capped run.
2727 const std::vector<std::vector<double> > got =
2728 con.empty()
2729 ? fluid_dae_integrate(terms, cons, ss.closure, x0, inner, tol, withcov, closable,
2730 Sigma0)
2731 : fluid_dae_integrate_hybrid(terms, cons, con, gates, stg, ss.closure, x0, inner,
2732 tol);
2733 xs.insert(xs.end(), got.begin(), got.end());
2734 }
2735
2736 std::vector<FluidTranPoint> out;
2737 out.reserve(xs.size());
2738 const std::size_t M = terms.station_block.size();
2739 const std::size_t K = M ? terms.class_block[0].size() : 0;
2740 for (std::size_t j = 0; j < xs.size(); ++j) {
2741 std::vector<double> x(xs[j].begin(), xs[j].begin() + terms.nstate);
2742 // The interpolant is a polynomial and does not know the state is a
2743 // population; a point that undershoots zero between two steps is
2744 // rounding, not a negative queue.
2745 for (double& v : x)
2746 if (v < 0.0) v = 0.0;
2747 // The rate factors are read at the variance the trajectory HAD at this
2748 // instant, not at the stationary one, whenever the covariance was
2749 // carried; that is the whole difference from `minnormal`'s transient.
2750 FluidClosure cl = ss.closure;
2751 Matrix<double> Sigma(terms.nstate, terms.nstate, 0.0);
2752 if (withcov) {
2753 for (std::size_t a = 0; a < nc; ++a)
2754 for (std::size_t b = 0; b < nc; ++b)
2755 Sigma(terms.cov_idx[a], terms.cov_idx[b]) =
2756 0.5 * (xs[j][terms.nstate + a * nc + b]
2757 + xs[j][terms.nstate + b * nc + a]);
2758 cl.sigma2.assign(M, 0.0);
2759 cl.cov.assign(M, Matrix<double>(0, 0, 0.0));
2760 const std::vector<double> s2 = fluid_dae_sigma_from(Sigma, terms, closable);
2761 for (std::size_t i = 0; i < s2.size(); ++i) cl.sigma2[i] = s2[i];
2762 }
2763 FluidTranPoint pt;
2764 pt.t = grid[j];
2766 fluid_dae_metrics(terms, x, cl, pt.QN, pt.UN, R, pt.TN);
2767 detail::fluid_snap_all(pt.QN, pt.UN, R, pt.TN);
2768 if (withcov) {
2769 // Summing a whole block is exactly the covariance of the per-phase
2770 // sums, so QVar is the diagonal of QCov and the two are filled in one
2771 // pass rather than aggregated twice.
2772 pt.QVar = Matrix<double>(M, K, 0.0);
2773 pt.QCov = Matrix<double>(M * K, M * K, 0.0);
2774 for (std::size_t i = 0; i < M; ++i)
2775 for (std::size_t k = 0; k < K; ++k) {
2776 const std::vector<std::size_t>& bi = terms.class_block[i][k];
2777 for (std::size_t j = 0; j < M; ++j)
2778 for (std::size_t l = 0; l < K; ++l) {
2779 const std::vector<std::size_t>& bj = terms.class_block[j][l];
2780 double acc = 0.0;
2781 for (std::size_t a = 0; a < bi.size(); ++a)
2782 for (std::size_t b = 0; b < bj.size(); ++b)
2783 acc += Sigma(bi[a], bj[b]);
2784 pt.QCov(k * M + i, l * M + j) = acc;
2785 }
2786 pt.QVar(i, k) = std::max(0.0, pt.QCov(k * M + i, k * M + i));
2787 }
2788 }
2789 out.push_back(pt);
2790 }
2791 return out;
2792}
2793
2794} // namespace fluid
2795} // namespace line
2796
2797#endif // LINE_SOLVERS_FLUID_FLUID_DAE_H
InputError(const std::string &what)
Definition error.h:39
std::size_t size() const
Definition matrix.h:91
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
bool empty() const
Definition matrix.h:92
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
Raised when the moment closure cannot serve this model: the linearization at the fixed point is not h...
FluidNonHyperbolicError(const std::string &what)
A network plus its refreshed NetworkStruct.
The exception types the port throws.
The second-order fluid methods: fluid_moment_terms.m, fluid_lyapunov.m, fluid_drift_jacobian....
The one exception the fluid fallback ladder catches.
Least squares for a rectangular system, exact-capable.
LU factorization with partial pivoting, templated on the number type.
Dense matrix and non-owning view.
std::vector< double > fluid_dae_hold_multipliers(const FluidMomentTerms &terms, const FluidDaeGates &gates, const FluidDaeStaging &stg, const FluidDaeConstraints &con, const std::vector< std::size_t > &active, const std::vector< double > &x, const std::vector< double > &sg, const FluidClosure &cl, const std::vector< double > &m0, bool &ok)
The multipliers that hold the active caps at this state, by small Newton.
Definition fluid_dae.h:1753
double fluid_dae_reach(const std::vector< double > &row, const std::vector< std::size_t > &coord_class, const std::vector< double > &njobs, std::size_t K)
The largest row x the population can produce, ignoring the coupling.
Definition fluid_dae.h:137
Matrix< double > fluid_dae_clamp_tangent(const FluidDaeConstraints &con, const std::vector< std::size_t > &active, const FluidMomentTerms &t)
Orthogonal projector onto the subspace the CLAMPING caps leave free: I - R'(RR')^-1 R over the covari...
Definition fluid_dae.h:976
Matrix< double > fluid_drift_jacobian(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
Port of fluid_drift_jacobian.m: the analytic Jacobian of the fluid drift.
FluidSolution solver_fluid_dae(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, const FluidDaeOptions &dopt_in=FluidDaeOptions())
solver_fluid_dae.m: the min-normal closure solved as one system.
Definition fluid_dae.h:2153
FluidDaeConstraints fluid_dae_constraints(const qn::NetworkStruct< T > &sn, const FluidMomentTerms &terms)
Definition fluid_dae.h:155
std::vector< std::vector< double > > fluid_dae_integrate_hybrid(const FluidMomentTerms &terms, const FluidDaeConservation &cons, const FluidDaeConstraints &con, const FluidDaeGates &gates, const FluidDaeStaging &stg, const FluidClosure &closure, const std::vector< double > &x0In, const std::vector< double > &grid, double tol, std::vector< FluidDaeSwitch > *switches=nullptr)
The transient UNDER CAPS: one index-1 DAE per segment, restarted at every located crossing.
Definition fluid_dae.h:1836
std::vector< std::size_t > fluid_dae_closable(const FluidMomentTerms &t)
Stations whose variance enters the drift.
Definition fluid_dae.h:920
FluidDaeOptions fluid_dae_options(const FluidOptions &opt, const FluidDaeOptions &dopt)
The controls the DAE route actually reads: the struct a caller pinned, with whatever options....
Definition fluid_dae.h:903
std::vector< double > fluid_moment_drift(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The drift F(x) = D r(x) under a closure: terms.driftFcn.
std::vector< double > fluid_dae_sigma_from(const Matrix< double > &Sigma, const FluidMomentTerms &t, const std::vector< std::size_t > &cidx)
Project a state-level covariance onto the per-station variances the drift reads.
Definition fluid_dae.h:933
std::vector< double > fluid_moment_rates(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The event rates r(x) under a closure: terms.ratesFcn.
FluidDaeLegs fluid_dae_legs(const FluidMomentTerms &t, const FluidDaeGates &gates, const FluidDaeStaging &stg, const FluidDaeConstraints &con, const std::vector< std::size_t > &active, const std::vector< double > &sg, const std::vector< double > &r, const std::vector< double > &mult, bool staged_flow)
Shared by the steady-state residual and the transient right-hand side so that the two solve the SAME ...
Definition fluid_dae.h:699
FluidMomentTerms fluid_moment_terms(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
FluidDaeGates fluid_dae_gates(const qn::NetworkStruct< T > &sn, const FluidMomentTerms &t, const FluidDaeConstraints &con)
Definition fluid_dae.h:467
void fluid_dae_project(std::vector< double > &u, std::size_t nfree)
Onto the feasible box: the state is free, the variances are not.
Definition fluid_dae.h:1131
void fluid_dae_extend(FluidDaeConstraints &con, const FluidDaeStaging &stg, const FluidMomentTerms &t)
Extend every cap to the staging coordinates that hold mass INSIDE it.
Definition fluid_dae.h:644
FluidDaeStaging fluid_dae_staging(const FluidMomentTerms &t, const FluidDaeConstraints &con)
Definition fluid_dae.h:554
std::vector< double > fluid_moment_factors(const FluidMomentTerms &t, const std::vector< double > &x, const FluidClosure &cl)
The rate factors g(x) under a closure: terms.factorFcn.
bool fluid_dae_residual(const FluidDaeSystem &sysd, const std::vector< double > &u, std::vector< double > &G, std::vector< double > *rates_out, bool rethrow=false, std::vector< double > *fire_out=nullptr)
The coupled algebraic system, stacked: drift, conservation, closure consistency.
Definition fluid_dae.h:1042
void fluid_dae_metrics(const FluidMomentTerms &terms, const std::vector< double > &x, const FluidClosure &cl, Matrix< double > &QN, Matrix< double > &UN, Matrix< double > &RN, Matrix< double > &TN, const std::vector< double > *rates=nullptr)
The metrics of one state, read exactly as the steady-state table reads them.
Definition fluid_dae.h:2079
std::vector< std::vector< double > > fluid_dae_integrate(const FluidMomentTerms &terms, const FluidDaeConservation &cons, const FluidClosure &closure, const std::vector< double > &x0, const std::vector< double > &grid, double tol, bool withcov=false, const std::vector< std::size_t > &closable=std::vector< std::size_t >(), const Matrix< double > &init_sigma=Matrix< double >(0, 0, 0.0))
Integrate the closure as an index-1 DAE, with RODAS, and report the state at every point of grid.
Definition fluid_dae.h:1637
std::vector< FluidTranPoint > solver_fluid_dae_transient(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, double t_end, std::size_t points=101, const std::vector< double > &out_grid=std::vector< double >(), const FluidDaeOptions &dopt_in=FluidDaeOptions())
@@SolverFLD/getTranAvg for the DAE route: the metrics ALONG the trajectory.
Definition fluid_dae.h:2611
bool fluid_coord_eliminated(const FluidMomentTerms &t, std::size_t s)
True when the immediate reduction folded coordinate s away, so the reduced drift holds no mass there ...
Matrix< double > fluid_moment_lyapunov(const FluidMomentTerms &t, const Matrix< double > &A, const std::vector< double > &r, const Matrix< double > *clampT=nullptr)
local_lyapunov of solver_fluid_moments.m: the covariance on the coordinates that carry a real populat...
FluidDaeConservation fluid_dae_conservation(const qn::NetworkStruct< T > &sn, const FluidMomentTerms &terms, const FluidDaeStaging &stg)
The conserved chains as equations.
Definition fluid_dae.h:804
FluidDaeNewtonInfo fluid_dae_newton(const FluidDaeSystem &sysd, std::vector< double > &u, double tol, std::size_t maxit)
Damped PROJECTED Newton with a finite-difference Jacobian and an Armijo backtrack on the residual nor...
Definition fluid_dae.h:1159
Matrix< double > fluid_dae_lift_qcov(const Matrix< double > &C0sc, const std::vector< double > &x0, const FluidMomentTerms &terms)
Lift a (station, class) covariance onto the closing-state phase layout: the inverse of the aggregatio...
Definition fluid_dae.h:1566
DropStrategy
Blocking and loss rules, with the values of MATLAB DropStrategy.
Definition lang_types.h:426
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
LstsqResult< T > lstsq(const Matrix< T > &A, const std::vector< T > &b, const T &tol)
Least-squares solution of A x = b, minimum-norm when A is rank deficient.
Definition lstsq.h:152
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
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
std::vector< double > sigma2
per station; empty selects first order
Definition fluid_odes.h:124
Population conservation, one row per CLOSED chain, in state space.
Definition fluid_dae.h:791
std::vector< double > N
chain populations
Definition fluid_dae.h:793
Matrix< double > C
(nchain x nstate)
Definition fluid_dae.h:792
Finite capacity regions as linear admission constraints on the fluid state.
Definition fluid_dae.h:112
std::vector< bool > staged
true where the job waits in a room
Definition fluid_dae.h:119
std::vector< std::size_t > klass_row
which class, npos when several
Definition fluid_dae.h:118
std::vector< double > b
Definition fluid_dae.h:115
Matrix< double > As
(ncon x nstaging), see fluid_dae_extend
Definition fluid_dae.h:114
std::vector< std::string > label
Definition fluid_dae.h:120
std::vector< std::size_t > region
which region, npos for a station cap
Definition fluid_dae.h:116
Matrix< double > A
(ncon x nstate)
Definition fluid_dae.h:113
std::vector< std::vector< bool > > member
(nregions x nstate)
Definition fluid_dae.h:121
std::vector< std::size_t > station
which station, npos for a region cap
Definition fluid_dae.h:117
std::vector< std::size_t > coord_class
Definition fluid_dae.h:122
static std::size_t none()
Definition fluid_dae.h:125
Which events each cap throttles, and what happens to the mass it stops.
Definition fluid_dae.h:458
std::vector< std::vector< bool > > gate
(ncon x nevents)
Definition fluid_dae.h:459
Matrix< double > Dn
Definition fluid_dae.h:462
std::vector< std::vector< bool > > loss
Definition fluid_dae.h:461
Matrix< double > Dp
the jump matrix split in three
Definition fluid_dae.h:462
std::vector< std::vector< bool > > held
Definition fluid_dae.h:460
Matrix< double > DnExt
Definition fluid_dae.h:462
The two legs of every event under the active caps, and the waiting rooms.
Definition fluid_dae.h:678
std::vector< double > ds
the derivative of each room
Definition fluid_dae.h:681
std::vector< double > rup
the rate each event FIRES at
Definition fluid_dae.h:679
std::vector< double > drain
each room's total outflow
Definition fluid_dae.h:682
std::vector< double > rin
the rate mass LANDS at
Definition fluid_dae.h:680
What the Newton reports about where it stopped.
Definition fluid_dae.h:1136
Options that only the DAE route reads.
Definition fluid_dae.h:863
std::size_t maxcov
Largest covariance dimension integrated ALONGSIDE the mean on the transient.
Definition fluid_dae.h:882
std::size_t maxstate
The simultaneous solve carries one Lyapunov solve per residual evaluation and takes a finite-differen...
Definition fluid_dae.h:871
The waiting room outside a capped region, as fluid coordinates.
Definition fluid_dae.h:545
std::vector< std::size_t > adm_region
Definition fluid_dae.h:549
std::vector< std::size_t > adm_stage
Definition fluid_dae.h:549
std::vector< std::size_t > region
Definition fluid_dae.h:547
std::vector< std::size_t > klass
per staging coordinate
Definition fluid_dae.h:547
std::vector< bool > adm
per event: an admission?
Definition fluid_dae.h:548
std::vector< std::vector< bool > > gated_by
(ncon x n) which rows gate which room
Definition fluid_dae.h:550
Matrix< double > Dp
negative and positive parts of D
Definition fluid_dae.h:551
What a hybrid transient did, beside the trajectory.
Definition fluid_dae.h:1825
int kind
0 release, 1 activate, 2 a crossing the cap could not hold
Definition fluid_dae.h:1828
Everything the residual needs, gathered so the Newton can stay generic.
Definition fluid_dae.h:948
std::vector< std::size_t > active
binding capacity constraints
Definition fluid_dae.h:955
std::size_t nstaging() const
u = [x; staging; sigma2; mult]
Definition fluid_dae.h:968
const FluidDaeConservation * cons
Definition fluid_dae.h:950
const FluidDaeStaging * stg
Definition fluid_dae.h:952
const FluidMomentTerms * terms
Definition fluid_dae.h:949
Matrix< double > clampT
The tangent space of the caps that CLAMP, or an empty matrix.
Definition fluid_dae.h:966
const FluidDaeConstraints * con
Definition fluid_dae.h:951
const FluidDaeGates * gates
Definition fluid_dae.h:953
std::vector< std::size_t > cidx
closable stations
Definition fluid_dae.h:954
std::vector< std::vector< std::vector< std::size_t > > > class_block
state coordinates of each (station,class): Sigma is indexed by SERVICE PHASE, so reading a per-class ...
Matrix< double > Sigma
state-level covariance, on range(D)
Matrix< double > QStd
per station and class queue-length variance
std::vector< double > sigma2
per-station population variance
Port of fluid_moment_terms.m: the event representation of the fluid population process,...
std::vector< bool > ev_is_departure
the leading n_departures events
std::vector< double > S
servers, INF substituted, lld peak folded
std::vector< std::vector< std::vector< std::size_t > > > class_block
std::vector< std::size_t > cov_idx
coordinates carrying a real population
std::vector< bool > min_exact
stations whose occupancy cannot reach their server count, where min(n,c) is the identity and the clos...
std::vector< std::vector< std::size_t > > station_block
std::vector< std::size_t > ev_class
0-based, from the event's coordinate
Matrix< double > D
(nstate x nevents)
std::vector< std::size_t > ev_station
std::vector< lang::SchedStrategy > sched
per station
Definition fluid_odes.h:207
Controls, defaulting to SolverOptions('Fluid') in the reference.
FluidClosure closure
options.config.moment_sigma2 and options.config.moment_cov: the second moment the drift's non-linear ...
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 point of a transient trajectory: the metrics at time t.
Matrix< double > QCov
The same second moment as a FULL (M*K)-by-(M*K) covariance, indexed ir = r*M + i, empty wherever QVar...
Matrix< double > QVar
Per-(station,class) queue-length VARIANCE at this instant, empty where the method carries no second m...
static constexpr double Zero
Definition lang_types.h:762
FINITE CAPACITY REGIONS, MATLAB's refreshRegions output.
Matrix< T > lincon_A
optional linear constraint A n <= b
std::vector< std::vector< double > > cap
(nstations x nclasses+1), -1 = unbounded
std::vector< double > maxmem
per member station, -1 = unbounded
std::vector< DropStrategy > rule
per class
std::vector< T > size
per class; size is the memory footprint
std::vector< bool > members
membership, independent of the caps