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