LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
solver_env.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_ENV_SOLVER_ENV_H
6#define LINE_SOLVERS_ENV_SOLVER_ENV_H
7
8/**
9 * @file
10 * @ingroup line_solvers
11 * SolverENV: a queueing network in a random environment.
12 *
13 * Port of `matlab/src/solvers/@@SolverENV/SolverENV.m` driving
14 * `solver_env_meanfield_analyzer.m`, the default (and historical) coupling.
15 *
16 * THE FIXED POINT, in one paragraph. Each stage is solved TRANSIENTLY, not in
17 * steady state, because a stage does not last long enough to reach one -- the
18 * environment switches first. What the network carries across a switch is its
19 * queue lengths, so stage e must be started from the mean queue lengths it is
20 * ENTERED with, and those are the exit queue lengths of whichever stage
21 * preceded it. That is a fixed point over the entry vectors, and the iteration
22 * below is a Picard iteration on it:
23 *
24 * Qentry[e] = sum_h probOrig(h, e) * reset_{h->e}( Qexit[h][e] )
25 *
26 * where Qexit[h][e] is the transient of stage h AVERAGED OVER WHEN the h -> e
27 * switch happens -- the increments of that transition's CDF are the weights.
28 * The environment-averaged answer at the end weights each stage's transient
29 * over its own HOLDING time CDF instead (any exit, not a particular one), and
30 * blends the stages by their stationary probabilities probEnv.
31 *
32 * WHY THE WEIGHTS ARE CDF INCREMENTS AND NOT A DENSITY. The transient comes
33 * back on a grid, so the reference integrates the metric against the measure
34 * the transition induces on that same grid: w_j = F(t_j) - F(t_{j-1}), with
35 * w_1 = 0, normalized by their sum. That is a Riemann-Stieltjes sum, and it
36 * needs no density -- which matters, because a deterministic transition has
37 * none.
38 *
39 * THE MEAN-FIELD COLLAPSE is the approximation, and it is worth naming: only
40 * the MARGINAL MEAN queue lengths cross a switch, so any correlation between
41 * stations at the moment of the switch is discarded.
42 * `solver_env_statevec_analyzer.m` carries the whole joint distribution
43 * instead; it is ported in `solver_env_statevec.h`, as a separate class with a
44 * CTMC stage solver, and `env_dispatch.h` chooses between the two the way
45 * `SolverENV.init` does. `method = "statevec"` is refused HERE by name rather
46 * than silently served by the mean-field coupling.
47 *
48 * A LAYERED STAGE IS THE SAME FIXED POINT WITH A DIFFERENT STAGE SOLVER. When
49 * a stage holds a `lqn::LqnStruct` rather than a flat network, its transient
50 * comes from a `SolverLN` over the stage's own layers instead of from one fluid
51 * integration, and the (station, class) view the coupling blends over is the
52 * BLOCK-DIAGONAL UNION of those layers -- `LayeredNetwork.layerBlocks` in the
53 * reference, `SolverLN::layer_blocks` here. Nothing else changes: the same
54 * marginal mean queue lengths cross a switch, split into per-layer blocks on
55 * the way in (`SolverLN::init_from_marginal`) and reassembled on the way out.
56 * The one thing to know about the handoff is that it must be replayed AFTER the
57 * layered fixed point and BEFORE the layer transients, because the fixed point
58 * resets its layers as it converges; `run_layer_transient` is where that
59 * happens, and a warm start installed anywhere earlier is silently inert.
60 *
61 * WHAT IS PORTED, and what is refused by name:
62 * ported `meanfield` (the default), `smp` (the mean-field coupling on the
63 * exact SEMI-MARKOV environment: arcs of any law, competing or kernel,
64 * probEnv and probOrig from the embedded jump chain and the mean
65 * holding times of Environment::init, and each exit averaged over the
66 * cumulative incidence of its own destination -- see `dest_resolved`),
67 * `statedep` (state-dependent environment rates,
68 * `resetEnvRates`), stochastic and deterministic sojourn,
69 * per-transition reset policies including the named `keep`/`clear`
70 * of a node breakdown, fluid stages, CTMC stages, and LayeredNetwork
71 * stages over fluid layers
72 * refused `statevec`/`blend` (this class is the mean-field coupling; use
73 * env::solver_env for them), `avg`/`dec` (the closed-form fast/slow
74 * limits of `solveEnvLimit`, ported as env::SolverEnvLimit and
75 * likewise reached through env::solver_env), and the cache
76 * aggregation of `aggregateCacheMeanfield_`.
77 */
78
79#include <algorithm>
80#include <cmath>
81#include <cstddef>
82#include <limits>
83#include <memory>
84#include <string>
85#include <vector>
86
100#include "line/util/error.h"
101#include "line/util/matrix.h"
102
103namespace line {
104namespace env {
105
106/**
107 * `LnOptions` as a LAYERED environment stage is solved with.
108 *
109 * Only the layer engine differs from the SolverLN default, and it differs
110 * because the mean-field coupling has no use for a stage it cannot integrate:
111 * see `EnvOptions::lqn`.
112 */
115 o.layer_solver = "fluid";
116 return o;
117}
118
119/** Options of SolverENV. Defaults are `Solver.defaultOptions`. */
121 int iter_max = 100;
122 double iter_tol = 1e-4;
123 /**
124 * Which solver runs each FLAT stage: `fluid` (the fluid transient), `ctmc`
125 * (the enumerated chain), `mva` or `nc`. A LAYERED stage is run by SolverLN
126 * whatever this says, and its own layer engine is named by
127 * `lqn.layer_solver`; asking for `ctmc` alongside a layered stage is refused
128 * rather than reinterpreted, since an LQN has no single generator to
129 * enumerate.
130 *
131 * `mva` and `nc` are the ensemble the reference builds with
132 * `SolverENV(env, @SolverMVA)` / `@SolverNC`. Neither has a transient, so,
133 * as `SolverENV.methodRefusal` rules, they serve the closed-form limits
134 * `avg` and `dec` (`SolverEnvLimit`, at `stage_mva` / `stage_nc`) and the
135 * mean-field family refuses them by name.
136 */
137 std::string stage_solver = "fluid";
138 /** Options of an `mva` stage solver, `SolverMVA(model, options)`. */
140 /** Options of an `nc` stage solver, `SolverNC(model, options)`. */
142 /** The inter-stage coupling: `meanfield` is the reference's default. */
143 std::string method = "meanfield";
144 /** `options.sojourn`: `stochastic` (default) or `deterministic`. */
145 std::string sojourn = "stochastic";
146 /** Options handed to each stage solver. */
148 /**
149 * `options.cutoff` of a CTMC stage, read only when `stage_solver` is ctmc.
150 * An open stage needs one; a closed one enumerates its own population.
151 */
152 double stage_cutoff = -1.0;
153 /** `options.timespan(2)` of the inner solver: the transient horizon. */
154 double timespan_end = 100.0;
155 /**
156 * Method `occupation`: SolverENV's OWN `options.timespan`, [t0, t1]. The
157 * horizon is t1 - t0 when both are finite and unbounded otherwise, which is
158 * the default ([Inf, Inf], as `SolverOptions('ENV')` sets it). Distinct from
159 * `timespan_end`, the inner solvers' transient horizon of the couplings.
160 */
161 double env_timespan_start = std::numeric_limits<double>::infinity();
162 double env_timespan_end = std::numeric_limits<double>::infinity();
163 /**
164 * Method `occupation`: `options.config.env_init`, the initial probability
165 * of each stage. Empty means the first stage, as the reference defaults.
166 */
167 std::vector<double> env_init;
168 /**
169 * Points on a UNIFORM transient grid, used only where `stage_grid` declines
170 * to build one (a stage whose holding time is the disabled 1 x 1 zero pair).
171 *
172 * It used to be the accuracy knob of the whole method, because the exit
173 * metrics are a Riemann-Stieltjes sum over whatever grid the stage was
174 * integrated on and a uniform grid resolves the HORIZON rather than the
175 * sojourn. Since 2026-08-11 each stage is integrated on the grid the
176 * sojourn asks for -- 90% of the points under `5*E[S]` -- so the answer no
177 * longer moves with this: on renv_node_breakdown the horizon may be 100 or
178 * 1000 and Server QLen is 0.458854 or 0.459191.
179 */
180 std::size_t tran_points = 1001;
181 /**
182 * Options of the `SolverLN` that runs a LAYERED stage.
183 *
184 * The reference names the stage solver by handing SolverENV a FACTORY --
185 * `ENV(env, @(m) LN(m, @(mm) FLD(mm), 'timespan', [0 T]))` -- and this field
186 * is that factory's argument list, because a C++ template cannot take a
187 * MATLAB function handle. `timespan_end`, `tran_points` and `tran_grid` are
188 * OVERWRITTEN by the environment for every stage: the horizon and the grid
189 * the exit average is summed on belong to the coupling, not to a stage.
190 *
191 * `layer_solver` defaults to `fluid` here where `LnOptions` alone defaults
192 * to `mva`, and the difference is not a preference: the coupling carries
193 * queue lengths across a switch and therefore needs each stage's
194 * TRANSIENT, which only the fluid layer engine produces. A caller who names
195 * another engine is refused by `init`, not quietly served the fluid one.
196 */
198};
199
200/** What SolverENV reports. */
202 /** Environment-averaged metrics, (nstations x nclasses). */
204 /** Per-stage, sojourn-averaged metrics. */
205 std::vector<Matrix<double>> QExit, UExit, TExit;
206 /** The entry queue lengths the fixed point converged to. */
207 std::vector<Matrix<double>> Qentry;
208 /**
209 * `meancov` only: the environment-wide queue-length COVARIANCE over the
210 * (station, class) pairs, (M*K)-by-(M*K) and indexed `ir = r*M + i`, and its
211 * diagonal reshaped to (nstations x nclasses). Empty under every other
212 * coupling, which carries a first moment alone.
213 */
215 int iterations = 0;
216 bool converged = false;
217};
218
219namespace detail {
220
221/**
222 * `maxpe(approx, exact)`: max |1 - approx/exact| over the entries where exact
223 * is nonzero, which is what the reference's convergence test compares.
224 */
225inline double env_maxpe(const std::vector<double>& approx, const std::vector<double>& exact) {
226 double worst = -1.0;
227 for (std::size_t i = 0; i < approx.size() && i < exact.size(); ++i) {
228 if (exact[i] == 0.0) continue;
229 const double e = std::fabs(1.0 - approx[i] / exact[i]);
230 if (e > worst) worst = e;
231 }
232 return worst; // negative means "no comparable entry", the reference's empty
233}
234
235/** Linear interpolation of a transient metric at `d`, clamped to the grid. */
236inline double env_det_eval(const std::vector<double>& t, const std::vector<double>& metric,
237 double d) {
238 if (t.empty()) return 0.0;
239 d = std::max(t.front(), std::min(d, t.back()));
240 for (std::size_t j = 1; j < t.size(); ++j) {
241 if (d <= t[j]) {
242 const double dt = t[j] - t[j - 1];
243 if (!(dt > 0.0)) return metric[j];
244 const double a = (d - t[j - 1]) / dt;
245 return metric[j - 1] * (1.0 - a) + metric[j] * a;
246 }
247 }
248 return metric.back();
249}
250
251} // namespace detail
252
253/**
254 * The environment solver.
255 *
256 * `envObj` must already carry a model per stage; `init()` is called here, as
257 * `SolverENV.init` does.
258 */
259template <class T>
261public:
262 SolverEnv(Environment<T>& e, const EnvOptions& o) : envObj(e), opt(o) { init(); }
263
265 const std::size_t E = envObj.nstages();
266 EnvSolution out;
267 out.QN = Matrix<double>(M, K, 0.0);
268 out.UN = Matrix<double>(M, K, 0.0);
269 out.TN = Matrix<double>(M, K, 0.0);
270
271 pre();
272 std::vector<std::vector<double>> qfirst_prev(E), qfirst_curr(E);
273 int it = 0;
274 for (it = 1; it <= opt.iter_max; ++it) {
275 for (std::size_t e = 0; e < E; ++e) analyze(e);
276 // The reference's convergence test compares the FIRST point of the
277 // transient -- the entry queue length -- across iterations.
278 qfirst_prev = qfirst_curr;
279 qfirst_curr.assign(E, std::vector<double>());
280 for (std::size_t e = 0; e < E; ++e) {
281 qfirst_curr[e].reserve(M * K);
282 for (std::size_t i = 0; i < M; ++i)
283 for (std::size_t r = 0; r < K; ++r) qfirst_curr[e].push_back(tranQ[e][i][r][0]);
284 }
285 bool conv = it > 1;
286 if (conv)
287 for (std::size_t e = 0; e < E && conv; ++e) {
288 const double d = detail::env_maxpe(qfirst_curr[e], qfirst_prev[e]);
289 if (d < 0.0) continue; // nothing comparable, treated as converged
290 if (!std::isfinite(d) || d >= opt.iter_tol) conv = false;
291 }
292 post();
293 if (conv) {
294 out.converged = true;
295 break;
296 }
297 }
298 out.iterations = std::min(it, opt.iter_max);
299 finish(out);
300 return out;
301 }
302
303private:
304 void init() {
305 // MVA and NC stages are a valid ensemble with no transient, which the
306 // reference's `methodRefusal` turns away from the mean-field family with
307 // this sentence; the closed-form limits are where they run.
308 if (opt.stage_solver == "mva" || opt.stage_solver == "nc")
309 throw UnsupportedError(ErrorKind{"Structure"}, // init's LINE:unsupported:Structure
310 "SolverENV: the mean-field environment coupling integrates each stage over its "
311 "sojourn, so it needs transient averages from the stage solver, which " +
312 std::string(opt.stage_solver == "mva" ? "SolverMVA" : "SolverNC") +
313 " (stage 1) does not produce. Use a fluid or CTMC stage solver, or method='dec' "
314 "or 'avg'.");
315 if (opt.stage_solver != "fluid" && opt.stage_solver != "ctmc")
316 throw UnsupportedError(ErrorKind{"Option"},
317 "SolverENV: stage solver '" + opt.stage_solver +
318 "' is not available; the environment coupling needs a TRANSIENT stage solve and "
319 "only the fluid analyzer and the enumerated CTMC provide one in this port");
320 ctmc_stages = (opt.stage_solver == "ctmc");
321 if (opt.method == "statevec")
322 throw UnsupportedError(ErrorKind{"Method"},
323 "SolverENV: the state-vector analyzer (solver_env_statevec_analyzer.m) carries the "
324 "full joint distribution across a switch, which THIS class does not: it is the "
325 "mean-field coupling and carries the marginal means. The state-vector coupling is "
326 "ported as env::SolverEnvStatevec, with its own options and its CTMC stage solver; "
327 "reach it through env::solver_env (env_dispatch.h), which is the analyzer "
328 "selection of SolverENV.init");
329 if (opt.method == "avg" || opt.method == "dec" || opt.method == "occupation")
330 throw UnsupportedError(ErrorKind{"Method"},
331 "SolverENV: the closed-form fast/slow environment limits (SolverENV.solveEnvLimit) "
332 "and the occupation weighting (SolverENV.solveEnvOccupation) "
333 "replace this fixed point rather than configure it -- neither carries anything "
334 "across a switch. They are ported as env::SolverEnvLimit, with their own result "
335 "type; reach them through env::solver_env (env_dispatch.h), which is the method "
336 "dispatch of SolverENV.runAnalyzer");
337 // `smp` selects no analyzer of its own: it runs the mean-field one on the
338 // semi-Markov stage probabilities Environment::init computes, and resolves
339 // each exit by destination (`dest_resolved`).
340 smp = (opt.method == "smp");
341 statedep = (opt.method == "statedep");
342 // "default" IS the reference's spelling of this coupling: SolverENV.m
343 // installs solver_env_meanfield_analyzer for every method that is not
344 // statevec, and its listValidMethods leads with 'default'. Only the CLI
345 // normalised it away, so a caller constructing SolverEnv directly with
346 // the reference's own name was refused. "blend" is a third spelling of
347 // it since 2026-09-13, when that name was repointed here from the
348 // state-vector coupling, and this whitelist is why it has to be named:
349 // falling past the statevec branch reaches the mean-field analyzer, but
350 // an unlisted name is refused before it gets there.
351 if (opt.method != "meanfield" && opt.method != "default" && opt.method != "mean"
352 && opt.method != "meancov" && opt.method != "blend" && opt.method != "blending"
353 && opt.method != "smp"
354 && !statedep)
355 throw UnsupportedError(ErrorKind{"Method"}, "SolverENV: unknown method '" + opt.method + "'");
356 // 'meancov' IS THIS COUPLING, carrying a COVARIANCE beside the mean: the
357 // propagation is the mean-field one and only the object crossing a switch
358 // is richer, so it is a flag here rather than an analyzer of its own.
359 meancov = (opt.method == "meancov");
360 // THE STAGE METHOD IS THE CALLER'S, as it is in the reference, where a
361 // stage is an ordinary SolverFluid the caller constructed: `kp` integrates
362 // the Ko-Pender fluid AND diffusion limits, and is the only route in this
363 // port that carries a second moment along a stage trajectory. Every other
364 // fluid method integrates the closing drift, which carries a first moment
365 // alone; `meancov` over such stages keeps the TIMING variance of the
366 // sojourn and the spread of the per-origin means, and nothing of the
367 // within-stage spread, exactly as the reference does for a stage solver
368 // that reports none.
369 {
370 std::string sm = opt.stage.method;
371 if (sm.size() > 4 && sm.compare(0, 4, "fld.") == 0) sm = sm.substr(4);
372 kp_stages = (!ctmc_stages && sm == "kp");
373 // `dae` is the OTHER route that carries a second moment along a stage
374 // trajectory: it integrates the linear-noise covariance beside the
375 // min-normal mean, and unlike `kp` its DRIFT reads that covariance, so
376 // a stage restarting at Sigma(0) = 0 gets the mean wrong as well as
377 // the variance. It needs a stage route of its own: `solver_fluid_transient`
378 // integrates the CLOSING drift whatever the method name says (only
379 // `solver_fluid_run_transient` dispatches, and it takes no output grid),
380 // so a `dae` stage sent there would be answered by a different method,
381 // report no covariance at all, and drop `init_qcov` in silence.
382 dae_stages = (!ctmc_stages && !kp_stages && sm == "dae");
383 }
384 // A CTMC STAGE IS NOT REFUSED under `meancov`: it takes a lattice STATE
385 // rather than a distribution, so the carried covariance simply does not
386 // seed it, which is what the reference does too. The coupling then keeps
387 // the timing variance of the sojourn and the spread of the per-origin
388 // means, and reports both.
389 if (!(opt.timespan_end > 0.0) || !std::isfinite(opt.timespan_end))
390 throw InputError(
391 "SolverENV: the stage transient needs a finite positive horizon, "
392 "options.timespan(2); the mean-field coupling integrates each stage's "
393 "TRAJECTORY against the holding-time CDF and has nothing to integrate over an "
394 "infinite one");
395 if (opt.tran_points < 2)
396 throw InputError("SolverENV: the transient grid needs at least two points");
397 if (!std::is_same<T, double>::value)
398 throw UnsupportedError(ErrorKind{"Arithmetic"},
399 "SolverENV: a fluid stage integrates its drift with LSODA, which is double only; "
400 "rerun with --arith double");
401
402 // `methodRefusal`: only 'smp' reads a stage's holding time off its arc
403 // CDFs; every other coupling needs the (D0,D1) race of its transitions.
404 envObj.require_declared();
405 envObj.refuse_semi_markov(opt.method);
406 if (smp && envObj.is_semi_markov() && opt.stage_solver == "fluid" &&
407 !envObj.has_lqn_stages()) {
408 const qn::NetworkStruct<T>& sn1 = envObj.stage(0).model;
409 for (std::size_t ind = 0; ind < sn1.nodes.size(); ++ind)
410 if (sn1.nodes[ind].nodetype == lang::NodeType::Cache)
411 throw UnsupportedError(ErrorKind{"Structure"},
412 "SolverENV: The mean-field cache fixed point integrates each stage's "
413 "holding time as a phase process inside the cache drift, which a "
414 "semi-Markov stage (a non-Markovian or kernel transition) does not have. "
415 "Fit the transitions to phase-type laws, or use a non-fluid stage solver.");
416 }
417 envObj.refuse_absorbing(opt.method);
418 envObj.init();
419 const std::size_t E = envObj.nstages();
420 build_lqn_stages();
421 for (std::size_t e = 1; e < E; ++e)
422 if (envObj.is_lqn(0) && envObj.is_lqn(e) && lnsolv[e]->nlayers() != lnsolv[0]->nlayers())
423 throw UnsupportedError(ErrorKind{"Method"},
424 "Stages 1 and " + std::to_string(e + 1) + " are layered networks whose layers "
425 "differ (a zero-capacity element removes the layers only a trapped reference task "
426 "reaches), and method '" + opt.method + "' couples the stages layer by layer. Use "
427 "method 'occupation'.");
428 M = stage_M(0);
429 K = stage_K(0);
430 for (std::size_t e = 1; e < E; ++e)
431 if (stage_M(e) != M || stage_K(e) != K)
432 throw InputError(
433 "SolverENV: every stage must have the same stations and classes; the metrics "
434 "are blended entrywise across them. A LAYERED stage counts the block-diagonal "
435 "union of the layers SolverLN builds for it, which is what layerBlocks "
436 "reports, so two layered stages agree exactly when they have the same layer "
437 "shape -- not merely the same LQN element count");
438 entry.assign(E, Matrix<double>(M, K, 0.0));
439 centry.assign(E, Matrix<double>(M * K, M * K, 0.0));
440 tranC.assign(E, std::vector<Matrix<double>>());
441 tranT.assign(E, std::vector<double>());
442 tranQ.assign(E, std::vector<std::vector<std::vector<double>>>());
443 tranU.assign(E, std::vector<std::vector<std::vector<double>>>());
444 tranTp.assign(E, std::vector<std::vector<std::vector<double>>>());
445 steady.assign(E, StageSteady());
446 steady_asked.assign(E, 0);
447 det_sojourn = (opt.sojourn == "deterministic");
448 dvals.assign(E, 0.0);
449 refresh_sojourn_means();
450 if (statedep) {
451 bool any = false;
452 for (std::size_t e = 0; e < E; ++e)
453 for (std::size_t h = 0; h < E; ++h)
454 if (envObj.arc(e, h).enabled && envObj.arc(e, h).reset_rates) any = true;
455 if (!any)
456 throw InputError(
457 "SolverENV: method 'statedep' updates each environment transition from the "
458 "state its stage is left in, and no arc carries a rate reset "
459 "(Environment::set_env_rate_reset, resetEnvRatesFun in the reference); the "
460 "run would be the mean-field fixed point under a different name");
461 }
462 }
463
464 // ---- layered (LayeredNetwork) stages ---------------------------------
465
466 /**
467 * Build one persistent `SolverLN` per LAYERED stage.
468 *
469 * ONE SOLVER FOR THE WHOLE RUN, not one per sweep, and that is the
470 * reference's arrangement too (`self.solvers{e}` outlives the iteration).
471 * It matters twice over: the layer ensemble is what carries the aggregate
472 * (station, class) SHAPE this coupling blends over, and it has to be known
473 * before any stage is solved; and the layered fixed point is solved ONCE,
474 * because a steady solve ignores the state it is started from -- only the
475 * transient reads it -- so re-running it every environment sweep would
476 * recompute the same numbers.
477 */
478 void build_lqn_stages() {
479 const std::size_t E = envObj.nstages();
480 lnsolv.assign(E, std::shared_ptr<ln::SolverLN<T>>());
481 lnblk.assign(E, ln::LnLayerBlocks());
482 if (!envObj.has_lqn_stages()) return;
483 if (ctmc_stages)
484 throw UnsupportedError(ErrorKind{"LayeredNetwork"},
485 "SolverENV: a LayeredNetwork stage is solved by SolverLN over its layers, not by "
486 "an enumerated CTMC over one stage generator -- an LQN has no single generator to "
487 "enumerate. Leave stage_solver at 'fluid', which names the engine each LAYER is "
488 "integrated with (EnvOptions::lqn.layer_solver)");
489 if (opt.lqn.layer_solver != "fluid")
490 throw UnsupportedError(ErrorKind{"LayeredNetwork"},
491 "SolverENV: a LayeredNetwork stage is carried across an environment switch by its "
492 "queue lengths, so the coupling needs the stage's TRANSIENT; among the layer "
493 "engines only the fluid one produces one, and this ensemble asks for '" +
494 opt.lqn.layer_solver + "' layers (EnvOptions::lqn.layer_solver)");
495 for (std::size_t e = 0; e < E; ++e) {
496 if (!envObj.is_lqn(e)) continue;
497 ln::LnOptions lo = opt.lqn;
498 // THE HORIZON AND THE GRID BELONG TO THE COUPLING. A stage lasts as
499 // long as the environment lets it, and the exit average is summed
500 // against the holding-time CDF, so neither is a property of the LQN
501 // and a caller's value for either would silently change what the
502 // Stieltjes sum integrates over. `tran_grid` is installed per sweep
503 // by `lqn_stage_transient`, since a state-dependent method may move
504 // the holding time between sweeps.
505 lo.timespan_end = opt.timespan_end;
506 lo.tran_points = opt.tran_points;
507 lo.tran_grid.clear();
508 lnsolv[e] = std::make_shared<ln::SolverLN<T>>(envObj.stage(e).lqn_model, lo);
509 lnblk[e] = lnsolv[e]->layer_blocks();
510 }
511 }
512
513 /** Stations of stage `e`: its own, or the block-diagonal union of its layers. */
514 std::size_t stage_M(std::size_t e) const {
515 return envObj.is_lqn(e) ? lnblk[e].M : envObj.stage(e).model.nstations;
516 }
517
518 /** Classes of stage `e`: its own, or the block-diagonal union of its layers. */
519 std::size_t stage_K(std::size_t e) const {
520 return envObj.is_lqn(e) ? lnblk[e].K : envObj.stage(e).model.nclasses;
521 }
522
523 /**
524 * One LAYERED stage's transient, assembled block-diagonally into the same
525 * `tranT`/`tranQ`/`tranU`/`tranTp` the flat path fills.
526 *
527 * `seed` says whether the fixed point has an entry vector yet, exactly as it
528 * does on the CTMC path: `pre()` runs before it has one, and warm-starting
529 * from an all-zero matrix would empty every closed layer rather than leave
530 * it on its default state.
531 *
532 * THE OFF-DIAGONAL BLOCKS ARE ZERO AND ARE ALLOCATED ANYWAY. They pair a
533 * station of one layer with a class of another and stand for nothing, which
534 * is why the reference leaves them as empty cells; here they must still be
535 * present and sized, because the convergence test reads the FIRST point of
536 * every (station, class) series and an absent series has no first point.
537 * Zero series contribute nothing to the blend and are skipped by `maxpe`,
538 * which ignores an exact value of zero -- so the two ports agree on the
539 * numbers as well as on the shape.
540 */
541 void lqn_stage_transient(std::size_t e, bool seed) {
542 ln::SolverLN<T>& s = *lnsolv[e];
543 s.init_from_marginal(seed ? entry[e] : Matrix<double>());
544 s.set_tran_grid(stage_grid(e));
545 const ln::LnTranSolution tr = s.get_tran_avg();
546 const ln::LnLayerBlocks& b = lnblk[e];
547
548 // Every layer was integrated on the same grid -- one horizon, one
549 // out_grid -- so layer one's is the stage's time base.
550 tranT[e].clear();
551 if (!tr.layers.empty()) tranT[e] = tr.layers[0].t;
552 const std::size_t P = std::max<std::size_t>(tranT[e].size(), 1);
553 tranQ[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
554 tranU[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
555 tranTp[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
556 if (tranT[e].empty()) return;
557
558 // `b` and the blocks were both derived from the SAME layer ensemble --
559 // it is built once, in SolverLN's constructor, and never resized -- so
560 // `msz`/`ksz` are the exact extents of each block and the offsets tile
561 // the aggregate exactly. Nothing here is a bounds guard.
562 for (std::size_t l = 0; l < tr.layers.size(); ++l) {
563 const ln::LnTranLayer& L = tr.layers[l];
564 for (std::size_t i = 0; i < b.msz[l]; ++i)
565 for (std::size_t r = 0; r < b.ksz[l]; ++r) {
566 const std::size_t row = b.roff[l] + i, col = b.coff[l] + r;
567 copy_series(L.QN[i][r], tranQ[e][row][col]);
568 copy_series(L.UN[i][r], tranU[e][row][col]);
569 copy_series(L.TN[i][r], tranTp[e][row][col]);
570 }
571 }
572 }
573
574 /**
575 * Copy a layer series onto the stage grid.
576 *
577 * The two lengths agree by construction -- every layer was integrated on the
578 * grid this stage's time base came from -- and the loop is written over
579 * whichever is shorter so that a layer whose integration was cut short
580 * leaves the tail at zero rather than reading past its own trajectory.
581 */
582 static void copy_series(const std::vector<double>& src, std::vector<double>& dst) {
583 for (std::size_t j = 0; j < dst.size() && j < src.size(); ++j) dst[j] = src[j];
584 }
585
586 /** The mean holding times the deterministic sojourn evaluates at. */
587 void refresh_sojourn_means() {
588 if (!det_sojourn) return;
589 for (std::size_t e = 0; e < envObj.nstages(); ++e)
590 dvals[e] = envObj.hold_time_mean(e);
591 }
592
593 /**
594 * `pre_`: seed every stage from its own solve at the first iteration, so
595 * the fixed point starts somewhere the model could be.
596 *
597 * Which solve is the reference's branch on the STAGE horizon: a finite one
598 * takes the LAST POINT of the stage transient, and only an infinite one --
599 * which this coupling refuses in `init`, since it has no transient to
600 * integrate -- takes the steady state. The distinction is not cosmetic on a
601 * stage that is unstable on its own -- a broken server offered more than it
602 * can serve -- where the steady state is whatever the integrator's own
603 * horizon happened to reach and is far from the transient at
604 * `timespan_end`. The seed only moves by the drift per environment cycle,
605 * so a seed off by a factor of ten costs iterations proportionally.
606 */
607 void pre() {
608 const std::size_t E = envObj.nstages();
609 for (std::size_t e = 0; e < E; ++e) {
610 if (envObj.is_lqn(e)) {
611 // The layered seed is the reference's own: the LAST POINT of the
612 // stage transient run from the layers' default state, which for
613 // a finite horizon is what `pre_` takes for every stage solver.
614 lqn_stage_transient(e, false);
615 if (tranT[e].empty()) continue;
616 const std::size_t last = tranT[e].size() - 1;
617 for (std::size_t i = 0; i < M; ++i)
618 for (std::size_t r = 0; r < K; ++r) entry[e](i, r) = tranQ[e][i][r][last];
619 continue;
620 }
621 if (ctmc_stages) {
622 // A CTMC stage has no "seed from nowhere" solve to run: it
623 // starts from the model's OWN initial state, which is what
624 // `initDefault` put there, so the first sweep enters at that
625 // state's queue lengths rather than at zero.
626 const ctmc::CtmcTransient<T> tr = ctmc_stage_transient(e, false, std::vector<double>());
627 if (tr.t.empty()) continue;
628 for (std::size_t i = 0; i < M; ++i)
629 for (std::size_t r = 0; r < K; ++r)
630 entry[e](i, r) = num_traits<T>::to_double(tr.QNt[i][r].back());
631 continue;
632 }
633 if (kp_stages) {
634 kp_stage_transient(e, false);
635 if (tranT[e].empty()) continue;
636 const std::size_t last = tranT[e].size() - 1;
637 for (std::size_t i = 0; i < M; ++i)
638 for (std::size_t r = 0; r < K; ++r) entry[e](i, r) = tranQ[e][i][r][last];
639 continue;
640 }
641 fluid::FluidOptions fo = opt.stage;
642 fo.init_sol.clear();
643 // THE SAME METHOD AS `analyze`: the entry vector this seeds the fixed
644 // point with is read off a stage trajectory, so taking it from the
645 // closing drift and then iterating with `dae` would start the loop at
646 // another method's answer.
647 const std::vector<fluid::FluidTranPoint> tr =
648 dae_stages ? fluid::solver_fluid_dae_transient(envObj.stage(e).model, fo,
649 opt.timespan_end, opt.tran_points)
650 : fluid::solver_fluid_transient(envObj.stage(e).model, fo,
651 opt.timespan_end, opt.tran_points);
652 if (tr.empty()) continue;
653 for (std::size_t i = 0; i < M; ++i)
654 for (std::size_t r = 0; r < K; ++r) entry[e](i, r) = tr.back().QN(i, r);
655 }
656 }
657
658 /** `analyze_`: the transient of one stage from its entry queue lengths. */
659 void analyze(std::size_t e) {
660 if (envObj.is_lqn(e)) {
661 lqn_stage_transient(e, true);
662 return;
663 }
664 tranT[e].clear();
665 tranC[e].clear();
666 tranQ[e].assign(M, std::vector<std::vector<double>>(K));
667 tranU[e].assign(M, std::vector<std::vector<double>>(K));
668 tranTp[e].assign(M, std::vector<std::vector<double>>(K));
669 if (ctmc_stages) {
670 // THE REFINED GRID, and it was MEASURED against the alternative.
671 // The reference forms its Stieltjes sum on whatever points ode23
672 // stopped at, so reporting on those looks like the faithful choice;
673 // on renv_threestages_repairmen it is the worse one -- Queue1 QLen
674 // 0.8344 against the reference's 0.83053, where the refined grid
675 // gives 0.83092. The refined grid puts 90% of its points under
676 // 5*E[S], which is where the holding-time CDF puts its mass, so it
677 // resolves the integrand rather than the trajectory. The residual
678 // 4.7e-4 is what is left of this quadrature difference.
679 const ctmc::CtmcTransient<T> tr = ctmc_stage_transient(e, true, stage_grid(e));
680 for (std::size_t j = 0; j < tr.t.size(); ++j) {
681 tranT[e].push_back(num_traits<T>::to_double(tr.t[j]));
682 for (std::size_t i = 0; i < M; ++i)
683 for (std::size_t r = 0; r < K; ++r) {
684 tranQ[e][i][r].push_back(num_traits<T>::to_double(tr.QNt[i][r][j]));
685 tranU[e][i][r].push_back(num_traits<T>::to_double(tr.UNt[i][r][j]));
686 tranTp[e][i][r].push_back(num_traits<T>::to_double(tr.TNt[i][r][j]));
687 }
688 }
689 return;
690 }
691 const qn::NetworkStruct<T>& sn = envObj.stage(e).model;
692 if (kp_stages) {
693 kp_stage_transient(e, true);
694 return;
695 }
696 fluid::FluidOptions fo = opt.stage;
697 fo.init_sol = initsol_from_marginal(sn, entry[e]);
698 // A `dae` stage takes the entry covariance as well as the entry mean, and
699 // gives its own back. Only that method reads `init_qcov`: offering it to a
700 // closing stage would be dropped in silence, which is the failure this
701 // predicate exists to avoid.
702 if (meancov && dae_stages) fo.init_qcov = centry[e];
703 const std::vector<fluid::FluidTranPoint> tr =
704 dae_stages ? fluid::solver_fluid_dae_transient(sn, fo, opt.timespan_end,
705 opt.tran_points, stage_grid(e))
706 : fluid::solver_fluid_transient(sn, fo, opt.timespan_end,
707 opt.tran_points, stage_grid(e));
708 for (const fluid::FluidTranPoint& p : tr) {
709 tranT[e].push_back(p.t);
710 for (std::size_t i = 0; i < M; ++i)
711 for (std::size_t r = 0; r < K; ++r) {
712 tranQ[e][i][r].push_back(p.QN(i, r));
713 tranU[e][i][r].push_back(p.UN(i, r));
714 tranTp[e][i][r].push_back(p.TN(i, r));
715 }
716 if (meancov && dae_stages && p.QCov.rows() == M * K) tranC[e].push_back(p.QCov);
717 }
718 // ALL OR NOTHING: `exit_cov` reads tranC[e] only when it has one matrix per
719 // point, so a run where some points carried a covariance and others did not
720 // must contribute none rather than a ragged series.
721 if (tranC[e].size() != tranT[e].size()) tranC[e].clear();
722 }
723
724 /**
725 * One stage's transient under the KO-PENDER limits, mean AND covariance.
726 *
727 * THE ONLY STAGE ROUTE THAT CARRIES A SECOND MOMENT in this port: the
728 * closing drift integrates a first moment alone, so a `meancov` run over
729 * closing stages keeps the TIMING variance of the sojourn and nothing of the
730 * within-stage spread. `kp` integrates both on one grid, which is why the
731 * covariance is read off the same solve as the mean rather than asked for
732 * again. Selected by `EnvOptions::stage.method`, so a caller asks for the
733 * limit by name exactly as a standalone fluid solve does.
734 *
735 * `seed` says whether the fixed point has an entry vector yet, as on the
736 * layered and CTMC paths: `pre()` runs before it has one, and seeding from
737 * an all-zero mean and covariance would start every stage empty rather than
738 * at this method's own stationary arrival phase.
739 */
740 void kp_stage_transient(std::size_t e, bool seed) {
741 tranT[e].clear();
742 tranC[e].clear();
743 const qn::NetworkStruct<T>& sn = envObj.stage(e).model;
744 fluid::FluidOptions fo = opt.stage;
745 fo.method = "kp";
746 fo.init_sol.clear();
747 fo.timespan_end = opt.timespan_end;
748 if (seed) {
749 fo.init_qlen = entry[e];
750 if (meancov) fo.init_qcov = centry[e];
751 }
752 fluid::FluidKpTransient tr;
753 fluid::solver_fluid_kp_core(sn, fo, &tr, stage_grid(e));
754 tranT[e] = tr.t;
755 const std::size_t P = tr.t.size();
756 tranQ[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
757 tranU[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
758 tranTp[e].assign(M, std::vector<std::vector<double>>(K, std::vector<double>(P, 0.0)));
759 // Mean and covariance come off the SAME integration, on the same grid, so
760 // the two moments this coupling carries need no interpolation onto one
761 // another and the covariance costs nothing beyond the aggregation.
762 for (std::size_t n = 0; n < P; ++n)
763 for (std::size_t i = 0; i < M; ++i)
764 for (std::size_t r = 0; r < K; ++r) {
765 tranQ[e][i][r][n] = tr.QN[n](i, r);
766 tranU[e][i][r][n] = tr.UN[n](i, r);
767 tranTp[e][i][r][n] = tr.TN[n](i, r);
768 }
769 if (meancov) tranC[e] = tr.QCov;
770 }
771
772 /**
773 * One stage's transient under an ENUMERATED CTMC, from its entry marginal.
774 *
775 * THE MARGINAL IS ROUNDED HERE and is not for a fluid stage, which is the
776 * reference's own split (`roundMarginalForDiscreteSolver` runs for every
777 * stage solver except SolverFluid): a chain has no state holding 1.7 jobs,
778 * so the fixed point's continuous iterate has to be placed on the lattice
779 * before it can be a state at all. The rounding conserves each closed
780 * class's population, so the placed state is one the chain actually
781 * contains.
782 *
783 * The state is written onto a COPY of the stage struct as its one-row
784 * declared state space, which is the same channel `setState` reaches
785 * `solver_ctmc_transient_analyzer` through -- so this seeds the stage
786 * exactly as a caller would, rather than through a private door.
787 */
788 ctmc::CtmcTransient<T> ctmc_stage_transient(std::size_t e, bool seed,
789 const std::vector<double>& grid) const {
790 qn::NetworkStruct<T> sn = envObj.stage(e).model;
791 // `seed` is explicit and not inferred from the arguments: `pre()` runs
792 // BEFORE the fixed point has an entry vector, and its all-zero matrix
793 // would place every job nowhere -- a state the chain does not have.
794 if (seed) seed_ctmc_state(sn, entry[e]);
795 std::vector<T> g;
796 g.reserve(grid.size());
797 for (std::size_t j = 0; j < grid.size(); ++j) g.push_back(num_traits<T>::from_double(grid[j]));
799 sn, ctmc_stage_options(), num_traits<T>::from_int(0),
800 num_traits<T>::from_double(opt.timespan_end), g);
801 }
802
803 /** The CTMC options a stage is solved with; the cutoff is the caller's. */
804 ctmc::CtmcOptions ctmc_stage_options() const {
805 ctmc::CtmcOptions co;
806 co.cutoff = opt.stage_cutoff;
807 return co;
808 }
809
810 /**
811 * Write the rounded entry marginal onto `sn` as its declared state.
812 *
813 * A row that the encoding cannot realize leaves the station alone, so the
814 * analyzer falls back to that station's default marking rather than being
815 * handed a state the chain does not contain -- an error the coupling could
816 * not act on, where the default is at least a state of the same model.
817 */
818 void seed_ctmc_state(qn::NetworkStruct<T>& sn, const Matrix<double>& Q) const {
819 if (Q.empty()) return;
820 const std::vector<std::vector<std::size_t>> nir = round_marginal(sn, Q);
821 for (std::size_t i = 0; i < sn.nstations && i < nir.size(); ++i) {
822 const std::size_t ind = sn.station_to_node[i]; // 1-based node index
823 // A SOURCE'S STATE IS NOT A FUNCTION OF ITS QUEUE LENGTH: `State.fromMarginal`'s EXT
824 // branch ignores n and seeds every generated class in phase one, which is the default
825 // row. Built from the zero marginal it is all-zero, a state the chain does not contain.
826 if (sn.stations[i].nodetype == lang::NodeType::Source) continue;
827 std::vector<std::size_t> ph(sn.nclasses, 1);
828 for (std::size_t r = 0; r < sn.nclasses; ++r) ph[r] = sn.phasessz_of(i + 1, r + 1);
829 std::vector<T> row;
830 if (!qn::from_marginal_node_first(sn, ind, nir[i], ph, row))
831 continue;
832 Matrix<T> space(1, row.size());
833 for (std::size_t c = 0; c < row.size(); ++c) space(0, c) = row[c];
834 sn.statespace[ind] = space;
835 sn.stateprior[ind] = std::vector<T>(1, num_traits<T>::from_int(1));
836 }
837 }
838
839 /**
840 * `roundMarginalForDiscreteSolver`: the continuous per-class marginal on the
841 * integer lattice, conserving each CLOSED class's population.
842 *
843 * Rounding entrywise does not conserve it -- three stations holding 0.5 jobs
844 * of a one-job class round to 0, 0, 0 and the class disappears from the
845 * chain -- so the residual is handed to the stations with the largest
846 * fractional parts, which is the reference's own rule.
847 */
848 std::vector<std::vector<std::size_t>> round_marginal(const qn::NetworkStruct<T>& sn,
849 const Matrix<double>& Q) const {
850 std::vector<std::vector<std::size_t>> n(sn.nstations, std::vector<std::size_t>(sn.nclasses, 0));
851 for (std::size_t r = 0; r < sn.nclasses; ++r) {
852 const double pop = sn.classes[r].population;
853 std::vector<double> frac(sn.nstations, 0.0);
854 long placed = 0;
855 for (std::size_t i = 0; i < sn.nstations; ++i) {
856 const double q = (i < Q.rows() && r < Q.cols()) ? std::max(0.0, Q(i, r)) : 0.0;
857 const double fl = std::floor(q);
858 n[i][r] = static_cast<std::size_t>(fl);
859 frac[i] = q - fl;
860 placed += static_cast<long>(fl);
861 }
862 if (!std::isfinite(pop)) continue; // an open class has no population to conserve
863 long want = static_cast<long>(std::llround(pop));
864 while (placed < want) {
865 std::size_t best = 0;
866 double bv = -1.0;
867 for (std::size_t i = 0; i < sn.nstations; ++i)
868 if (frac[i] > bv) { bv = frac[i]; best = i; }
869 ++n[best][r];
870 frac[best] = -1.0;
871 ++placed;
872 if (bv < 0.0) break; // nowhere left to place; the loop would not terminate
873 }
874 while (placed > want) {
875 std::size_t best = 0;
876 double bv = 2.0;
877 bool any = false;
878 for (std::size_t i = 0; i < sn.nstations; ++i)
879 if (n[i][r] > 0 && frac[i] < bv) { bv = frac[i]; best = i; any = true; }
880 if (!any) break;
881 --n[best][r];
882 frac[best] = 2.0;
883 --placed;
884 }
885 }
886 return n;
887 }
888
889 /** One stage's steady tables, in the (station, class) space of the blend. */
890 struct StageSteady {
891 Matrix<double> QN, UN, TN;
892 };
893
894 /**
895 * The stage's own STEADY tables, which are the exit value of every metric
896 * its transient carries no trajectory for.
897 *
898 * A METRIC WITH NO TRAJECTORY IS NOT A METRIC WORTH ZERO. A stage solver
899 * advances the quantities its integration actually carries; what it leaves
900 * out it holds CONSTANT over the stage, so the value at the exit instant is
901 * that constant whatever the sojourn was. `post()` and `finish()` used to
902 * skip such a stage outright and blend the zeros, reporting a stage whose
903 * metrics never move as ABSENT rather than as constant. That is the defect
904 * `stageSteady_` closes in the reference (`solver_env_meanfield_analyzer.m`)
905 * and `stageSteady`/`_stage_steady` close in the JAR and native python.
906 *
907 * THE HORIZON IS PUT ASIDE FOR THE ASK, and here it is not a refusal to
908 * dodge but a DIFFERENT ANSWER to avoid. The reference's `getAvg` rejects a
909 * finite `options.timespan` by name; `solver_fluid` instead ACCEPTS one and
910 * quietly stops its window iteration there, disarming both the fixed-point
911 * and the geometric-tail tests. Asked with the stage horizon still on the
912 * options it would hand back the state at `t = timespan_end` -- the very
913 * transient this seed exists to complement -- instead of the constant.
914 *
915 * THE ASK GOES TO THE ENGINE THAT PRODUCED THE TRAJECTORY, branch for branch
916 * with `analyze`: the closing drift's own fixed point for a closing stage
917 * (`solver_fluid_transient` integrates that drift whatever the method name
918 * says), `kp` and `dae` through their own steady entries, and the enumerated
919 * chain's stationary law for a CTMC stage. A seed taken from another closure
920 * would not be the constant the trajectory holds.
921 *
922 * A LAYERED STAGE IS NOT ASKED, exactly as the JAR's `stageSteadyAsk`
923 * refuses a non-`Network` stage by name: `SolverLN`'s steady table is over
924 * the LQN's OWN nodes -- hosts, tasks, entries, activities -- and not over
925 * the block-diagonal (station, class) aggregate this coupling blends in, so
926 * a leading-block copy would read LQN node k as class k. Its transient is
927 * already assembled in the right layout by `lqn_stage_transient`, and every
928 * cell it owns carries one.
929 *
930 * ASKED ONCE PER STAGE, AND ONLY WHERE IT IS READ. The stage networks do not
931 * change across sweeps -- `statedep` rewrites the environment arcs, not the
932 * models -- so the constants are the same every time; and a stage whose
933 * trajectory IS readable has every cell of the seed overwritten from it, so
934 * asking then would cost a steady solve per sweep to be discarded. That is
935 * this port's form of the reference's "read `result` first".
936 */
937 const StageSteady& stage_steady(std::size_t e) {
938 if (steady_asked[e]) return steady[e];
939 steady_asked[e] = 1;
940 StageSteady& out = steady[e];
941 out.QN = Matrix<double>(M, K, 0.0);
942 out.UN = Matrix<double>(M, K, 0.0);
943 out.TN = Matrix<double>(M, K, 0.0);
944 if (envObj.is_lqn(e)) return out;
945 const qn::NetworkStruct<T>& sn = envObj.stage(e).model;
946 if (ctmc_stages) {
947 const ctmc::CtmcAvg<T> a = ctmc::solver_ctmc_analyzer(sn, ctmc_stage_options()).avg;
948 copy_finite(a.QN, out.QN);
949 copy_finite(a.UN, out.UN);
950 copy_finite(a.TN, out.TN);
951 return out;
952 }
953 fluid::FluidOptions fo = opt.stage;
954 // NOTHING CARRIED IN: the constant is what the stage holds on its own,
955 // and the entry vector the handoff computes is the transient's business.
956 fo.init_sol.clear();
957 fo.init_qlen = Matrix<double>();
958 fo.init_qcov = Matrix<double>();
959 fo.timespan_end = std::numeric_limits<double>::infinity();
960 fluid::FluidSolution s;
961 if (kp_stages) {
962 fo.method = "kp";
963 s = fluid::solver_fluid_kp<T>(sn, fo);
964 } else if (dae_stages) {
965 fo.method = "dae";
966 s = fluid::solver_fluid_dae<T>(sn, fo);
967 } else {
968 fo.method = "closing";
969 s = fluid::solver_fluid<T>(sn, fo);
970 }
971 copy_finite(s.QN, out.QN);
972 copy_finite(s.UN, out.UN);
973 copy_finite(s.TN, out.TN);
974 return out;
975 }
976
977 /**
978 * Copy `src` onto `dst`, leaving every non-finite entry at zero: a NaN is
979 * ABSENT, not a number, and must not enter the probEnv blend.
980 *
981 * The two shapes agree by construction -- `init` refuses an environment
982 * whose stages differ in stations or classes, and a flat stage's own tables
983 * are (nstations, nclasses) -- so this is a copy and not a fit.
984 */
985 template <class S>
986 static void copy_finite(const Matrix<S>& src, Matrix<double>& dst) {
987 for (std::size_t i = 0; i < dst.rows(); ++i)
988 for (std::size_t r = 0; r < dst.cols(); ++r) {
989 const double v = num_traits<S>::to_double(src(i, r));
990 if (std::isfinite(v)) dst(i, r) = v;
991 }
992 }
993
994 /**
995 * The exit tables of stage `e`: its transient averaged over the sojourn on
996 * the cells that carry one, and its steady constants on the cells that do
997 * not. `want_all` asks for U and T beside Q, which only a state-dependent
998 * rate reads in `post()` and which `finish()` always reports.
999 *
1000 * THE WEIGHT IS THE STAGE SOJOURN, NOT THE e -> h CLOCK, and the reference
1001 * says so where it builds it: competing exponentials leave the exit TIME
1002 * independent of which destination won, so the exit average does not depend
1003 * on h at all -- h enters only through the reset applied on the way in.
1004 * Weighting by `proc[e][h]` instead read the transient over the mean of ONE
1005 * risk rather than of their minimum: on renv_twostages_repairmen, whose
1006 * Stage2 competes a 0.5 self arc with a 0.5 arc back, that is a mean of 2
1007 * against the sojourn's 1, and it reported Queue1 QLen 0.55882 against
1008 * MATLAB's 0.55550 with the two stations' throughputs 1.4% apart in a closed
1009 * cycle that admits one throughput.
1010 *
1011 * A TRAJECTORY THE COUPLING CANNOT AVERAGE IS NOT AN ANSWER OF ZERO. Two
1012 * cases reach `stage_steady` rather than a blend: a stage that reported no
1013 * transient at all, and one whose sojourn puts NO MASS on the grid, which is
1014 * this port's form of the reference's single-point transient (`tR = 1`, the
1015 * table `cdf_weights` can form no increment over). Both mean the same thing
1016 * -- nothing moved that the exit instant could be read off -- and the stage's
1017 * own constant is the exit value in both.
1018 */
1019 void stage_exit(std::size_t e, bool want_all, Matrix<double>& Qe, Matrix<double>& Ue,
1020 Matrix<double>& Te) {
1021 std::vector<double> w;
1022 double wsum = 0.0;
1023 if (!tranT[e].empty() && !det_sojourn) {
1024 w = cdf_weights(e, tranT[e]);
1025 for (double v : w) wsum += v;
1026 }
1027 if (tranT[e].empty() || (!det_sojourn && !(wsum > 0.0))) {
1028 const StageSteady& st = stage_steady(e);
1029 Qe = st.QN;
1030 if (want_all) {
1031 Ue = st.UN;
1032 Te = st.TN;
1033 }
1034 return;
1035 }
1036 for (std::size_t i = 0; i < M; ++i)
1037 for (std::size_t r = 0; r < K; ++r) {
1038 if (det_sojourn) {
1039 // Deterministic sojourn: the exit metrics are the transient
1040 // read at t = d_e.
1041 Qe(i, r) = detail::env_det_eval(tranT[e], tranQ[e][i][r], dvals[e]);
1042 if (!want_all) continue;
1043 Ue(i, r) = detail::env_det_eval(tranT[e], tranU[e][i][r], dvals[e]);
1044 Te(i, r) = detail::env_det_eval(tranT[e], tranTp[e][i][r], dvals[e]);
1045 continue;
1046 }
1047 Qe(i, r) = weighted(tranQ[e][i][r], w, wsum);
1048 if (!want_all) continue;
1049 Ue(i, r) = weighted(tranU[e][i][r], w, wsum);
1050 Te(i, r) = weighted(tranTp[e][i][r], w, wsum);
1051 }
1052 }
1053
1054 /**
1055 * `post_`: average each stage's transient over WHEN it hands over to each
1056 * destination, then re-seed every stage from its predecessors.
1057 */
1058 void post() {
1059 const std::size_t E = envObj.nstages();
1060 std::vector<std::vector<Matrix<double>>> Qexit(
1061 E, std::vector<Matrix<double>>(E, Matrix<double>(M, K, 0.0)));
1062 // The other two exit metrics are read only by a state-dependent rate,
1063 // which is given all three; without one they are never looked at, and
1064 // the reference computes them in the same loop regardless.
1065 std::vector<std::vector<Matrix<double>>> Uexit, Texit;
1066 if (statedep) {
1067 Uexit.assign(E, std::vector<Matrix<double>>(E, Matrix<double>(M, K, 0.0)));
1068 Texit.assign(E, std::vector<Matrix<double>>(E, Matrix<double>(M, K, 0.0)));
1069 }
1070 for (std::size_t e = 0; e < E; ++e) {
1071 // Seeded with the stage's own steady state rather than skipped: a
1072 // metric its transient carries no trajectory for is one it holds
1073 // CONSTANT, and calling that zero is what reported an open cache
1074 // model's Q, U and T as identically zero. see `stage_steady`
1075 Matrix<double> Qd(M, K, 0.0), Ud(M, K, 0.0), Td(M, K, 0.0);
1076 stage_exit(e, statedep, Qd, Ud, Td);
1077 for (std::size_t h = 0; h < E; ++h) {
1078 Qexit[e][h] = Qd;
1079 if (!statedep) continue;
1080 Uexit[e][h] = Ud;
1081 Texit[e][h] = Td;
1082 }
1083 if (dest_resolved(e)) exit_by_destination(e, Qexit, Uexit, Texit);
1084 }
1085
1086 // 'meancov' carries a COVARIANCE beside the mean, and it is taken over the
1087 // same sojourn weights the exit mean uses -- independent of the
1088 // destination, exactly as Qexit is, since competing exponentials leave the
1089 // exit time independent of where the switch goes.
1090 const std::size_t n2 = M * K;
1091 std::vector<Matrix<double>> Cexit;
1092 if (meancov) {
1093 Cexit.assign(E, Matrix<double>(n2, n2, 0.0));
1094 for (std::size_t e = 0; e < E; ++e)
1095 if (!tranT[e].empty()) Cexit[e] = stage_exit_cov(e);
1096 }
1097
1098 for (std::size_t e = 0; e < E; ++e) {
1099 if (tranT[e].empty()) continue;
1100 Matrix<double> Qe(M, K, 0.0);
1101 std::vector<double> ment(meancov ? n2 : 0, 0.0);
1102 Matrix<double> sent(meancov ? n2 : 0, meancov ? n2 : 0, 0.0);
1103 for (std::size_t h = 0; h < E; ++h) {
1104 const double p = envObj.prob_orig(h, e);
1105 if (!(p > 0.0)) continue;
1106 const ResetMarginal& f = envObj.arc(h, e).reset;
1107 const Matrix<double> reset = f ? f(Qexit[h][e]) : Qexit[h][e];
1108 if (reset.rows() != M || reset.cols() != K)
1109 throw InputError(
1110 "SolverENV: a reset policy returned a matrix of the wrong shape");
1111 for (std::size_t i = 0; i < M; ++i)
1112 for (std::size_t r = 0; r < K; ++r) Qe(i, r) += p * reset(i, r);
1113 if (!meancov) continue;
1114 // The reset is an arbitrary map on the means, so the covariance
1115 // crosses it by the delta method. The MIXTURE over origins is then
1116 // taken on the SECOND MOMENT, not on the covariances: a convex
1117 // combination of the C_h alone drops the spread of the per-origin
1118 // means, which is most of the variance when the stages differ.
1119 const Matrix<double> R = reset_jacobian(f, Qexit[h][e]);
1120 const Matrix<double> Ch = congruence(R, Cexit[h]);
1121 for (std::size_t a = 0; a < n2; ++a) {
1122 const double ma = reset(a % M, a / M);
1123 ment[a] += p * ma;
1124 for (std::size_t b = 0; b < n2; ++b)
1125 sent(a, b) += p * (Ch(a, b) + ma * reset(b % M, b / M));
1126 }
1127 }
1128 entry[e] = Qe;
1129 if (!meancov) continue;
1130 Matrix<double> Ce(n2, n2, 0.0);
1131 for (std::size_t a = 0; a < n2; ++a)
1132 for (std::size_t b = 0; b < n2; ++b)
1133 Ce(a, b) = 0.5 * ((sent(a, b) - ment[a] * ment[b]) +
1134 (sent(b, a) - ment[b] * ment[a]));
1135 centry[e] = Ce;
1136 }
1137
1138 // The state-dependent rates come AFTER the entry update, as they do in
1139 // the reference: the entries just computed used the probOrig of the
1140 // environment as it was during this iteration, and rewriting the arcs
1141 // first would blend them with weights from an environment the stage
1142 // transients were never solved under.
1143 if (!statedep) return;
1144 bool touched = false;
1145 for (std::size_t e = 0; e < E; ++e)
1146 for (std::size_t h = 0; h < E; ++h) {
1147 const EnvArc<T>& a = envObj.arc(e, h);
1148 if (!a.enabled || !a.reset_rates) continue;
1149 envObj.set_transition_dist(e, h,
1150 a.reset_rates(a.dist, Qexit[e][h], Uexit[e][h],
1151 Texit[e][h]));
1152 touched = true;
1153 }
1154 if (!touched) return;
1155 // Everything the analyzer integrates against -- the marked transition
1156 // processes, the superposed holding times, probEnv and probOrig -- is
1157 // derived from the arc distributions, so the environment is rebuilt
1158 // whole rather than patched.
1159 envObj.init();
1160 refresh_sojourn_means();
1161 }
1162
1163 /**
1164 * `finish_`: average each stage over its own holding time and blend the
1165 * stages by their stationary probabilities.
1166 */
1167 void finish(EnvSolution& out) {
1168 const std::size_t E = envObj.nstages();
1169 out.QExit.assign(E, Matrix<double>(M, K, 0.0));
1170 out.UExit.assign(E, Matrix<double>(M, K, 0.0));
1171 out.TExit.assign(E, Matrix<double>(M, K, 0.0));
1172 for (std::size_t e = 0; e < E; ++e)
1173 // Seeded with the stage's own steady state rather than skipped, for
1174 // the reason `post()` is: a metric with no trajectory is a constant
1175 // and not a zero, and here the zeros would be what the run REPORTS.
1176 // see `stage_steady`
1177 stage_exit(e, true, out.QExit[e], out.UExit[e], out.TExit[e]);
1178 for (std::size_t e = 0; e < E; ++e) {
1179 const double p = envObj.prob_env[e];
1180 for (std::size_t i = 0; i < M; ++i)
1181 for (std::size_t r = 0; r < K; ++r) {
1182 out.QN(i, r) += p * out.QExit[e](i, r);
1183 out.UN(i, r) += p * out.UExit[e](i, r);
1184 out.TN(i, r) += p * out.TExit[e](i, r);
1185 }
1186 }
1187 out.Qentry = entry;
1188 if (!meancov) return;
1189 // 'meancov' also REPORTS the second moment, mixed over the stages by the
1190 // same law of total variance the handoff uses. The term
1191 // sum_e p_e (m_e - m)(m_e - m)' is what the environment itself
1192 // contributes: two stages with identical within-stage variance but
1193 // different means still leave the queue length varying, and averaging the
1194 // per-stage covariances alone would report none of it.
1195 const std::size_t n = M * K;
1196 std::vector<double> mval(n, 0.0);
1197 Matrix<double> sval(n, n, 0.0);
1198 for (std::size_t e = 0; e < E; ++e) {
1199 const double p = envObj.prob_env[e];
1200 if (!(p > 0.0) || tranT[e].empty()) continue;
1201 const Matrix<double> Ce = stage_exit_cov(e);
1202 for (std::size_t a = 0; a < n; ++a) {
1203 const double ma = out.QExit[e](a % M, a / M);
1204 mval[a] += p * ma;
1205 for (std::size_t b = 0; b < n; ++b)
1206 sval(a, b) += p * (Ce(a, b) + ma * out.QExit[e](b % M, b / M));
1207 }
1208 }
1209 out.QCov = Matrix<double>(n, n, 0.0);
1210 out.QVar = Matrix<double>(M, K, 0.0);
1211 for (std::size_t a = 0; a < n; ++a) {
1212 for (std::size_t b = 0; b < n; ++b)
1213 out.QCov(a, b) = 0.5 * ((sval(a, b) - mval[a] * mval[b]) +
1214 (sval(b, a) - mval[b] * mval[a]));
1215 out.QVar(a % M, a / M) = std::max(0.0, out.QCov(a, a));
1216 }
1217 }
1218
1219 /**
1220 * The grid an exit average is summed on, refined where the SOJOURN WEIGHT
1221 * CANNOT SEE the integrator's own grid.
1222 *
1223 * The exit metric is `sum_k m(t_k) * [F(t_k) - F(t_{k-1})]` over the ODE
1224 * solver's OUTPUT grid, and that grid is chosen for the horizon rather than
1225 * for the sojourn: a stage integrated over [0,1e3] and read through an
1226 * Exp(1) clock puts almost every point where the weight is zero, so the
1227 * answer becomes an artifact of step placement.
1228 *
1229 * The grid is rebuilt UNCONDITIONALLY -- 90% of the points under `5*E[S]`
1230 * and the rest across the tail -- rather than only when the solver's own
1231 * grid looks too coarse. A "50 points inside the support is enough" escape
1232 * (native python's, before this) stops wherever the integrator's steps
1233 * happened to fall and does not converge: on renv_node_breakdown the sum
1234 * runs 0.462260, 0.460580, 0.459704, 0.459272, 0.459138, 0.459122 as the
1235 * point count goes 500 to 5e4, and this engine on a 1e5-point uniform grid
1236 * answers 0.459171. Rebuilding always is also what makes the four codebases
1237 * sum the SAME points, which is the property parity needs.
1238 */
1239 static constexpr std::size_t kCdfInterp = 5000;
1240
1241 static std::vector<double> refine_grid(double mean_hold, bool disabled,
1242 const std::vector<double>& t) {
1243 if (t.size() < 2) return t;
1244 // A DISABLED ARC is the 1 x 1 zero pair, and `map_mean` refuses it by
1245 // name ("zero arrival rate") rather than returning an infinity. Its
1246 // weights are identically zero, so the grid it would be summed on
1247 // cannot matter; leave it alone, exactly as cdf_weights does.
1248 if (disabled) return t;
1249 const double t0 = t.front();
1250 const double tend = t.back();
1251 double mean_sojourn = mean_hold;
1252 if (!(mean_sojourn > 0.0) || !std::isfinite(mean_sojourn))
1253 mean_sojourn = (tend - t0) / 10.0;
1254 double tcdf = std::min(tend, 5.0 * mean_sojourn);
1255 if (tcdf <= t0) tcdf = tend;
1256 const std::size_t ndense = static_cast<std::size_t>(0.9 * kCdfInterp);
1257 const std::size_t ntail = kCdfInterp - ndense;
1258 const bool with_tail = tcdf < tend && ntail > 1;
1259 std::vector<double> fine;
1260 fine.reserve(with_tail ? ndense + ntail : ndense);
1261 for (std::size_t k = 0; k < ndense; ++k)
1262 fine.push_back(t0 + (tcdf - t0) * static_cast<double>(k) /
1263 static_cast<double>(ndense - 1));
1264 if (with_tail)
1265 for (std::size_t k = 1; k <= ntail; ++k)
1266 fine.push_back(tcdf + (tend - tcdf) * static_cast<double>(k) /
1267 static_cast<double>(ntail));
1268 return fine;
1269 }
1270
1271 /**
1272 * The OUTPUT GRID stage `e` is integrated on: the refined one, so the exit
1273 * average is summed over points the trajectory was actually evaluated at.
1274 *
1275 * Interpolating instead cannot recover resolution the trajectory never had
1276 * -- over [0,1e3] a uniform 1001-point grid carries SIX samples below
1277 * `5*E[S]` for an `Exp(1)` sojourn, and a piecewise-linear reading of six
1278 * samples is still six samples' worth of information; it reported a
1279 * throughput of 0.8099 for a source admitting 0.8. LSODA takes an arbitrary
1280 * increasing output vector, so the points are simply asked for.
1281 *
1282 * The scale is the HOLDING time, which is the sojourn regardless of which
1283 * destination fires (competing exponentials), so one grid serves post()'s
1284 * per-destination weights and finish()'s holding-time ones alike.
1285 */
1286 std::vector<double> stage_grid(std::size_t e) const {
1287 std::vector<double> ends(2);
1288 ends[0] = 0.0;
1289 ends[1] = opt.timespan_end;
1290 const std::vector<double> g =
1291 refine_grid(hold_disabled(e) ? 0.0 : envObj.hold_time_mean(e), hold_disabled(e), ends);
1292 // A disabled holding time leaves `refine_grid` with nothing to say; fall
1293 // back to the uniform grid the option asks for.
1294 if (g.size() < 3) return std::vector<double>();
1295 return g;
1296 }
1297
1298 /**
1299 * The covariance of the station-class queue lengths at the instant stage `e`
1300 * is left, by the law of total variance over the random sojourn T:
1301 *
1302 * Cov[Q(T)] = E_T[Cov(Q(t)|t)] + Cov_T[E(Q(t)|t)]
1303 * = sum_n w_n (C(t_n) + m(t_n) m(t_n)')/sum(w) - m_exit m_exit'
1304 *
1305 * with the SAME weights the exit mean uses, so the two are consistent by
1306 * construction. `C(t)` is the within-stage covariance the stage solver
1307 * integrated and is absent -- hence zero -- for a stage carrying a first
1308 * moment only; the timing term survives regardless. A deterministic sojourn
1309 * contributes no timing variance, so the answer is `C(d)` alone.
1310 */
1311 Matrix<double> stage_exit_cov(std::size_t e) const {
1312 const std::size_t n = M * K;
1313 Matrix<double> C(n, n, 0.0);
1314 const std::vector<double>& t = tranT[e];
1315 if (t.size() < 2) return C;
1316 const bool have_c = tranC[e].size() == t.size();
1317 if (det_sojourn) {
1318 if (!have_c) return C;
1319 // Clamped to the stage's own grid rather than extrapolated: linear
1320 // extrapolation of a covariance can leave the positive semidefinite
1321 // cone, while a convex combination of two members stays inside it.
1322 double d = std::max(t.front(), std::min(dvals[e], t.back()));
1323 std::size_t j = t.size() - 1;
1324 for (std::size_t k = 1; k < t.size(); ++k)
1325 if (d <= t[k]) {
1326 j = k;
1327 break;
1328 }
1329 const double dt = t[j] - t[j - 1];
1330 const double a = (dt > 0.0) ? (d - t[j - 1]) / dt : 0.0;
1331 for (std::size_t p = 0; p < n; ++p)
1332 for (std::size_t q = 0; q < n; ++q)
1333 C(p, q) = (1.0 - a) * tranC[e][j - 1](p, q) + a * tranC[e][j](p, q);
1334 return C;
1335 }
1336 const std::vector<double> w = cdf_weights(e, t);
1337 double wsum = 0.0;
1338 for (double v : w) wsum += v;
1339 if (!(wsum > 0.0)) return C;
1340 std::vector<double> mexit(n, 0.0);
1341 for (std::size_t a = 0; a < n; ++a)
1342 mexit[a] = weighted(tranQ[e][a % M][a / M], w, wsum);
1343 for (std::size_t a = 0; a < n; ++a) {
1344 const std::vector<double>& qa = tranQ[e][a % M][a / M];
1345 for (std::size_t b = 0; b < n; ++b) {
1346 const std::vector<double>& qb = tranQ[e][b % M][b / M];
1347 double acc = 0.0;
1348 for (std::size_t k = 0; k < w.size() && k < qa.size() && k < qb.size(); ++k)
1349 acc += w[k] * qa[k] * qb[k];
1350 if (have_c)
1351 for (std::size_t k = 0; k < w.size(); ++k) acc += w[k] * tranC[e][k](a, b);
1352 C(a, b) = acc / wsum - mexit[a] * mexit[b];
1353 }
1354 }
1355 for (std::size_t a = 0; a < n; ++a)
1356 for (std::size_t b = a + 1; b < n; ++b) {
1357 const double v = 0.5 * (C(a, b) + C(b, a)); // drop the rounding asymmetry
1358 C(a, b) = v;
1359 C(b, a) = v;
1360 }
1361 return C;
1362 }
1363
1364 /**
1365 * The Jacobian of a reset policy at the exit mean, so a covariance can cross
1366 * the switch as `R C R'`.
1367 *
1368 * A reset policy is an arbitrary map on the (station x class) mean queue
1369 * lengths, and there is no general way to push a second moment through one.
1370 * The delta method is the first-order image, which is the order the whole
1371 * mean-field coupling works to. The two NAMED policies are linear and R is
1372 * then EXACT: the identity for `keep`, zero for `clear`. Linear indexing is
1373 * column-major, `ir = r*M + i`, the same index space as the covariance.
1374 */
1375 Matrix<double> reset_jacobian(const ResetMarginal& f, const Matrix<double>& qexit) const {
1376 const std::size_t n = M * K;
1377 Matrix<double> R(n, n, 0.0);
1378 if (!f) { // the identity map: no reset on this arc
1379 for (std::size_t a = 0; a < n; ++a) R(a, a) = 1.0;
1380 return R;
1381 }
1382 const Matrix<double> base = f(qexit);
1383 if (base.rows() != M || base.cols() != K) return R;
1384 double scale = 1.0;
1385 for (std::size_t i = 0; i < M; ++i)
1386 for (std::size_t r = 0; r < K; ++r) scale = std::max(scale, std::fabs(qexit(i, r)));
1387 const double step = 1e-6 * scale;
1388 for (std::size_t j = 0; j < n; ++j) {
1389 Matrix<double> qp = qexit;
1390 qp(j % M, j / M) += step;
1391 const Matrix<double> pert = f(qp);
1392 if (pert.rows() != M || pert.cols() != K) continue;
1393 for (std::size_t a = 0; a < n; ++a)
1394 R(a, j) = (pert(a % M, a / M) - base(a % M, a / M)) / step;
1395 }
1396 return R;
1397 }
1398
1399 /** The congruence `R C R'`, the image of a covariance under a linear map. */
1400 static Matrix<double> congruence(const Matrix<double>& R, const Matrix<double>& C) {
1401 const std::size_t n = R.rows();
1402 Matrix<double> RC(n, n, 0.0), out(n, n, 0.0);
1403 for (std::size_t a = 0; a < n; ++a)
1404 for (std::size_t b = 0; b < n; ++b) {
1405 double acc = 0.0;
1406 for (std::size_t c = 0; c < n; ++c) acc += R(a, c) * C(c, b);
1407 RC(a, b) = acc;
1408 }
1409 for (std::size_t a = 0; a < n; ++a)
1410 for (std::size_t b = 0; b < n; ++b) {
1411 double acc = 0.0;
1412 for (std::size_t c = 0; c < n; ++c) acc += RC(a, c) * R(b, c);
1413 out(a, b) = acc;
1414 }
1415 return out;
1416 }
1417
1418 /**
1419 * True when stage e's holding time is the disabled 1 x 1 zero pair, whose
1420 * CDF is identically zero. A semi-Markov stage never is: it has arcs.
1421 */
1422 bool hold_disabled(std::size_t e) const {
1423 if (envObj.hold_time_is_semi_markov(e)) return false;
1424 const mam::Map<double> m = envObj.hold_time[e].map();
1425 for (std::size_t a = 0; a < m.D1.rows(); ++a)
1426 for (std::size_t b = 0; b < m.D1.cols(); ++b)
1427 if (m.D1(a, b) != 0.0) return false;
1428 return true;
1429 }
1430
1431 /**
1432 * The Stieltjes weights of stage e's holding time over the transient grid,
1433 * through `Environment::hold_time_cdf` (`Environment.holdTimeCdf`): the MAP
1434 * for a Markovian stage, the arc race or kernel mixture for a semi-Markov one.
1435 */
1436 std::vector<double> cdf_weights(std::size_t e, const std::vector<double>& t) const {
1437 std::vector<double> w(t.size(), 0.0);
1438 if (t.size() < 2) return w;
1439 if (hold_disabled(e)) return w;
1440 const std::vector<double> F = envObj.hold_time_cdf(e, t);
1441 for (std::size_t j = 1; j < t.size(); ++j) w[j] = F[j] - F[j - 1];
1442 return w;
1443 }
1444
1445 /**
1446 * `destResolved_`: whether stage e's exit has to be averaged per destination.
1447 *
1448 * Only under 'smp', and only where the exit time depends on the destination:
1449 * a stage with two or more successors whose transitions are not all
1450 * competing exponentials. With competing exponentials the race leaves the
1451 * exit time independent of where the switch goes, and the destination-free
1452 * average is exact. A deterministic sojourn reads every exit at d_e and is
1453 * not resolved, as the reference's branch for it returns first.
1454 */
1455 bool dest_resolved(std::size_t e) const {
1456 if (!smp || det_sojourn) return false;
1457 const std::size_t E = envObj.nstages();
1458 std::size_t nsucc = 0;
1459 for (std::size_t h = 0; h < E; ++h)
1460 if (envObj.pemb(e, h) > 0.0) ++nsucc;
1461 if (nsucc < 2) return false;
1462 if (envObj.is_kernel_stage(e)) return true;
1463 std::vector<lang::Distrib<T>> arcs;
1464 std::vector<double> probs;
1465 envObj.stage_arcs(e, arcs, probs);
1466 for (const lang::Distrib<T>& a : arcs)
1467 if (!a.disabled && a.type != lang::ProcessType::EXP) return true;
1468 return false;
1469 }
1470
1471 /**
1472 * `exitByDestination_`: exit metrics of stage e for each destination h,
1473 * averaged over the cumulative incidence CIF_eh of `mc::smp_stage_cif`
1474 * rather than over the holding-time CDF. In a semi-Markov stage a switch to
1475 * h happens at the times the e -> h transition wins, so the state it hands
1476 * over is the transient read at those times. The weights of all
1477 * destinations add up to the holding-time weight, so the probEmb-mixture of
1478 * these exits is the destination-free average. A destination with no
1479 * trajectory or no weight keeps the stage's steady state. U and T are
1480 * resolved only where they are read, by a state-dependent rate.
1481 */
1482 void exit_by_destination(std::size_t e, std::vector<std::vector<Matrix<double>>>& Qexit,
1483 std::vector<std::vector<Matrix<double>>>& Uexit,
1484 std::vector<std::vector<Matrix<double>>>& Texit) {
1485 const std::size_t E = envObj.nstages();
1486 const StageSteady& st = stage_steady(e);
1487 for (std::size_t h = 0; h < E; ++h) {
1488 Qexit[e][h] = st.QN;
1489 if (!statedep) continue;
1490 Uexit[e][h] = st.UN;
1491 Texit[e][h] = st.TN;
1492 }
1493 if (tranT[e].empty()) return;
1494 std::vector<lang::Distrib<T>> arcs;
1495 std::vector<double> probs;
1496 envObj.stage_arcs(e, arcs, probs);
1497 const mc::SmpStageCif c = mc::smp_stage_cif(arcs, probs, tranT[e]);
1498 const std::size_t nt = tranT[e].size();
1499 for (std::size_t h = 0; h < E; ++h) {
1500 if (!(envObj.pemb(e, h) > 0.0)) continue;
1501 std::vector<double> w(nt, 0.0);
1502 double wsum = 0.0;
1503 bool nan = false;
1504 for (std::size_t j = 1; j < nt; ++j) {
1505 w[j] = c.C(h, j) - c.C(h, j - 1);
1506 if (std::isnan(w[j])) nan = true;
1507 wsum += w[j];
1508 }
1509 if (nan || !(wsum > 0.0)) continue;
1510 for (std::size_t i = 0; i < M; ++i)
1511 for (std::size_t r = 0; r < K; ++r) {
1512 Qexit[e][h](i, r) = weighted(tranQ[e][i][r], w, wsum);
1513 if (!statedep) continue;
1514 Uexit[e][h](i, r) = weighted(tranU[e][i][r], w, wsum);
1515 Texit[e][h](i, r) = weighted(tranTp[e][i][r], w, wsum);
1516 }
1517 }
1518 }
1519
1520 static double weighted(const std::vector<double>& v, const std::vector<double>& w,
1521 double wsum) {
1522 double s = 0.0;
1523 for (std::size_t j = 0; j < v.size() && j < w.size(); ++j) s += v[j] * w[j];
1524 return s / wsum;
1525 }
1526
1527 /**
1528 * `initFromMarginal` for a fluid stage: the mean queue length of every
1529 * (station, class) enters in PHASE ONE.
1530 *
1531 * The reference does NOT round this for a fluid stage
1532 * (`roundMarginalForDiscreteSolver` is skipped when the stage solver is
1533 * SolverFluid), because a fluid state is continuous -- rounding it would
1534 * quantize the very quantity the fixed point is iterating on.
1535 */
1536 std::vector<double> initsol_from_marginal(const qn::NetworkStruct<T>& sn,
1537 const Matrix<double>& Q) const {
1538 const fluid::FluidLayout L = fluid::fluid_layout(sn);
1539 std::vector<double> y(L.nstates, 0.0);
1540 for (std::size_t i = 0; i < sn.nstations && i < Q.rows(); ++i)
1541 for (std::size_t r = 0; r < sn.nclasses && r < Q.cols(); ++r) {
1542 if (!L.enabled[i][r]) continue;
1543 y[L.qidx[i][r]] = std::max(0.0, Q(i, r));
1544 }
1545 return y;
1546 }
1547
1548 Environment<T>& envObj;
1549 EnvOptions opt;
1550 std::size_t M = 0, K = 0;
1551 bool det_sojourn = false;
1552 bool statedep = false; ///< `method = "statedep"`: the arcs are rewritten each sweep
1553 bool smp = false; ///< `method = "smp"`: exits resolved by destination
1554 bool ctmc_stages = false; ///< `stage_solver = "ctmc"`: enumerate each stage's chain
1555 bool meancov = false; ///< `method = "meancov"`: a covariance crosses beside the mean
1556 bool kp_stages = false; ///< `stage.method = "kp"`: the Ko-Pender limits run each stage
1557 bool dae_stages = false; ///< `stage.method = "dae"`: the linear-noise DAE runs each stage
1558 std::vector<double> dvals;
1559 std::vector<Matrix<double>> entry; ///< per-stage entry queue lengths
1560 /**
1561 * `meancov` only: per-stage entry COVARIANCE over the (station, class)
1562 * pairs, the companion of `entry`, and the WITHIN-STAGE covariance the stage
1563 * solver integrated, one matrix per point of `tranT`. `tranC[e]` is empty
1564 * for a stage solver carrying a first moment only, and the coupling then
1565 * keeps the timing variance of the sojourn alone.
1566 */
1567 std::vector<Matrix<double>> centry;
1568 std::vector<std::vector<Matrix<double>>> tranC;
1569 std::vector<std::vector<double>> tranT;
1570 std::vector<std::vector<std::vector<std::vector<double>>>> tranQ, tranU, tranTp;
1571 /**
1572 * Per stage, its steady tables and whether they have been asked for: the
1573 * exit value of every metric its transient carries no trajectory for. Asked
1574 * at most once per run and only where one is read; see `stage_steady`.
1575 */
1576 std::vector<StageSteady> steady;
1577 std::vector<char> steady_asked;
1578 /** Per LAYERED stage, the SolverLN that runs it; null for a flat stage. */
1579 std::vector<std::shared_ptr<ln::SolverLN<T>>> lnsolv;
1580 /** Per LAYERED stage, where each of its layers sits in the aggregate view. */
1581 std::vector<ln::LnLayerBlocks> lnblk;
1582};
1583
1584} // namespace env
1585} // namespace line
1586
1587#endif // LINE_SOLVERS_ENV_SOLVER_ENV_H
InputError(const std::string &what)
Definition error.h:105
UnsupportedError(const std::string &what)
Definition error.h:119
SolverEnv(Environment< T > &e, const EnvOptions &o)
Definition solver_env.h:262
EnvSolution solve()
Definition solver_env.h:264
Equilibrium distribution of a discrete-time Markov chain, and stochastic complementation.
A random environment: a port of matlab/src/lang/Environment.m, restricted to what SolverENV reads out...
The exception types the port throws.
The min-normal closure as a DIFFERENTIAL-ALGEBRAIC system: solver_fluid_dae.m.
Port of solver_fluid_kp.m: the fluid AND diffusion limits of the (MAP_t/Ph_t/inf)^N network of Y.
Cumulative distribution of the inter-arrival time of a MAP.
Markovian arrival process descriptors: stationary vectors, rate, moments, autocorrelation and the ind...
Dense matrix and non-owning view.
The option and result types every MVA analyzer shares.
CtmcTransient< T > solver_ctmc_transient_analyzer(const NetworkStruct< T > &sn_arg, const CtmcOptions &opt, const T &t0, const T &t1, const std::vector< T > &grid=std::vector< T >())
Port of solver_ctmc_transient_analyzer.m.
CtmcSolution< T > solver_ctmc_analyzer(const NetworkStruct< T > &sn_in, const CtmcOptions &opt)
Port of solver_ctmc_analyzer.m plus the fork-join wrapper of @@SolverCTMC/runAnalyzer....
std::function< Matrix< double >(const Matrix< double > &)> ResetMarginal
The reset policy of a transition, resetFun in the reference.
Definition environment.h:98
ln::LnOptions env_default_lqn_options()
LnOptions as a LAYERED environment stage is solved with.
Definition solver_env.h:113
FluidSolution solver_fluid_kp(const qn::NetworkStruct< T > &sn, const FluidOptions &opt)
Port of solver_fluid_kp.m: the steady table at the horizon.
Definition fluid_kp.h:1055
FluidLayout fluid_layout(const qn::NetworkStruct< T > &sn)
Port of the layout half of solver_fluid_odes.m.
Definition fluid_odes.h:282
FluidSolution solver_fluid_dae(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, const FluidDaeOptions &dopt_in=FluidDaeOptions())
solver_fluid_dae.m: the min-normal closure solved as one system.
Definition fluid_dae.h:2153
std::vector< FluidTranPoint > solver_fluid_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 >())
Port of @@SolverFLD/getTranAvg: the metrics along the trajectory, not just at the fixed point.
FluidSolution solver_fluid(const qn::NetworkStruct< T > &sn_in, const FluidOptions &opt, qn::NetworkStruct< T > *sn_out=nullptr)
Port of solver_fluid_analyzer.m: dispatch on the method, refit the non-exponential FCFS stations the ...
std::vector< FluidTranPoint > solver_fluid_dae_transient(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, double t_end, std::size_t points=101, const std::vector< double > &out_grid=std::vector< double >(), const FluidDaeOptions &dopt_in=FluidDaeOptions())
@@SolverFLD/getTranAvg for the DAE route: the metrics ALONG the trajectory.
Definition fluid_dae.h:2611
FluidSolution solver_fluid_kp_core(const qn::NetworkStruct< T > &sn, const FluidOptions &opt, FluidKpTransient *tran, const std::vector< double > &out_grid=std::vector< double >())
The Ko-Pender solve, returning both the steady table and the covariance trajectory so that neither ha...
Definition fluid_kp.h:314
SmpStageCif smp_stage_cif(const std::vector< lang::Distrib< T > > &arcs, const std::vector< double > &probs, const std::vector< double > &t)
CIF and holding-time CDF of a stage at the ascending instants t.
bool from_marginal_node_first(const NetworkStruct< T > &sn, std::size_t ind, const std::vector< std::size_t > &n, const std::vector< std::size_t > &phases, std::vector< T > &out)
The FIRST row from_marginal_node emits, BUILT rather than enumerated.
Definition state.h:2599
Conservation laws of a layered queueing network, enumerated from its structure.
Definition aoi_dist2ph.h:52
Controls and result shape shared by the normalizing-constant analyzers.
A queueing network and its refreshed NetworkStruct.
Cumulative incidence functions of one semi-Markov stage.
Port of solver_ctmc_transient_analyzer.m: the time-dependent counterpart of solver_ctmc_analyzer,...
SolverFluid: the closing method, a port of solver_fluid.m, solver_fluid_iteration....
SolverLN: layered decomposition of a layered queueing network.
The <kind> part of a LINE:<category>:<kind> identifier (see _kb/17-error-vocabulary....
Definition error.h:37
Options of SolverENV.
Definition solver_env.h:120
std::string method
The inter-stage coupling: meanfield is the reference's default.
Definition solver_env.h:143
std::vector< double > env_init
Method occupation: options.config.env_init, the initial probability of each stage.
Definition solver_env.h:167
std::string stage_solver
Which solver runs each FLAT stage: fluid (the fluid transient), ctmc (the enumerated chain),...
Definition solver_env.h:137
std::string sojourn
options.sojourn: stochastic (default) or deterministic.
Definition solver_env.h:145
fluid::FluidOptions stage
Options handed to each stage solver.
Definition solver_env.h:147
double stage_cutoff
options.cutoff of a CTMC stage, read only when stage_solver is ctmc.
Definition solver_env.h:152
double timespan_end
options.timespan(2) of the inner solver: the transient horizon.
Definition solver_env.h:154
double env_timespan_start
Method occupation: SolverENV's OWN options.timespan, [t0, t1].
Definition solver_env.h:161
ln::LnOptions lqn
Options of the SolverLN that runs a LAYERED stage.
Definition solver_env.h:197
nc::NcSolverOptions stage_nc
Options of an nc stage solver, SolverNC(model, options).
Definition solver_env.h:141
mva::MvaOptions stage_mva
Options of an mva stage solver, SolverMVA(model, options).
Definition solver_env.h:139
std::size_t tran_points
Points on a UNIFORM transient grid, used only where stage_grid declines to build one (a stage whose h...
Definition solver_env.h:180
What SolverENV reports.
Definition solver_env.h:201
Matrix< double > UN
Definition solver_env.h:203
std::vector< Matrix< double > > Qentry
The entry queue lengths the fixed point converged to.
Definition solver_env.h:207
Matrix< double > QCov
meancov only: the environment-wide queue-length COVARIANCE over the (station, class) pairs,...
Definition solver_env.h:214
Matrix< double > TN
Definition solver_env.h:203
Matrix< double > QN
Environment-averaged metrics, (nstations x nclasses).
Definition solver_env.h:203
Matrix< double > QVar
Definition solver_env.h:214
std::vector< Matrix< double > > TExit
Definition solver_env.h:205
std::vector< Matrix< double > > UExit
Definition solver_env.h:205
std::vector< Matrix< double > > QExit
Per-stage, sojourn-averaged metrics.
Definition solver_env.h:205
Controls, defaulting to SolverOptions('Fluid') in the reference.
Options of SolverLN.
Definition solver_ln.h:275
std::string layer_solver
Which solver runs each layer: mva, nc, fluid, ssa, mam, ctmc, ldes (the native in-process LDES engine...
Definition solver_ln.h:318
The options SolverMVA reads.
Definition mva_types.h:32
Controls, defaulting to SolverOptions('NC') in the reference.
Definition nc_types.h:33