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 <string>
56#include <vector>
57
61#include "line/util/error.h"
62#include "line/util/matrix.h"
63
64namespace line {
65namespace fluid {
66
67/** The state-dependent factor attached to one event's driving variable. */
68struct SymFactor {
69 std::string type; ///< lin, min, pnorm, ext1, dpsmin, dpspw, fcfsw, fcfsws
70 std::size_t station = 0; ///< 1-based
71 std::size_t cls = 0; ///< 1-based
72 std::vector<std::size_t> others; ///< ext1: the other phases, 1-based state indices
73};
74
75/** The symbolic system, in whichever of the two forms the method implies. */
77 std::string form; ///< "W" or "J"
78 std::string method; ///< resolved: default becomes matrix
79 std::vector<std::string> station_names, class_names, sched_names;
80 std::vector<lang::SchedStrategy> sched;
81 std::size_t nstates = 0;
82 std::vector<std::size_t> state_station, state_class, state_phase; ///< 1-based
83 std::vector<double> S; ///< servers per station, infinite already substituted
84 std::vector<double> x0;
85
86 // form W
88 std::vector<double> alambda;
89 std::vector<bool> is_source;
90 std::vector<bool> is_inf; ///< (n) state belongs to an INF station
91 std::string smoothing; ///< "min" or "pnorm"
92 std::vector<double> pstar;
93
94 // form J
96 std::vector<double> coeff;
97 std::vector<std::size_t> event_var; ///< 0-based state index driving the event
98 std::vector<SymFactor> factor;
99 std::size_t nevents = 0;
101 std::vector<double> fcfs_phase_w;
102 double alpha = 0.0; ///< softmin sharpness, set only for that method
103};
104
105namespace detail {
106
107/** The per-strategy factor of an event, and the coefficient folding it implies. */
108inline SymFactor sym_factor(const std::string& method, lang::SchedStrategy sched, std::size_t i,
109 std::size_t c, std::size_t ki, double& coeff, const FluidLayout& L,
110 const std::vector<double>& S, const Matrix<double>& dpsw,
111 const std::vector<double>& fcfs_w, std::size_t var) {
112 SymFactor f;
113 f.station = i + 1;
114 f.cls = c + 1;
115 if (method == "closing") {
116 switch (sched) {
117 case lang::SchedStrategy::INF: f.type = "lin"; break;
119 if (ki == 0) {
120 f.type = "ext1";
121 for (std::size_t k = 1; k < L.kic[i][c]; ++k)
122 f.others.push_back(L.qidx[i][c] + k + 1);
123 } else {
124 f.type = "lin";
125 }
126 break;
128 case lang::SchedStrategy::FCFS: f.type = "min"; break;
130 // the share w_ir*x/ntilde_i of the capacity min(n_i,S_i), as in
131 // fluid_rates_closing_factors: no additive seed, not the full S_i
132 f.type = "dpsmin";
133 coeff *= dpsw(i, c);
134 break;
135 default: f.type = "lin"; break; // no case in ode_rates_closing: rate stays x
136 }
137 return f;
138 }
139 // statedep and softmin
140 switch (sched) {
141 case lang::SchedStrategy::INF: f.type = "lin"; break;
142 case lang::SchedStrategy::PS: f.type = "min"; break;
144 f.type = (method == "softmin") ? "fcfsws" : "fcfsw";
145 coeff *= fcfs_w[var];
146 break;
147 case lang::SchedStrategy::DPS: f.type = "dpspw"; break;
148 default: f.type = "lin"; break;
149 }
150 return f;
151}
152
153/** True when `statedep`/`softmin` enumerate events out of this station at all. */
154inline bool sym_handled_sched(lang::SchedStrategy s) {
158}
159
160} // namespace detail
161
162/**
163 * Build the symbolic system of `sn` under `opt.method`.
164 *
165 * `init_sol` is the initial condition to report; empty takes the solver's
166 * default, as `build_x0` in the reference does through `solver_fluid_initsol`.
167 */
168template <class T>
169FluidSymSystem fluid_symodes(const qn::NetworkStruct<T>& sn, const std::string& method_in,
170 double pstar, const std::vector<double>& init_sol) {
171 const std::size_t M = sn.nstations, K = sn.nclasses;
172 std::string method = method_in;
173 if (method.compare(0, 6, "fluid.") == 0) method = method.substr(6);
174
175 FluidSymSystem sys;
176 if (method == "default" || method == "matrix" || method == "pnorm") {
177 sys.form = "W";
178 sys.method = (method == "default") ? "matrix" : method;
179 } else if (method == "closing" || method == "statedep" || method == "softmin") {
180 sys.form = "J";
181 sys.method = method;
182 } else {
183 throw UnsupportedError(
184 "fluid_symodes: the symbolic ODE export is unsupported for method '" + method_in +
185 "'; supported are default, matrix, pnorm, closing, statedep and softmin");
186 }
187
188 sys.station_names.resize(M);
189 sys.sched_names.resize(M);
190 sys.sched.resize(M);
191 for (std::size_t i = 0; i < M; ++i) {
192 sys.station_names[i] = sn.stations[i].name;
193 sys.sched[i] = sn.stations[i].sched;
194 sys.sched_names[i] = lang::sched_to_text(sn.stations[i].sched);
195 }
196 sys.class_names.resize(K);
197 for (std::size_t r = 0; r < K; ++r) sys.class_names[r] = sn.classes[r].name;
198
199 double closed_pop = 0.0;
200 for (std::size_t r = 0; r < K; ++r)
201 if (std::isfinite(sn.classes[r].population)) closed_pop += sn.classes[r].population;
202 sys.S.assign(M, 1.0);
203 for (std::size_t i = 0; i < M; ++i) {
204 const double c = sn.stations[i].nservers;
205 sys.S[i] = std::isfinite(c) ? c : closed_pop;
206 }
207
208 const FluidLayout L = fluid_layout(sn);
209
210 if (sys.form == "W") {
211 // Taken from the assembly the integrator itself uses, so the exported
212 // system is the system that runs and not a second opinion about it.
213 const FluidMatrixSystem ms = fluid_matrix_system(sn, init_sol, pstar);
214 sys.nstates = ms.nstates;
215 sys.W = ms.W;
216 sys.alambda = ms.alambda;
217 sys.is_source = ms.is_source;
218 sys.is_inf = ms.is_inf;
219 sys.x0 = ms.x0;
220 sys.smoothing = (pstar > 0.0) ? "pnorm" : "min";
221 sys.pstar.assign(M, pstar);
222 // Walk the same block enumeration to recover (station, class, phase).
223 sys.state_station.reserve(ms.nstates);
224 sys.state_class.reserve(ms.nstates);
225 sys.state_phase.reserve(ms.nstates);
226 for (std::size_t i = 0; i < M; ++i)
227 for (std::size_t r = 0; r < K; ++r) {
228 const std::size_t p = L.kic[i][r];
229 if (p == 0) continue; // the disabled placeholder is dropped
230 for (std::size_t k = 0; k < p; ++k) {
231 sys.state_station.push_back(i + 1);
232 sys.state_class.push_back(r + 1);
233 sys.state_phase.push_back(k + 1);
234 }
235 }
236 if (sys.state_station.size() != sys.nstates)
237 throw NumericError("fluid_symodes: the W-form state metadata does not match the drift");
238 return sys;
239 }
240
241 // ---- form J -----------------------------------------------------------
242 const bool sd = (method == "statedep" || method == "softmin");
243 if (sd)
244 for (std::size_t i = 0; i < M; ++i)
245 if (sn.stations[i].sched == lang::SchedStrategy::EXT)
246 throw UnsupportedError("fluid_symodes: the '" + method +
247 "' method does not support open models, so their ODE "
248 "system cannot be exported; use 'matrix' or 'closing'");
249
250 sys.nstates = L.nstates;
251 sys.state_station.assign(L.nstates, 0);
252 sys.state_class.assign(L.nstates, 0);
253 sys.state_phase.assign(L.nstates, 0);
254 for (std::size_t i = 0; i < M; ++i)
255 for (std::size_t c = 0; c < K; ++c)
256 for (std::size_t k = 0; k < L.kic[i][c]; ++k) {
257 sys.state_station[L.qidx[i][c] + k] = i + 1;
258 sys.state_class[L.qidx[i][c] + k] = c + 1;
259 sys.state_phase[L.qidx[i][c] + k] = k + 1;
260 }
261
262 sys.dpsw = Matrix<double>(M, K, 1.0);
263 for (std::size_t i = 0; i < M; ++i) {
264 if (sn.stations[i].sched != lang::SchedStrategy::DPS) continue;
265 double tot = 0.0;
266 for (std::size_t r = 0; r < K; ++r) {
267 const double w = (r < sn.stations[i].schedparam.size())
268 ? num_traits<T>::to_double(sn.stations[i].schedparam[r])
269 : 1.0;
270 sys.dpsw(i, r) = w;
271 tot += w;
272 }
273 if (tot > 0.0)
274 for (std::size_t r = 0; r < K; ++r) sys.dpsw(i, r) /= tot;
275 }
276
277 sys.fcfs_phase_w.assign(L.nstates, 0.0);
278 if (sd)
279 for (std::size_t i = 0; i < M; ++i) {
280 if (sn.stations[i].sched != lang::SchedStrategy::FCFS) continue;
281 for (std::size_t c = 0; c < K; ++c) {
282 if (!L.enabled[i][c]) continue;
283 for (std::size_t k = 0; k < L.kic[i][c]; ++k)
284 sys.fcfs_phase_w[L.qidx[i][c] + k] =
285 -1.0 / num_traits<T>::to_double(sn.service[i][c].D0(k, k));
286 }
287 }
288
289 // The service processes and the station-space routing, as the drift reads them.
290 std::vector<std::vector<std::vector<double>>> mu(M, std::vector<std::vector<double>>(K));
291 std::vector<std::vector<std::vector<double>>> phi(M, std::vector<std::vector<double>>(K));
292 std::vector<std::vector<std::vector<double>>> pie(M, std::vector<std::vector<double>>(K));
293 for (std::size_t i = 0; i < M; ++i)
294 for (std::size_t r = 0; r < K; ++r) {
295 if (!L.enabled[i][r]) {
296 pie[i][r] = std::vector<double>{1.0};
297 continue;
298 }
299 detail::fluid_mu_phi(sn.service[i][r], mu[i][r], phi[i][r]);
300 pie[i][r] = detail::fluid_pie(sn.service[i][r]);
301 }
302 const std::size_t NS = sn.nof_stateful();
303 const bool have_rt = sn.rt.rows() == NS * K;
304 std::vector<std::size_t> sf(M, 0);
305 for (std::size_t i = 0; i < M; ++i) sf[i] = sn.stateful_of_station(i + 1) - 1;
306 const auto route = [&](std::size_t i, std::size_t c, std::size_t j, std::size_t l) -> double {
307 if (!have_rt) return 0.0;
308 return num_traits<T>::to_double(sn.rt(sf[i] * K + c, sf[j] * K + l));
309 };
310
311 std::vector<std::vector<double>> cols; // the J columns, assembled then transposed
312 // ---- departures -------------------------------------------------------
313 for (std::size_t i = 0; i < M; ++i)
314 for (std::size_t c = 0; c < K; ++c) {
315 if (!L.enabled[i][c]) continue;
316 for (std::size_t j = 0; j < M; ++j)
317 for (std::size_t l = 0; l < K; ++l) {
318 if (!(route(i, c, j, l) > 0.0)) continue;
319 for (std::size_t ki = 0; ki < L.kic[i][c]; ++ki)
320 for (std::size_t kj = 0; kj < L.kic[j][l]; ++kj) {
321 if (sd) {
322 // An INF self-loop has no event, and a station
323 // whose policy has no branch contributes none.
324 if (sn.stations[i].sched == lang::SchedStrategy::INF && j == i)
325 continue;
326 if (!detail::sym_handled_sched(sn.stations[i].sched)) continue;
327 }
328 const double pj = kj < pie[j][l].size() ? pie[j][l][kj] : 0.0;
329 double base = phi[i][c][ki] * mu[i][c][ki] * route(i, c, j, l) * pj;
330 if (!(base > 0.0)) continue;
331 std::vector<double> col(L.nstates, 0.0);
332 col[L.qidx[i][c] + ki] -= 1.0;
333 col[L.qidx[j][l] + kj] += 1.0;
334 const std::size_t var = L.qidx[i][c] + ki;
335 const SymFactor f =
336 detail::sym_factor(method, sn.stations[i].sched, i, c, ki, base, L,
337 sys.S, sys.dpsw, sys.fcfs_phase_w, var);
338 cols.push_back(col);
339 sys.coeff.push_back(base);
340 sys.event_var.push_back(var);
341 sys.factor.push_back(f);
342 }
343 }
344 }
345 // ---- phase changes ----------------------------------------------------
346 for (std::size_t i = 0; i < M; ++i)
347 for (std::size_t c = 0; c < K; ++c) {
348 if (!L.enabled[i][c]) continue;
349 if (sd && !detail::sym_handled_sched(sn.stations[i].sched)) continue;
350 for (std::size_t ki = 0; ki + 1 < L.kic[i][c]; ++ki)
351 for (std::size_t kp = 0; kp < L.kic[i][c]; ++kp) {
352 if (kp == ki) continue;
353 double base = num_traits<T>::to_double(sn.service[i][c].D0(ki, kp));
354 if (!(base > 0.0)) continue;
355 std::vector<double> col(L.nstates, 0.0);
356 col[L.qidx[i][c] + ki] = -1.0;
357 col[L.qidx[i][c] + kp] = 1.0;
358 const std::size_t var = L.qidx[i][c] + ki;
359 const SymFactor f =
360 detail::sym_factor(method, sn.stations[i].sched, i, c, ki, base, L, sys.S,
361 sys.dpsw, sys.fcfs_phase_w, var);
362 cols.push_back(col);
363 sys.coeff.push_back(base);
364 sys.event_var.push_back(var);
365 sys.factor.push_back(f);
366 }
367 }
368
369 sys.nevents = cols.size();
370 sys.J = Matrix<double>(L.nstates, sys.nevents, 0.0);
371 for (std::size_t e = 0; e < sys.nevents; ++e)
372 for (std::size_t s = 0; s < L.nstates; ++s) sys.J(s, e) = cols[e][s];
373 if (method == "softmin") sys.alpha = 20.0;
374 sys.x0 = init_sol; // the caller supplies the default; see fluid_export_odes
375 return sys;
376}
377
378/** The drift as expression strings, one per state variable, plus their names. */
380 std::vector<std::string> rhs;
381 std::vector<std::string> vars;
382};
383
384namespace detail {
385
386/** A 1-based index as text, for the `x1 ... xn` variable names. */
387inline std::string sym_state_index(std::size_t v) {
388 char buf[32];
389 std::snprintf(buf, sizeof(buf), "%llu", static_cast<unsigned long long>(v));
390 return std::string(buf);
391}
392
393/** `num2char`: decimal text a computer-algebra backend reads as exact. */
394inline std::string sym_plain_num(double v) {
395 char buf[64];
396 if (v == std::floor(v) && std::fabs(v) < 1e15) {
397 std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
398 return std::string(buf);
399 }
400 std::snprintf(buf, sizeof(buf), "%.17g", v);
401 return std::string(buf);
402}
403
404/** `joinSum`: the sum of PARTS, with OFFSET added only when it is not zero. */
405inline std::string sym_join_sum(const std::vector<std::string>& parts, const std::string& offset) {
406 if (parts.empty()) return "(" + offset + ")";
407 std::string body;
408 for (std::size_t i = 0; i < parts.size(); ++i) {
409 if (i) body += " + ";
410 body += parts[i];
411 }
412 return (offset == "0") ? "(" + body + ")" : "(" + offset + " + " + body + ")";
413}
414
415/** `stationSum`: the total fluid mass at station i, plus its consumer's offset. */
416inline std::string sym_station_sum(const FluidSymSystem& sys, std::size_t station1,
417 const std::vector<std::string>& vars, const std::string& offset) {
418 std::vector<std::string> parts;
419 for (std::size_t s = 0; s < sys.nstates; ++s)
420 if (sys.state_station[s] == station1) parts.push_back(vars[s]);
421 return sym_join_sum(parts, offset);
422}
423
424
425/** `phaseWeightedStationSum`: sum_u w_u x_u with w_u the mean phase time. */
426inline std::string sym_phase_weighted_station_sum(const FluidSymSystem& sys, std::size_t station1,
427 const std::vector<std::string>& vars,
428 const std::string& offset) {
429 std::vector<std::string> parts;
430 for (std::size_t s = 0; s < sys.nstates; ++s) {
431 if (sys.state_station[s] != station1) continue;
432 const double w = sys.fcfs_phase_w[s];
433 if (w == 0.0) continue;
434 parts.push_back("(" + sym_plain_num(w) + ")*" + vars[s]);
435 }
436 return sym_join_sum(parts, offset);
437}
438
439/**
440 * `softminExpr`: the smooth minimum in its weighted-average form,
441 * (x e^{-a x} + y e^{-a y}) / (e^{-a x} + e^{-a y}).
442 *
443 * `softmin` itself rewrites this as lo + gap*w/(1+w) only to keep the exponent
444 * argument non-positive, an overflow guard that is meaningless symbolically and
445 * would introduce the min/max branch this export exists to avoid.
446 */
447inline std::string sym_softmin_expr(const std::string& x, const std::string& y, double alpha) {
448 const std::string a = sym_plain_num(alpha);
449 return "((" + x + ")*exp(-(" + a + ")*(" + x + ")) + (" + y + ")*exp(-(" + a + ")*(" + y +
450 ")))/(exp(-(" + a + ")*(" + x + ")) + exp(-(" + a + ")*(" + y + ")))";
451}
452
453} // namespace detail
454
455/**
456 * Port of `@@SolverFLD/getSymbolicDrift`: the right-hand side of the mean-field ODE
457 * system as expression strings, one per state variable.
458 *
459 * This is the input a computer-algebra backend needs to produce a Jacobian or an
460 * equilibrium, and it is the same system `fluid_symodes` describes and
461 * `fluid_export_odes` typesets, written out variable by variable instead of in
462 * matrix form.
463 *
464 * ONLY SMOOTH DRIFTS ARE EXPORTED. The default, matrix, closing and statedep
465 * methods scale rates by min(n_i, S_i), which is not differentiable at n_i = S_i,
466 * so their Jacobian does not exist there; emitting a one-sided derivative would be
467 * a silent lie exactly at the regime switch that matters. The p-norm smoothing
468 * (`pstar`, methods matrix or pnorm) and the `softmin` method are smooth
469 * everywhere, and everything else is refused BY THE FACTOR TYPE that carries the
470 * kink rather than by method name, so a new non-smooth branch cannot slip through.
471 */
474 out.vars.resize(sys.nstates);
475 for (std::size_t s = 0; s < sys.nstates; ++s)
476 out.vars[s] = "x" + detail::sym_state_index(s + 1);
477 out.rhs.assign(sys.nstates, "0");
478 // `eps0`: the FineTol the softmin denominator carries, as the reference writes
479 // it into the expression rather than folding it into a number.
480 const std::string eps0 = detail::sym_plain_num(lang::GlobalConstants::FineTol);
481
482 if (sys.form == "W") {
483 // dx/dt = W' theta(x) + Alambda, with the p-norm smoothing:
484 // theta_s = x_s / (1 + (n_i/S_i)^p_i)^(1/p_i), theta_s = 0 at a Source.
485 if (sys.smoothing != "pnorm")
486 throw UnsupportedError(
487 "fluid_symbolic_drift: the drift of this method scales rates by min(n_i, S_i), "
488 "which is not differentiable at n_i = S_i, so it has no Jacobian there. Set pstar "
489 "to use the p-norm smoothing, or use the 'softmin' method");
490 std::vector<std::string> theta(sys.nstates, "0");
491 for (std::size_t s = 0; s < sys.nstates; ++s) {
492 if (sys.is_source[s]) continue;
493 const std::size_t i = sys.state_station[s];
494 const double S = sys.S[i - 1];
495 const double p = sys.pstar[i - 1];
496 // An INF station has no min() to smooth, so its theta stays linear;
497 // S holds the population there, not infinity
498 if (s < sys.is_inf.size() && sys.is_inf[s]) {
499 theta[s] = out.vars[s];
500 continue;
501 }
502 if (S <= 0.0 || p <= 0.0) {
503 theta[s] = out.vars[s];
504 continue;
505 }
506 const std::string ni = detail::sym_station_sum(sys, i, out.vars, eps0);
507 theta[s] = out.vars[s] + "/(1 + (" + ni + "/" + detail::sym_plain_num(S) + ")^" +
508 detail::sym_plain_num(p) + ")^(1/" + detail::sym_plain_num(p) + ")";
509 }
510 for (std::size_t s = 0; s < sys.nstates; ++s) {
511 std::vector<std::string> terms;
512 for (std::size_t t = 0; t < sys.nstates; ++t) {
513 const double w = sys.W(t, s);
514 if (w == 0.0 || theta[t] == "0") continue;
515 terms.push_back("(" + detail::sym_plain_num(w) + ")*(" + theta[t] + ")");
516 }
517 if (sys.alambda[s] != 0.0) terms.push_back(detail::sym_plain_num(sys.alambda[s]));
518 if (terms.empty()) continue;
519 std::string body;
520 for (std::size_t i = 0; i < terms.size(); ++i) {
521 if (i) body += " + ";
522 body += terms[i];
523 }
524 out.rhs[s] = body;
525 }
526 return out;
527 }
528 if (sys.form != "J")
529 throw UnsupportedError("fluid_symbolic_drift: unsupported ODE form '" + sys.form + "'");
530
531 // dx/dt = J r(x), with r_e = coeff(e) * factor_e(x). Only the smooth factor
532 // types are exportable: 'min' (PS/FCFS under closing and statedep), 'fcfsw'
533 // (statedep FCFS), 'dpsmin' (closing DPS) and 'dpspw' (piecewise DPS) all
534 // carry a min or a branch.
535 std::vector<std::string> rate(sys.nevents, "0");
536 for (std::size_t e = 0; e < sys.nevents; ++e) {
537 const SymFactor& f = sys.factor[e];
538 const std::string& v = out.vars[sys.event_var[e]];
539 std::string factor;
540 if (f.type == "lin") {
541 factor = v;
542 } else if (f.type == "ext1") {
543 if (f.others.empty()) {
544 factor = "1";
545 } else {
546 std::string body;
547 for (std::size_t k = 0; k < f.others.size(); ++k) {
548 if (k) body += " + ";
549 body += out.vars[f.others[k] - 1];
550 }
551 factor = "(1 - (" + body + "))";
552 }
553 } else if (f.type == "fcfsws") {
554 const std::string ni = detail::sym_station_sum(sys, f.station, out.vars, "0");
555 const std::string nhat =
556 detail::sym_phase_weighted_station_sum(sys, f.station, out.vars, eps0);
557 factor = v + "*(" +
558 detail::sym_softmin_expr(ni, detail::sym_plain_num(sys.S[f.station - 1]),
559 sys.alpha) +
560 ")/(" + nhat + ")";
561 } else {
562 throw UnsupportedError(
563 "fluid_symbolic_drift: event " + detail::sym_state_index(e + 1) +
564 " scales its rate by the non-smooth factor '" + f.type +
565 "', which has no derivative where the regime switches, so the system has no "
566 "Jacobian. Use the 'softmin' method, or the p-norm smoothing of the 'matrix' "
567 "method");
568 }
569 rate[e] = "(" + detail::sym_plain_num(sys.coeff[e]) + ")*(" + factor + ")";
570 }
571 for (std::size_t s = 0; s < sys.nstates; ++s) {
572 std::vector<std::string> terms;
573 for (std::size_t e = 0; e < sys.nevents; ++e) {
574 const double j = sys.J(s, e);
575 if (j == 0.0) continue;
576 terms.push_back("(" + detail::sym_plain_num(j) + ")*(" + rate[e] + ")");
577 }
578 if (terms.empty()) continue;
579 std::string body;
580 for (std::size_t i = 0; i < terms.size(); ++i) {
581 if (i) body += " + ";
582 body += terms[i];
583 }
584 out.rhs[s] = body;
585 }
586 return out;
587}
588
589/** What `@@SolverFLD/getJacobian` returns: d f_i / d x_j as expression strings. */
591 std::vector<std::string> vars; ///< the state variable names
592 std::vector<std::string> rhs; ///< the drift, one per variable
593 std::vector<std::vector<std::string> > J; ///< J[i][j] = d f_i / d x_j
594};
595
596namespace detail {
597
598/** d(n_i)/d(x_m): one when state m belongs to station i, zero otherwise. */
599inline bool sym_in_station(const FluidSymSystem& sys, std::size_t station1, std::size_t m) {
600 return sys.state_station[m] == station1;
601}
602
603/**
604 * d/dx_m of the softmin of (n_i, S), by the chain rule through n_i.
605 *
606 * With a = n_i, b = S constant and D = e^{-alpha a} + e^{-alpha b},
607 *
608 * sm(a) = (a e^{-alpha a} + b e^{-alpha b}) / D
609 * sm'(a) = e^{-alpha a} (1 - alpha a + alpha sm(a)) / D
610 *
611 * which is written out rather than left as a quotient of four exponentials
612 * because the form above has one exponential per state and the naive one has
613 * four, and a backend that simplifies neither would carry the difference into
614 * every entry of the Jacobian.
615 */
616inline std::string sym_softmin_deriv(const std::string& a, const std::string& b, double alpha) {
617 const std::string al = sym_plain_num(alpha);
618 const std::string ea = "exp(-(" + al + ")*(" + a + "))";
619 const std::string eb = "exp(-(" + al + ")*(" + b + "))";
620 const std::string D = "(" + ea + " + " + eb + ")";
621 const std::string sm = sym_softmin_expr(a, b, alpha);
622 return "(" + ea + "*(1 - (" + al + ")*(" + a + ") + (" + al + ")*(" + sm + ")))/" + D;
623}
624
625/** `(u)*(v)`, dropping the term when either side is the literal zero. */
626inline std::string sym_mul(const std::string& u, const std::string& v) {
627 if (u == "0" || v == "0") return "0";
628 if (u == "1") return v;
629 if (v == "1") return u;
630 return "(" + u + ")*(" + v + ")";
631}
632
633/** Sum of the non-zero terms, or the literal zero. */
634inline std::string sym_sum(const std::vector<std::string>& parts) {
635 std::string body;
636 for (std::size_t i = 0; i < parts.size(); ++i) {
637 if (parts[i] == "0") continue;
638 if (!body.empty()) body += " + ";
639 body += parts[i];
640 }
641 return body.empty() ? std::string("0") : body;
642}
643
644} // namespace detail
645
646/**
647 * Port of `@@SolverFLD/getJacobian`: d f_i / d x_j of the mean-field drift, as
648 * expression strings.
649 *
650 * IT IS DIFFERENTIATED HERE, NOT SENT AWAY. The reference hands the drift
651 * strings to the line-sage-rest backend over HTTP and returns what SAGE gives
652 * back. This port has no symbolic engine and no HTTP client -- the CTMC port
653 * makes the same refusal for its own symbolic getters -- but the drift is not an
654 * opaque expression here: `FluidSymSystem` carries it STRUCTURALLY, as a jump
655 * matrix times per-event rates whose state dependence is one of a closed set of
656 * typed factors. Each of those factors has a derivative that can be written down
657 * once, so the Jacobian is produced exactly, by the chain rule over the
658 * structure, rather than by parsing the strings the drift printer emitted.
659 *
660 * The smoothness gate is the SAME one `fluid_symbolic_drift` applies, and for
661 * the same reason: `min(n_i, S_i)` has no derivative at n_i = S_i, and a
662 * one-sided value there would be a silent lie exactly at the regime switch. The
663 * refusal is by FACTOR TYPE, so a new non-smooth branch cannot slip through.
664 *
665 * `equilibria`, the reference's fourth output, is NOT produced here: solving
666 * f(x) = 0 in closed form needs a computer-algebra solver, which differentiating
667 * does not. It lives in `fluid_jacobian` (fluid_jacobian.h), which resolves the
668 * same line-sage-rest backend the reference uses and asks it; this function stays
669 * backend-free so that the Jacobian alone never needs one.
670 */
674 out.vars = d.vars;
675 out.rhs = d.rhs;
676 const std::size_t n = sys.nstates;
677 out.J.assign(n, std::vector<std::string>(n, "0"));
678 const std::string eps0 = detail::sym_plain_num(lang::GlobalConstants::FineTol);
679
680 if (sys.form == "W") {
681 // theta_s = x_s / Q_i, Q_i = (1 + (n_i/S_i)^p_i)^(1/p_i)
682 // d theta_s / d x_m = [m == s]/Q_i - x_s Q_i' / Q_i^2
683 // Q_i' = (1 + (n_i/S_i)^p)^(1/p - 1) (n_i/S_i)^(p-1) / S_i, for m at i
684 std::vector<std::vector<std::string> > dtheta(n, std::vector<std::string>(n, "0"));
685 for (std::size_t s = 0; s < n; ++s) {
686 if (sys.is_source[s]) continue;
687 const std::size_t i = sys.state_station[s];
688 const double S = sys.S[i - 1], p = sys.pstar[i - 1];
689 if ((s < sys.is_inf.size() && sys.is_inf[s]) || S <= 0.0 || p <= 0.0) {
690 dtheta[s][s] = "1"; // theta_s = x_s
691 continue;
692 }
693 const std::string ni = detail::sym_station_sum(sys, i, out.vars, eps0);
694 const std::string Sn = detail::sym_plain_num(S), pn = detail::sym_plain_num(p);
695 const std::string base = "(1 + ((" + ni + ")/" + Sn + ")^" + pn + ")";
696 const std::string Q = base + "^(1/" + pn + ")";
697 const std::string dQ = base + "^(1/" + pn + " - 1)*((" + ni + ")/" + Sn + ")^(" + pn +
698 " - 1)/" + Sn;
699 for (std::size_t m = 0; m < n; ++m) {
700 std::vector<std::string> parts;
701 if (m == s) parts.push_back("1/(" + Q + ")");
702 if (detail::sym_in_station(sys, i, m))
703 parts.push_back("-(" + out.vars[s] + ")*(" + dQ + ")/(" + Q + ")^2");
704 dtheta[s][m] = detail::sym_sum(parts);
705 }
706 }
707 for (std::size_t s = 0; s < n; ++s)
708 for (std::size_t m = 0; m < n; ++m) {
709 std::vector<std::string> parts;
710 for (std::size_t t = 0; t < n; ++t) {
711 const double w = sys.W(t, s);
712 if (w == 0.0 || dtheta[t][m] == "0") continue;
713 parts.push_back(detail::sym_mul(detail::sym_plain_num(w), dtheta[t][m]));
714 }
715 out.J[s][m] = detail::sym_sum(parts);
716 }
717 return out;
718 }
719 if (sys.form != "J")
720 throw UnsupportedError("fluid_symbolic_jacobian: unsupported ODE form '" + sys.form + "'");
721
722 // d rate_e / d x_m = coeff_e * d factor_e / d x_m, one row per factor type.
723 std::vector<std::vector<std::string> > drate(sys.nevents, std::vector<std::string>(n, "0"));
724 for (std::size_t e = 0; e < sys.nevents; ++e) {
725 const SymFactor& f = sys.factor[e];
726 const std::size_t v = sys.event_var[e];
727 const std::string cf = detail::sym_plain_num(sys.coeff[e]);
728 if (f.type == "lin") {
729 drate[e][v] = cf;
730 } else if (f.type == "ext1") {
731 for (std::size_t k = 0; k < f.others.size(); ++k)
732 drate[e][f.others[k] - 1] = "-(" + cf + ")";
733 } else if (f.type == "fcfsws") {
734 // factor = x_v * softmin(n_i, S) / nhat_i, with n_i and nhat_i both
735 // linear in the states of station i. Product and quotient rules:
736 // d/dx_m = [m == v] sm/nhat
737 // + x_v sm'(n_i) [m at i] / nhat
738 // - x_v sm w_m [m at i] / nhat^2
739 const std::string ni = detail::sym_station_sum(sys, f.station, out.vars, "0");
740 const std::string nhat =
741 detail::sym_phase_weighted_station_sum(sys, f.station, out.vars, eps0);
742 const std::string Sn = detail::sym_plain_num(sys.S[f.station - 1]);
743 const std::string sm = detail::sym_softmin_expr(ni, Sn, sys.alpha);
744 const std::string dsm = detail::sym_softmin_deriv(ni, Sn, sys.alpha);
745 for (std::size_t m = 0; m < n; ++m) {
746 std::vector<std::string> parts;
747 if (m == v) parts.push_back("(" + sm + ")/(" + nhat + ")");
748 if (detail::sym_in_station(sys, f.station, m)) {
749 parts.push_back("(" + out.vars[v] + ")*(" + dsm + ")/(" + nhat + ")");
750 const double w = sys.fcfs_phase_w[m];
751 if (w != 0.0)
752 parts.push_back("-(" + out.vars[v] + ")*(" + sm + ")*(" +
753 detail::sym_plain_num(w) + ")/(" + nhat + ")^2");
754 }
755 const std::string body = detail::sym_sum(parts);
756 drate[e][m] = body == "0" ? std::string("0") : detail::sym_mul(cf, body);
757 }
758 } else {
759 throw UnsupportedError(
760 "fluid_symbolic_jacobian: event " + detail::sym_state_index(e + 1) +
761 " scales its rate by the non-smooth factor '" + f.type +
762 "', which has no derivative where the regime switches, so the system has no "
763 "Jacobian. Use the 'softmin' method, or the p-norm smoothing of the 'matrix' "
764 "method");
765 }
766 }
767
768 for (std::size_t s = 0; s < n; ++s)
769 for (std::size_t m = 0; m < n; ++m) {
770 std::vector<std::string> parts;
771 for (std::size_t e = 0; e < sys.nevents; ++e) {
772 const double j = sys.J(s, e);
773 if (j == 0.0 || drate[e][m] == "0") continue;
774 parts.push_back(detail::sym_mul(detail::sym_plain_num(j), drate[e][m]));
775 }
776 out.J[s][m] = detail::sym_sum(parts);
777 }
778 return out;
779}
780
781} // namespace fluid
782} // namespace line
783
784#endif // LINE_SOLVERS_FLUID_FLUID_SYMODES_H
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
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.
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:181
const char * sched_to_text(SchedStrategy s)
Definition lang_types.h:230
A queueing network and its refreshed NetworkStruct.
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 > coeff
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:668