LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
fluid_symodes.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_SYMODES_H
6#define LINE_SOLVERS_FLUID_FLUID_SYMODES_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * A symbolic description of the fluid ODE system: a port of
12 * `solver_fluid_symodes.m`, which is what `@@SolverFLD/exportODEs` renders.
13 *
14 * WHY THE SOLVER CANNOT SIMPLY BE ASKED. The numerical path evaluates the drift
15 * at a point; it never holds the drift as an object. Anything that wants to
16 * PRINT the system, differentiate it by hand, or hand it to another tool needs
17 * the coefficients and the shape of every term, and that is what this builds.
18 * It is deliberately a SECOND construction of the same dynamics: it mirrors the
19 * numerical code path term by term, so a divergence between the two is a real
20 * defect and not a rendering artefact.
21 *
22 * TWO FORMS, MATCHING THE TWO SOLVER PATHS.
23 *
24 * form W (methods default, matrix, pnorm)
25 * dx/dt = W' theta(x) + lambda
26 * theta_s(x) = x_s min(n_i, S_i)/n_i, or the p-norm smoothing of it, and
27 * theta_s = 0 at a Source. This is `solver_fluid_matrix` and is taken
28 * from the SAME assembly the integrator uses, so the two cannot drift.
29 *
30 * form J (methods closing, statedep, softmin)
31 * dx/dt = J r(x), r_e(x) = coeff_e * factor_e(x)
32 * with one event per (departure, phase change) and a factor whose shape
33 * is fixed by the scheduling strategy of the station that drives it.
34 *
35 * THE FACTOR TYPES, which are the whole content of the J form:
36 * lin x_v (INF, and unhandled policies)
37 * min x_v min(n_i, S_i)/n_i (PS, FCFS under closing)
38 * ext1 1 - sum of the other phases at a Source
39 * dpsmin x_v min(n_i, S_i)/ntilde_i, the weight w_ir folded into coeff
40 * dpspw piecewise: x_v below S_i, weighted above it
41 * fcfsw x_v min(n_i, S_i)/nhat_i, phase weight folded into coeff
42 * fcfsws the same with softmin in place of min
43 *
44 * WHAT IS REFUSED BY NAME: `tbi`, `diffusion` and `mfq` have no ODE system of
45 * this shape at all -- tbi partitions and re-solves, diffusion adds a noise
46 * term, mfq solves a queue analytically -- and `statedep`/`softmin` have no
47 * open-model branch, exactly as in the reference.
48 */
49
50#include <algorithm>
51#include <cmath>
52#include <cstddef>
53#include <cstdio>
54#include <limits>
55#include <map>
56#include <string>
57#include <utility>
58#include <vector>
59
63#include "line/util/error.h"
64#include "line/util/matrix.h"
65
66namespace line {
67namespace fluid {
68
69/** The state-dependent factor attached to one event's driving variable. */
70struct SymFactor {
71 std::string type; ///< lin, min, pnorm, ext1, dpsmin, dpspw, fcfsw, fcfsws
72 std::size_t station = 0; ///< 1-based
73 std::size_t cls = 0; ///< 1-based
74 std::vector<std::size_t> others; ///< ext1: the other phases, 1-based state indices
75};
76
77/** The symbolic system, in whichever of the two forms the method implies. */
79 std::string form; ///< "W" or "J"
80 std::string method; ///< resolved: default becomes matrix
81 std::vector<std::string> station_names, class_names, sched_names;
82 std::vector<lang::SchedStrategy> sched;
83 std::size_t nstates = 0;
84 std::vector<std::size_t> state_station, state_class, state_phase; ///< 1-based
85 std::vector<double> S; ///< servers per station, infinite already substituted
86 std::vector<double> x0;
87
88 // form W
90 std::vector<double> alambda;
91 std::vector<bool> is_source;
92 std::vector<bool> is_inf; ///< (n) state belongs to an INF station
93 std::string smoothing; ///< "min" or "pnorm"
94 std::vector<double> pstar;
95
96 // form J
98 std::vector<double> coeff;
99 std::vector<std::size_t> event_var; ///< 0-based state index driving the event
100 std::vector<SymFactor> factor;
101 std::size_t nevents = 0;
103 std::vector<double> fcfs_phase_w;
104 double alpha = 0.0; ///< softmin sharpness, set only for that method
105
106 // both forms: the linear invariants the drift conserves, sum_{s in inv_states[g]} x_s = inv_mass[g]
107 std::vector<std::vector<std::size_t>> inv_states; ///< 0-based states of each conserved group
108 std::vector<double> inv_mass; ///< the mass that group holds
109};
110
111namespace detail {
112
113/** The per-strategy factor of an event, and the coefficient folding it implies. */
114inline SymFactor sym_factor(const std::string& method, lang::SchedStrategy sched, std::size_t i,
115 std::size_t c, std::size_t ki, double& coeff, const FluidLayout& L,
116 const std::vector<double>& S, const Matrix<double>& dpsw,
117 const std::vector<double>& fcfs_w, std::size_t var) {
118 SymFactor f;
119 f.station = i + 1;
120 f.cls = c + 1;
121 if (method == "closing") {
122 switch (sched) {
123 case lang::SchedStrategy::INF: f.type = "lin"; break;
125 if (ki == 0) {
126 f.type = "ext1";
127 for (std::size_t k = 1; k < L.kic[i][c]; ++k)
128 f.others.push_back(L.qidx[i][c] + k + 1);
129 } else {
130 f.type = "lin";
131 }
132 break;
134 case lang::SchedStrategy::FCFS: f.type = "min"; break;
136 // the share w_ir*x/ntilde_i of the capacity min(n_i,S_i), as in
137 // fluid_rates_closing_factors: no additive seed, not the full S_i
138 f.type = "dpsmin";
139 coeff *= dpsw(i, c);
140 break;
141 default: f.type = "lin"; break; // no case in ode_rates_closing: rate stays x
142 }
143 return f;
144 }
145 // statedep and softmin
146 switch (sched) {
147 case lang::SchedStrategy::INF: f.type = "lin"; break;
148 case lang::SchedStrategy::PS: f.type = "min"; break;
150 f.type = (method == "softmin") ? "fcfsws" : "fcfsw";
151 coeff *= fcfs_w[var];
152 break;
153 case lang::SchedStrategy::DPS: f.type = "dpspw"; break;
154 default: f.type = "lin"; break;
155 }
156 return f;
157}
158
159/** True when `statedep`/`softmin` enumerate events out of this station at all. */
160inline bool sym_handled_sched(lang::SchedStrategy s) {
164}
165
166} // namespace detail
167
168namespace detail {
169
170/**
171 * The linear invariants of the exported drift: one per closed chain, and one per
172 * (Source, class) phase group.
173 *
174 * Port of `conservation_constraints` (Python, P238). The drift conserves each
175 * closed chain's population and the mass held at each Source, but f(x) = 0 cannot
176 * see either: alone it is solved by one point per population, a continuum that a
177 * computer-algebra engine returns as a free-parameter family or, on p-norm
178 * radicals, never finishes. These are the equations that pin the equilibrium down.
179 * A chain with an open class conserves nothing and is skipped. The Source mass is
180 * the exported x0 there, which is 0 in form W (the Source is out of the dynamics)
181 * and the unit job pool in form J when no initial state was supplied.
182 */
183template <class T>
184void sym_conservation(FluidSymSystem& sys, const qn::NetworkStruct<T>& sn) {
185 sys.inv_states.clear();
186 sys.inv_mass.clear();
187 const bool have_x0 = sys.x0.size() == sys.nstates;
188 for (std::size_t c = 0; c < sn.chains.size(); ++c) {
189 bool closed = true, any = false;
190 double pop = 0.0;
191 for (std::size_t r = 0; r < sn.chains[c].size(); ++r) {
192 if (!sn.chains[c][r]) continue;
193 any = true;
194 if (!std::isfinite(sn.classes[r].population)) {
195 closed = false;
196 break;
197 }
198 pop += sn.classes[r].population;
199 }
200 if (!any || !closed) continue;
201 std::vector<std::size_t> states;
202 for (std::size_t s = 0; s < sys.nstates; ++s) {
203 const std::size_t i = sys.state_station[s] - 1, r = sys.state_class[s] - 1;
204 if (sys.sched[i] != lang::SchedStrategy::EXT && r < sn.chains[c].size() &&
205 sn.chains[c][r])
206 states.push_back(s);
207 }
208 if (states.empty()) continue;
209 sys.inv_states.push_back(states);
210 sys.inv_mass.push_back(pop);
211 }
212 // (station, class) -> its Source states, in (station, class) order as the reference sorts them
213 std::map<std::pair<std::size_t, std::size_t>, std::vector<std::size_t>> groups;
214 for (std::size_t s = 0; s < sys.nstates; ++s)
215 if (sys.sched[sys.state_station[s] - 1] == lang::SchedStrategy::EXT)
216 groups[std::make_pair(sys.state_station[s], sys.state_class[s])].push_back(s);
217 for (std::map<std::pair<std::size_t, std::size_t>, std::vector<std::size_t>>::const_iterator
218 it = groups.begin();
219 it != groups.end(); ++it) {
220 double mass = 0.0;
221 if (have_x0)
222 for (std::size_t k = 0; k < it->second.size(); ++k) mass += sys.x0[it->second[k]];
223 else
224 mass = (sys.form == "J") ? 1.0 : 0.0;
225 sys.inv_states.push_back(it->second);
226 sys.inv_mass.push_back(mass);
227 }
228}
229
230} // namespace detail
231
232/**
233 * Build the symbolic system of `sn` under `opt.method`.
234 *
235 * `init_sol` is the initial condition to report; empty takes the solver's
236 * default, as `build_x0` in the reference does through `solver_fluid_initsol`.
237 */
238template <class T>
239FluidSymSystem fluid_symodes(const qn::NetworkStruct<T>& sn, const std::string& method_in,
240 double pstar, const std::vector<double>& init_sol) {
241 const std::size_t M = sn.nstations, K = sn.nclasses;
242 std::string method = method_in;
243 if (method.compare(0, 4, "fld.") == 0) method = method.substr(4);
244
245 FluidSymSystem sys;
246 if (method == "default" || method == "matrix" || method == "pnorm") {
247 sys.form = "W";
248 sys.method = (method == "default") ? "matrix" : method;
249 } else if (method == "closing" || method == "statedep" || method == "softmin") {
250 sys.form = "J";
251 sys.method = method;
252 } else {
253 throw UnsupportedError(ErrorKind{"Method"},
254 "fluid_symodes: the symbolic ODE export is unsupported for method '" + method_in +
255 "'; supported are default, matrix, pnorm, closing, statedep and softmin");
256 }
257
258 sys.station_names.resize(M);
259 sys.sched_names.resize(M);
260 sys.sched.resize(M);
261 for (std::size_t i = 0; i < M; ++i) {
262 sys.station_names[i] = sn.stations[i].name;
263 sys.sched[i] = sn.stations[i].sched;
264 sys.sched_names[i] = lang::sched_to_text(sn.stations[i].sched);
265 }
266 sys.class_names.resize(K);
267 for (std::size_t r = 0; r < K; ++r) sys.class_names[r] = sn.classes[r].name;
268
269 double closed_pop = 0.0;
270 for (std::size_t r = 0; r < K; ++r)
271 if (std::isfinite(sn.classes[r].population)) closed_pop += sn.classes[r].population;
272 sys.S.assign(M, 1.0);
273 for (std::size_t i = 0; i < M; ++i) {
274 const double c = sn.stations[i].nservers;
275 sys.S[i] = std::isfinite(c) ? c : closed_pop;
276 }
277
278 const FluidLayout L = fluid_layout(sn);
279
280 if (sys.form == "W") {
281 // Taken from the assembly the integrator itself uses, so the exported
282 // system is the system that runs and not a second opinion about it.
283 const FluidMatrixSystem ms = fluid_matrix_system(sn, init_sol, pstar);
284 sys.nstates = ms.nstates;
285 sys.W = ms.W;
286 sys.alambda = ms.alambda;
287 sys.is_source = ms.is_source;
288 sys.is_inf = ms.is_inf;
289 sys.x0 = ms.x0;
290 sys.smoothing = (pstar > 0.0) ? "pnorm" : "min";
291 sys.pstar.assign(M, pstar);
292 // Walk the same block enumeration to recover (station, class, phase).
293 sys.state_station.reserve(ms.nstates);
294 sys.state_class.reserve(ms.nstates);
295 sys.state_phase.reserve(ms.nstates);
296 for (std::size_t i = 0; i < M; ++i)
297 for (std::size_t r = 0; r < K; ++r) {
298 const std::size_t p = L.kic[i][r];
299 if (p == 0) continue; // the disabled placeholder is dropped
300 for (std::size_t k = 0; k < p; ++k) {
301 sys.state_station.push_back(i + 1);
302 sys.state_class.push_back(r + 1);
303 sys.state_phase.push_back(k + 1);
304 }
305 }
306 if (sys.state_station.size() != sys.nstates)
307 throw NumericError(ErrorKind{"Degenerate"}, "fluid_symodes: the W-form state metadata does not match the drift");
308 detail::sym_conservation(sys, sn);
309 return sys;
310 }
311
312 // ---- form J -----------------------------------------------------------
313 const bool sd = (method == "statedep" || method == "softmin");
314 if (sd)
315 for (std::size_t i = 0; i < M; ++i)
316 if (sn.stations[i].sched == lang::SchedStrategy::EXT)
317 throw UnsupportedError(ErrorKind{"OpenClass"}, "fluid_symodes: the '" + method +
318 "' method does not support open models, so their ODE "
319 "system cannot be exported; use 'matrix' or 'closing'");
320
321 sys.nstates = L.nstates;
322 sys.state_station.assign(L.nstates, 0);
323 sys.state_class.assign(L.nstates, 0);
324 sys.state_phase.assign(L.nstates, 0);
325 for (std::size_t i = 0; i < M; ++i)
326 for (std::size_t c = 0; c < K; ++c)
327 for (std::size_t k = 0; k < L.kic[i][c]; ++k) {
328 sys.state_station[L.qidx[i][c] + k] = i + 1;
329 sys.state_class[L.qidx[i][c] + k] = c + 1;
330 sys.state_phase[L.qidx[i][c] + k] = k + 1;
331 }
332
333 sys.dpsw = Matrix<double>(M, K, 1.0);
334 for (std::size_t i = 0; i < M; ++i) {
335 if (sn.stations[i].sched != lang::SchedStrategy::DPS) continue;
336 double tot = 0.0;
337 for (std::size_t r = 0; r < K; ++r) {
338 const double w = (r < sn.stations[i].schedparam.size())
339 ? num_traits<T>::to_double(sn.stations[i].schedparam[r])
340 : 1.0;
341 sys.dpsw(i, r) = w;
342 tot += w;
343 }
344 if (tot > 0.0)
345 for (std::size_t r = 0; r < K; ++r) sys.dpsw(i, r) /= tot;
346 }
347
348 sys.fcfs_phase_w.assign(L.nstates, 0.0);
349 if (sd)
350 for (std::size_t i = 0; i < M; ++i) {
351 if (sn.stations[i].sched != lang::SchedStrategy::FCFS) continue;
352 for (std::size_t c = 0; c < K; ++c) {
353 if (!L.enabled[i][c]) continue;
354 for (std::size_t k = 0; k < L.kic[i][c]; ++k)
355 sys.fcfs_phase_w[L.qidx[i][c] + k] =
356 -1.0 / num_traits<T>::to_double(sn.service[i][c].D0(k, k));
357 }
358 }
359
360 // The service processes and the station-space routing, as the drift reads them.
361 std::vector<std::vector<std::vector<double>>> mu(M, std::vector<std::vector<double>>(K));
362 std::vector<std::vector<std::vector<double>>> phi(M, std::vector<std::vector<double>>(K));
363 std::vector<std::vector<std::vector<double>>> pie(M, std::vector<std::vector<double>>(K));
364 for (std::size_t i = 0; i < M; ++i)
365 for (std::size_t r = 0; r < K; ++r) {
366 if (!L.enabled[i][r]) {
367 pie[i][r] = std::vector<double>{1.0};
368 continue;
369 }
370 detail::fluid_mu_phi(sn.service[i][r], mu[i][r], phi[i][r]);
371 pie[i][r] = detail::fluid_pie(sn.service[i][r]);
372 }
373 const std::size_t NS = sn.nof_stateful();
374 const bool have_rt = sn.rt.rows() == NS * K;
375 std::vector<std::size_t> sf(M, 0);
376 for (std::size_t i = 0; i < M; ++i) sf[i] = sn.stateful_of_station(i + 1) - 1;
377 const auto route = [&](std::size_t i, std::size_t c, std::size_t j, std::size_t l) -> double {
378 if (!have_rt) return 0.0;
379 return num_traits<T>::to_double(sn.rt(sf[i] * K + c, sf[j] * K + l));
380 };
381
382 std::vector<std::vector<double>> cols; // the J columns, assembled then transposed
383 // ---- departures -------------------------------------------------------
384 for (std::size_t i = 0; i < M; ++i)
385 for (std::size_t c = 0; c < K; ++c) {
386 if (!L.enabled[i][c]) continue;
387 for (std::size_t j = 0; j < M; ++j)
388 for (std::size_t l = 0; l < K; ++l) {
389 if (!(route(i, c, j, l) > 0.0)) continue;
390 for (std::size_t ki = 0; ki < L.kic[i][c]; ++ki)
391 for (std::size_t kj = 0; kj < L.kic[j][l]; ++kj) {
392 if (sd) {
393 // An INF self-loop has no event, and a station
394 // whose policy has no branch contributes none.
395 if (sn.stations[i].sched == lang::SchedStrategy::INF && j == i)
396 continue;
397 if (!detail::sym_handled_sched(sn.stations[i].sched)) continue;
398 }
399 const double pj = kj < pie[j][l].size() ? pie[j][l][kj] : 0.0;
400 double base = phi[i][c][ki] * mu[i][c][ki] * route(i, c, j, l) * pj;
401 if (!(base > 0.0)) continue;
402 std::vector<double> col(L.nstates, 0.0);
403 col[L.qidx[i][c] + ki] -= 1.0;
404 col[L.qidx[j][l] + kj] += 1.0;
405 const std::size_t var = L.qidx[i][c] + ki;
406 const SymFactor f =
407 detail::sym_factor(method, sn.stations[i].sched, i, c, ki, base, L,
408 sys.S, sys.dpsw, sys.fcfs_phase_w, var);
409 cols.push_back(col);
410 sys.coeff.push_back(base);
411 sys.event_var.push_back(var);
412 sys.factor.push_back(f);
413 }
414 }
415 }
416 // ---- phase changes ----------------------------------------------------
417 for (std::size_t i = 0; i < M; ++i)
418 for (std::size_t c = 0; c < K; ++c) {
419 if (!L.enabled[i][c]) continue;
420 if (sd && !detail::sym_handled_sched(sn.stations[i].sched)) continue;
421 for (std::size_t ki = 0; ki + 1 < L.kic[i][c]; ++ki)
422 for (std::size_t kp = 0; kp < L.kic[i][c]; ++kp) {
423 if (kp == ki) continue;
424 double base = num_traits<T>::to_double(sn.service[i][c].D0(ki, kp));
425 if (!(base > 0.0)) continue;
426 std::vector<double> col(L.nstates, 0.0);
427 col[L.qidx[i][c] + ki] = -1.0;
428 col[L.qidx[i][c] + kp] = 1.0;
429 const std::size_t var = L.qidx[i][c] + ki;
430 const SymFactor f =
431 detail::sym_factor(method, sn.stations[i].sched, i, c, ki, base, L, sys.S,
432 sys.dpsw, sys.fcfs_phase_w, var);
433 cols.push_back(col);
434 sys.coeff.push_back(base);
435 sys.event_var.push_back(var);
436 sys.factor.push_back(f);
437 }
438 }
439
440 sys.nevents = cols.size();
441 sys.J = Matrix<double>(L.nstates, sys.nevents, 0.0);
442 for (std::size_t e = 0; e < sys.nevents; ++e)
443 for (std::size_t s = 0; s < L.nstates; ++s) sys.J(s, e) = cols[e][s];
444 if (method == "softmin") sys.alpha = 20.0;
445 sys.x0 = init_sol; // the caller supplies the default; see fluid_export_odes
446 detail::sym_conservation(sys, sn);
447 return sys;
448}
449
450/** The drift as expression strings, one per state variable, plus their names. */
452 std::vector<std::string> rhs;
453 std::vector<std::string> vars;
454};
455
456namespace detail {
457
458/** A 1-based index as text, for the `x1 ... xn` variable names. */
459inline std::string sym_state_index(std::size_t v) {
460 char buf[32];
461 std::snprintf(buf, sizeof(buf), "%llu", static_cast<unsigned long long>(v));
462 return std::string(buf);
463}
464
465/** `num2char`: decimal text a computer-algebra backend reads as exact. */
466inline std::string sym_plain_num(double v) {
467 char buf[64];
468 if (v == std::floor(v) && std::fabs(v) < 1e15) {
469 std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
470 return std::string(buf);
471 }
472 std::snprintf(buf, sizeof(buf), "%.17g", v);
473 return std::string(buf);
474}
475
476/** `joinSum`: the sum of PARTS, with OFFSET added only when it is not zero. */
477inline std::string sym_join_sum(const std::vector<std::string>& parts, const std::string& offset) {
478 if (parts.empty()) return "(" + offset + ")";
479 std::string body;
480 for (std::size_t i = 0; i < parts.size(); ++i) {
481 if (i) body += " + ";
482 body += parts[i];
483 }
484 return (offset == "0") ? "(" + body + ")" : "(" + offset + " + " + body + ")";
485}
486
487/** `stationSum`: the total fluid mass at station i, plus its consumer's offset. */
488inline std::string sym_station_sum(const FluidSymSystem& sys, std::size_t station1,
489 const std::vector<std::string>& vars, const std::string& offset) {
490 std::vector<std::string> parts;
491 for (std::size_t s = 0; s < sys.nstates; ++s)
492 if (sys.state_station[s] == station1) parts.push_back(vars[s]);
493 return sym_join_sum(parts, offset);
494}
495
496
497/** `phaseWeightedStationSum`: sum_u w_u x_u with w_u the mean phase time. */
498inline std::string sym_phase_weighted_station_sum(const FluidSymSystem& sys, std::size_t station1,
499 const std::vector<std::string>& vars,
500 const std::string& offset) {
501 std::vector<std::string> parts;
502 for (std::size_t s = 0; s < sys.nstates; ++s) {
503 if (sys.state_station[s] != station1) continue;
504 const double w = sys.fcfs_phase_w[s];
505 if (w == 0.0) continue;
506 parts.push_back("(" + sym_plain_num(w) + ")*" + vars[s]);
507 }
508 return sym_join_sum(parts, offset);
509}
510
511/**
512 * `softminExpr`: the smooth minimum in its weighted-average form,
513 * (x e^{-a x} + y e^{-a y}) / (e^{-a x} + e^{-a y}).
514 *
515 * `softmin` itself rewrites this as lo + gap*w/(1+w) only to keep the exponent
516 * argument non-positive, an overflow guard that is meaningless symbolically and
517 * would introduce the min/max branch this export exists to avoid.
518 */
519inline std::string sym_softmin_expr(const std::string& x, const std::string& y, double alpha) {
520 const std::string a = sym_plain_num(alpha);
521 return "((" + x + ")*exp(-(" + a + ")*(" + x + ")) + (" + y + ")*exp(-(" + a + ")*(" + y +
522 ")))/(exp(-(" + a + ")*(" + x + ")) + exp(-(" + a + ")*(" + y + ")))";
523}
524
525} // namespace detail
526
527/**
528 * The invariants of `sys` as expressions g(x) = 0 over the drift's `x1 ... xn`.
529 *
530 * Each reads `xa + xb + ... - m`, one per entry of `inv_states`, and is what the
531 * `/api/v1/fluid/odes` request carries as `constraints` when equilibria are asked
532 * for (Python `conservation_constraints`).
533 */
534inline std::vector<std::string> fluid_conservation_constraints(const FluidSymSystem& sys) {
535 std::vector<std::string> out;
536 for (std::size_t g = 0; g < sys.inv_states.size(); ++g) {
537 std::string e;
538 for (std::size_t k = 0; k < sys.inv_states[g].size(); ++k) {
539 if (k > 0) e += " + ";
540 e += "x" + detail::sym_state_index(sys.inv_states[g][k] + 1);
541 }
542 out.push_back(e + " - " + detail::sym_plain_num(sys.inv_mass[g]));
543 }
544 return out;
545}
546
547/**
548 * Port of `@@SolverFLD/getSymbolicDrift`: the right-hand side of the mean-field ODE
549 * system as expression strings, one per state variable.
550 *
551 * This is the input a computer-algebra backend needs to produce a Jacobian or an
552 * equilibrium, and it is the same system `fluid_symodes` describes and
553 * `fluid_export_odes` typesets, written out variable by variable instead of in
554 * matrix form.
555 *
556 * ONLY SMOOTH DRIFTS ARE EXPORTED. The default, matrix, closing and statedep
557 * methods scale rates by min(n_i, S_i), which is not differentiable at n_i = S_i,
558 * so their Jacobian does not exist there; emitting a one-sided derivative would be
559 * a silent lie exactly at the regime switch that matters. The p-norm smoothing
560 * (`pstar`, methods matrix or pnorm) and the `softmin` method are smooth
561 * everywhere, and everything else is refused BY THE FACTOR TYPE that carries the
562 * kink rather than by method name, so a new non-smooth branch cannot slip through.
563 */
566 out.vars.resize(sys.nstates);
567 for (std::size_t s = 0; s < sys.nstates; ++s)
568 out.vars[s] = "x" + detail::sym_state_index(s + 1);
569 out.rhs.assign(sys.nstates, "0");
570 // `eps0`: the FineTol the softmin denominator carries, as the reference writes
571 // it into the expression rather than folding it into a number.
572 const std::string eps0 = detail::sym_plain_num(lang::GlobalConstants::FineTol);
573
574 if (sys.form == "W") {
575 // dx/dt = W' theta(x) + Alambda, with the p-norm smoothing:
576 // theta_s = x_s / (1 + (n_i/S_i)^p_i)^(1/p_i), theta_s = 0 at a Source.
577 if (sys.smoothing != "pnorm")
578 throw UnsupportedError(ErrorKind{"Symbolic"},
579 "fluid_symbolic_drift: the drift of this method scales rates by min(n_i, S_i), "
580 "which is not differentiable at n_i = S_i, so it has no Jacobian there. Set pstar "
581 "to use the p-norm smoothing, or use the 'softmin' method");
582 std::vector<std::string> theta(sys.nstates, "0");
583 for (std::size_t s = 0; s < sys.nstates; ++s) {
584 if (sys.is_source[s]) continue;
585 const std::size_t i = sys.state_station[s];
586 const double S = sys.S[i - 1];
587 const double p = sys.pstar[i - 1];
588 // An INF station has no min() to smooth, so its theta stays linear;
589 // S holds the population there, not infinity
590 if (s < sys.is_inf.size() && sys.is_inf[s]) {
591 theta[s] = out.vars[s];
592 continue;
593 }
594 if (S <= 0.0 || p <= 0.0) {
595 theta[s] = out.vars[s];
596 continue;
597 }
598 const std::string ni = detail::sym_station_sum(sys, i, out.vars, eps0);
599 theta[s] = out.vars[s] + "/(1 + (" + ni + "/" + detail::sym_plain_num(S) + ")^" +
600 detail::sym_plain_num(p) + ")^(1/" + detail::sym_plain_num(p) + ")";
601 }
602 for (std::size_t s = 0; s < sys.nstates; ++s) {
603 std::vector<std::string> terms;
604 for (std::size_t t = 0; t < sys.nstates; ++t) {
605 const double w = sys.W(t, s);
606 if (w == 0.0 || theta[t] == "0") continue;
607 terms.push_back("(" + detail::sym_plain_num(w) + ")*(" + theta[t] + ")");
608 }
609 if (sys.alambda[s] != 0.0) terms.push_back(detail::sym_plain_num(sys.alambda[s]));
610 if (terms.empty()) continue;
611 std::string body;
612 for (std::size_t i = 0; i < terms.size(); ++i) {
613 if (i) body += " + ";
614 body += terms[i];
615 }
616 out.rhs[s] = body;
617 }
618 return out;
619 }
620 if (sys.form != "J")
621 throw UnsupportedError(ErrorKind{"Symbolic"}, "fluid_symbolic_drift: unsupported ODE form '" + sys.form + "'");
622
623 // dx/dt = J r(x), with r_e = coeff(e) * factor_e(x). Only the smooth factor
624 // types are exportable: 'min' (PS/FCFS under closing and statedep), 'fcfsw'
625 // (statedep FCFS), 'dpsmin' (closing DPS) and 'dpspw' (piecewise DPS) all
626 // carry a min or a branch.
627 std::vector<std::string> rate(sys.nevents, "0");
628 for (std::size_t e = 0; e < sys.nevents; ++e) {
629 const SymFactor& f = sys.factor[e];
630 const std::string& v = out.vars[sys.event_var[e]];
631 std::string factor;
632 if (f.type == "lin") {
633 factor = v;
634 } else if (f.type == "ext1") {
635 if (f.others.empty()) {
636 factor = "1";
637 } else {
638 std::string body;
639 for (std::size_t k = 0; k < f.others.size(); ++k) {
640 if (k) body += " + ";
641 body += out.vars[f.others[k] - 1];
642 }
643 factor = "(1 - (" + body + "))";
644 }
645 } else if (f.type == "fcfsws") {
646 const std::string ni = detail::sym_station_sum(sys, f.station, out.vars, "0");
647 const std::string nhat =
648 detail::sym_phase_weighted_station_sum(sys, f.station, out.vars, eps0);
649 factor = v + "*(" +
650 detail::sym_softmin_expr(ni, detail::sym_plain_num(sys.S[f.station - 1]),
651 sys.alpha) +
652 ")/(" + nhat + ")";
653 } else {
654 throw UnsupportedError(ErrorKind{"Symbolic"},
655 "fluid_symbolic_drift: event " + detail::sym_state_index(e + 1) +
656 " scales its rate by the non-smooth factor '" + f.type +
657 "', which has no derivative where the regime switches, so the system has no "
658 "Jacobian. Use the 'softmin' method, or the p-norm smoothing of the 'matrix' "
659 "method");
660 }
661 rate[e] = "(" + detail::sym_plain_num(sys.coeff[e]) + ")*(" + factor + ")";
662 }
663 for (std::size_t s = 0; s < sys.nstates; ++s) {
664 std::vector<std::string> terms;
665 for (std::size_t e = 0; e < sys.nevents; ++e) {
666 const double j = sys.J(s, e);
667 if (j == 0.0) continue;
668 terms.push_back("(" + detail::sym_plain_num(j) + ")*(" + rate[e] + ")");
669 }
670 if (terms.empty()) continue;
671 std::string body;
672 for (std::size_t i = 0; i < terms.size(); ++i) {
673 if (i) body += " + ";
674 body += terms[i];
675 }
676 out.rhs[s] = body;
677 }
678 return out;
679}
680
681/** What `@@SolverFLD/getJacobian` returns: d f_i / d x_j as expression strings. */
683 std::vector<std::string> vars; ///< the state variable names
684 std::vector<std::string> rhs; ///< the drift, one per variable
685 std::vector<std::vector<std::string> > J; ///< J[i][j] = d f_i / d x_j
686};
687
688namespace detail {
689
690/** d(n_i)/d(x_m): one when state m belongs to station i, zero otherwise. */
691inline bool sym_in_station(const FluidSymSystem& sys, std::size_t station1, std::size_t m) {
692 return sys.state_station[m] == station1;
693}
694
695/**
696 * d/dx_m of the softmin of (n_i, S), by the chain rule through n_i.
697 *
698 * With a = n_i, b = S constant and D = e^{-alpha a} + e^{-alpha b},
699 *
700 * sm(a) = (a e^{-alpha a} + b e^{-alpha b}) / D
701 * sm'(a) = e^{-alpha a} (1 - alpha a + alpha sm(a)) / D
702 *
703 * which is written out rather than left as a quotient of four exponentials
704 * because the form above has one exponential per state and the naive one has
705 * four, and a backend that simplifies neither would carry the difference into
706 * every entry of the Jacobian.
707 */
708inline std::string sym_softmin_deriv(const std::string& a, const std::string& b, double alpha) {
709 const std::string al = sym_plain_num(alpha);
710 const std::string ea = "exp(-(" + al + ")*(" + a + "))";
711 const std::string eb = "exp(-(" + al + ")*(" + b + "))";
712 const std::string D = "(" + ea + " + " + eb + ")";
713 const std::string sm = sym_softmin_expr(a, b, alpha);
714 return "(" + ea + "*(1 - (" + al + ")*(" + a + ") + (" + al + ")*(" + sm + ")))/" + D;
715}
716
717/** `(u)*(v)`, dropping the term when either side is the literal zero. */
718inline std::string sym_mul(const std::string& u, const std::string& v) {
719 if (u == "0" || v == "0") return "0";
720 if (u == "1") return v;
721 if (v == "1") return u;
722 return "(" + u + ")*(" + v + ")";
723}
724
725/** Sum of the non-zero terms, or the literal zero. */
726inline std::string sym_sum(const std::vector<std::string>& parts) {
727 std::string body;
728 for (std::size_t i = 0; i < parts.size(); ++i) {
729 if (parts[i] == "0") continue;
730 if (!body.empty()) body += " + ";
731 body += parts[i];
732 }
733 return body.empty() ? std::string("0") : body;
734}
735
736} // namespace detail
737
738/**
739 * Port of `@@SolverFLD/getJacobian`: d f_i / d x_j of the mean-field drift, as
740 * expression strings.
741 *
742 * IT IS DIFFERENTIATED HERE, NOT SENT AWAY. The reference hands the drift
743 * strings to the line-sage-rest backend over HTTP and returns what SAGE gives
744 * back. This port has no symbolic engine and no HTTP client -- the CTMC port
745 * makes the same refusal for its own symbolic getters -- but the drift is not an
746 * opaque expression here: `FluidSymSystem` carries it STRUCTURALLY, as a jump
747 * matrix times per-event rates whose state dependence is one of a closed set of
748 * typed factors. Each of those factors has a derivative that can be written down
749 * once, so the Jacobian is produced exactly, by the chain rule over the
750 * structure, rather than by parsing the strings the drift printer emitted.
751 *
752 * The smoothness gate is the SAME one `fluid_symbolic_drift` applies, and for
753 * the same reason: `min(n_i, S_i)` has no derivative at n_i = S_i, and a
754 * one-sided value there would be a silent lie exactly at the regime switch. The
755 * refusal is by FACTOR TYPE, so a new non-smooth branch cannot slip through.
756 *
757 * `equilibria`, the reference's fourth output, is NOT produced here: solving
758 * f(x) = 0 in closed form needs a computer-algebra solver, which differentiating
759 * does not. It lives in `fluid_jacobian` (fluid_jacobian.h), which resolves the
760 * same line-sage-rest backend the reference uses and asks it; this function stays
761 * backend-free so that the Jacobian alone never needs one.
762 */
766 out.vars = d.vars;
767 out.rhs = d.rhs;
768 const std::size_t n = sys.nstates;
769 out.J.assign(n, std::vector<std::string>(n, "0"));
770 const std::string eps0 = detail::sym_plain_num(lang::GlobalConstants::FineTol);
771
772 if (sys.form == "W") {
773 // theta_s = x_s / Q_i, Q_i = (1 + (n_i/S_i)^p_i)^(1/p_i)
774 // d theta_s / d x_m = [m == s]/Q_i - x_s Q_i' / Q_i^2
775 // Q_i' = (1 + (n_i/S_i)^p)^(1/p - 1) (n_i/S_i)^(p-1) / S_i, for m at i
776 std::vector<std::vector<std::string> > dtheta(n, std::vector<std::string>(n, "0"));
777 for (std::size_t s = 0; s < n; ++s) {
778 if (sys.is_source[s]) continue;
779 const std::size_t i = sys.state_station[s];
780 const double S = sys.S[i - 1], p = sys.pstar[i - 1];
781 if ((s < sys.is_inf.size() && sys.is_inf[s]) || S <= 0.0 || p <= 0.0) {
782 dtheta[s][s] = "1"; // theta_s = x_s
783 continue;
784 }
785 const std::string ni = detail::sym_station_sum(sys, i, out.vars, eps0);
786 const std::string Sn = detail::sym_plain_num(S), pn = detail::sym_plain_num(p);
787 const std::string base = "(1 + ((" + ni + ")/" + Sn + ")^" + pn + ")";
788 const std::string Q = base + "^(1/" + pn + ")";
789 const std::string dQ = base + "^(1/" + pn + " - 1)*((" + ni + ")/" + Sn + ")^(" + pn +
790 " - 1)/" + Sn;
791 for (std::size_t m = 0; m < n; ++m) {
792 std::vector<std::string> parts;
793 if (m == s) parts.push_back("1/(" + Q + ")");
794 if (detail::sym_in_station(sys, i, m))
795 parts.push_back("-(" + out.vars[s] + ")*(" + dQ + ")/(" + Q + ")^2");
796 dtheta[s][m] = detail::sym_sum(parts);
797 }
798 }
799 for (std::size_t s = 0; s < n; ++s)
800 for (std::size_t m = 0; m < n; ++m) {
801 std::vector<std::string> parts;
802 for (std::size_t t = 0; t < n; ++t) {
803 const double w = sys.W(t, s);
804 if (w == 0.0 || dtheta[t][m] == "0") continue;
805 parts.push_back(detail::sym_mul(detail::sym_plain_num(w), dtheta[t][m]));
806 }
807 out.J[s][m] = detail::sym_sum(parts);
808 }
809 return out;
810 }
811 if (sys.form != "J")
812 throw UnsupportedError(ErrorKind{"Symbolic"}, "fluid_symbolic_jacobian: unsupported ODE form '" + sys.form + "'");
813
814 // d rate_e / d x_m = coeff_e * d factor_e / d x_m, one row per factor type.
815 std::vector<std::vector<std::string> > drate(sys.nevents, std::vector<std::string>(n, "0"));
816 for (std::size_t e = 0; e < sys.nevents; ++e) {
817 const SymFactor& f = sys.factor[e];
818 const std::size_t v = sys.event_var[e];
819 const std::string cf = detail::sym_plain_num(sys.coeff[e]);
820 if (f.type == "lin") {
821 drate[e][v] = cf;
822 } else if (f.type == "ext1") {
823 for (std::size_t k = 0; k < f.others.size(); ++k)
824 drate[e][f.others[k] - 1] = "-(" + cf + ")";
825 } else if (f.type == "fcfsws") {
826 // factor = x_v * softmin(n_i, S) / nhat_i, with n_i and nhat_i both
827 // linear in the states of station i. Product and quotient rules:
828 // d/dx_m = [m == v] sm/nhat
829 // + x_v sm'(n_i) [m at i] / nhat
830 // - x_v sm w_m [m at i] / nhat^2
831 const std::string ni = detail::sym_station_sum(sys, f.station, out.vars, "0");
832 const std::string nhat =
833 detail::sym_phase_weighted_station_sum(sys, f.station, out.vars, eps0);
834 const std::string Sn = detail::sym_plain_num(sys.S[f.station - 1]);
835 const std::string sm = detail::sym_softmin_expr(ni, Sn, sys.alpha);
836 const std::string dsm = detail::sym_softmin_deriv(ni, Sn, sys.alpha);
837 for (std::size_t m = 0; m < n; ++m) {
838 std::vector<std::string> parts;
839 if (m == v) parts.push_back("(" + sm + ")/(" + nhat + ")");
840 if (detail::sym_in_station(sys, f.station, m)) {
841 parts.push_back("(" + out.vars[v] + ")*(" + dsm + ")/(" + nhat + ")");
842 const double w = sys.fcfs_phase_w[m];
843 if (w != 0.0)
844 parts.push_back("-(" + out.vars[v] + ")*(" + sm + ")*(" +
845 detail::sym_plain_num(w) + ")/(" + nhat + ")^2");
846 }
847 const std::string body = detail::sym_sum(parts);
848 drate[e][m] = body == "0" ? std::string("0") : detail::sym_mul(cf, body);
849 }
850 } else {
851 throw UnsupportedError(ErrorKind{"Symbolic"},
852 "fluid_symbolic_jacobian: event " + detail::sym_state_index(e + 1) +
853 " scales its rate by the non-smooth factor '" + f.type +
854 "', which has no derivative where the regime switches, so the system has no "
855 "Jacobian. Use the 'softmin' method, or the p-norm smoothing of the 'matrix' "
856 "method");
857 }
858 }
859
860 for (std::size_t s = 0; s < n; ++s)
861 for (std::size_t m = 0; m < n; ++m) {
862 std::vector<std::string> parts;
863 for (std::size_t e = 0; e < sys.nevents; ++e) {
864 const double j = sys.J(s, e);
865 if (j == 0.0 || drate[e][m] == "0") continue;
866 parts.push_back(detail::sym_mul(detail::sym_plain_num(j), drate[e][m]));
867 }
868 out.J[s][m] = detail::sym_sum(parts);
869 }
870 return out;
871}
872
873} // namespace fluid
874} // namespace line
875
876#endif // LINE_SOLVERS_FLUID_FLUID_SYMODES_H
NumericError(const std::string &what)
Definition error.h:112
UnsupportedError(const std::string &what)
Definition error.h:119
A network plus its refreshed NetworkStruct.
The exception types the port throws.
The matrix fluid method: a port of solver_fluid_matrix.m, the formulation of Ruuskanen,...
The fluid drift: a port of solver_fluid_odes.m and the ode_jumps_new / ode_rate_base / ode_rates_clos...
Dense matrix and non-owning view.
std::vector< std::string > fluid_conservation_constraints(const FluidSymSystem &sys)
The invariants of sys as expressions g(x) = 0 over the drift's x1 ... xn.
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
FluidMatrixSystem fluid_matrix_system(const qn::NetworkStruct< T > &sn, const std::vector< double > &init_sol, double pstar)
Assemble the matrix-form drift of sn.
FluidSymbolicDrift fluid_symbolic_drift(const FluidSymSystem &sys)
Port of @@SolverFLD/getSymbolicDrift: the right-hand side of the mean-field ODE system as expression ...
FluidSymbolicJacobian fluid_symbolic_jacobian(const FluidSymSystem &sys)
Port of @@SolverFLD/getJacobian: d f_i / d x_j of the mean-field drift, as expression strings.
FluidSymSystem fluid_symodes(const qn::NetworkStruct< T > &sn, const std::string &method_in, double pstar, const std::vector< double > &init_sol)
Build the symbolic system of sn under opt.method.
SchedStrategy
Scheduling disciplines, with the values of MATLAB SchedStrategy.
Definition lang_types.h:185
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:235
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
A queueing network and its refreshed NetworkStruct.
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
Where each (station, class) block sits in the state vector.
Definition fluid_odes.h:86
std::vector< std::vector< std::size_t > > qidx
0-based first index of (i,r)
Definition fluid_odes.h:88
std::size_t nstates
length of the state vector
Definition fluid_odes.h:87
std::vector< std::vector< bool > > enabled
whether (i,r) is served at all
Definition fluid_odes.h:90
std::vector< std::vector< std::size_t > > kic
phases held by (i,r); 0 when disabled
Definition fluid_odes.h:89
The assembled matrix-form drift and the maps that read metrics off it.
std::vector< double > alambda
(n) external arrivals per state
Matrix< double > W
(n x n) drift generator
std::vector< bool > is_inf
(n) state belongs to an INF station
std::vector< double > x0
(n) initial state
std::vector< bool > is_source
(n) state belongs to an EXT station
The symbolic system, in whichever of the two forms the method implies.
std::vector< double > inv_mass
the mass that group holds
std::vector< double > coeff
std::vector< std::vector< std::size_t > > inv_states
0-based states of each conserved group
std::vector< std::size_t > event_var
0-based state index driving the event
std::vector< double > x0
std::vector< std::size_t > state_station
std::vector< double > fcfs_phase_w
std::string form
"W" or "J"
std::vector< std::size_t > state_phase
1-based
std::vector< double > pstar
std::string smoothing
"min" or "pnorm"
std::vector< std::string > station_names
std::vector< double > alambda
std::vector< bool > is_inf
(n) state belongs to an INF station
double alpha
softmin sharpness, set only for that method
std::vector< bool > is_source
std::vector< std::size_t > state_class
std::vector< std::string > sched_names
std::vector< std::string > class_names
std::string method
resolved: default becomes matrix
std::vector< lang::SchedStrategy > sched
std::vector< double > S
servers per station, infinite already substituted
std::vector< SymFactor > factor
The drift as expression strings, one per state variable, plus their names.
std::vector< std::string > vars
std::vector< std::string > rhs
What @@SolverFLD/getJacobian returns: d f_i / d x_j as expression strings.
std::vector< std::string > vars
the state variable names
std::vector< std::string > rhs
the drift, one per variable
std::vector< std::vector< std::string > > J
J[i][j] = d f_i / d x_j.
The state-dependent factor attached to one event's driving variable.
std::vector< std::size_t > others
ext1: the other phases, 1-based state indices
std::size_t cls
1-based
std::string type
lin, min, pnorm, ext1, dpsmin, dpspw, fcfsw, fcfsws
std::size_t station
1-based
static constexpr double FineTol
Definition lang_types.h:765