1function [QN,UN,RN,TN,xvec_it,QNt,UNt,TNt,xvec_t,t,iters,runtime] = solver_fluid_matrix(sn, options)
3% [QN,UN,RN,TN,CN,RUNTIME] = SOLVER_FLUID_MATRIX(QN, OPTIONS)
5% Copyright (c) 2012-2026, Imperial College London
8M = sn.nstations; %number of stations
9K = sn.nclasses; %number of classes
12% An NHPP stores {breakpoints, rates, cyclic} in sn.proc, not a {D0,D1} MAP.
13% The matrix method
is a steady-state method, so
the process enters at its
14% nominal (time-average) rate as a single-phase exponential MAP;
the
15% time-varying intensity
is a transient
property handled by
the closing
16% method
's per-event rate multiplier (solver_fluid_ratemult).
17if isfield(sn,'procid
')
20 if sn.procid(ist,r) == ProcessType.NHPP
21 lam = sn.rates(ist,r);
22 if isfinite(lam) && lam > 0
23 PH{ist}{r} = {-lam, lam};
30P_full = sn.rt; % Full routing matrix (stateful nodes)
31NK = sn.njobs'; %initial population
34S(infServers) = sum(NK);
36%refstat = sn.refstat; % reference station
39% Extract station-to-station routing matrix from stateful-to-stateful matrix
40%
using stochastic complementation to resolve routing through non-station
44 isf = sn.stationToStateful(ist);
46 station_indices = [station_indices, (isf-1)*K + r];
49P = dtmc_stochcomp(P_full, station_indices);
51% Remove Sink->Source feedback routing
for open classes
52% In open networks, jobs exit at Sink and should not recirculate back to Source.
53% The routing matrix includes
this feedback (added by getRoutingMatrix.m
for
54% pseudo-closed network analysis), but it causes incorrect flow balance in
the
55% fluid ODE formulation because arrivals are already accounted
for via Alambda.
57 if sn.sched(src_ist) == SchedStrategy.EXT
58 % This
is a Source station - remove feedback routing TO it
for open classes
60 % Check
if this is an open
class with external arrivals
61 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
62 % Zero out routing TO
this Source
for this class from all other stations
63 src_col = (src_ist - 1) * K + r; % Column index in
P for (Source,
class r)
65 if from_ist ~= src_ist % Don
't modify Source's own outgoing routing
67 from_row = (from_ist - 1) * K + from_r;
68 P(from_row, src_col) = 0; % Remove feedback to Source
77% ODE building as per Ruuskanen et al., PEVA 151 (2021).
88 Psi = blkdiag(Psi,PH{ist}{r}{1});
89 B = blkdiag(B,sum(PH{ist}{r}{2},2));
90 A = blkdiag(A,pie{ist}{r}
');
96% Build arrival rate vector Aλ
for mixed/open networks (Ruuskanen et al., PEVA 151 (2021), Eq. 7)
97% Following
the ground truth implementation: Source
is conceptually excluded from state space.
98% Arrivals go directly to queue phases (not Source phases) weighted by routing probabilities.
99% This matches dx = W
' * theta + A * lambda where lambda represents arrivals INTO queues.
101% First, identify Source stations and their arrival rates per class
102source_arrivals = zeros(M, K); % source_arrivals(src, r) = arrival rate at source src for class r
104 if sn.sched(src_ist) == SchedStrategy.EXT
106 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
107 source_arrivals(src_ist, r) = sn.rates(src_ist, r);
113% Build Alambda: arrivals go to QUEUE phases (where jobs route from Source), not Source phases
114Alambda_full = zeros(size(A,1), 1);
118 if nphases(ist,r) > 0
119 if sn.sched(ist) == SchedStrategy.EXT
120 % Source station: do NOT add arrivals here (arrivals go to downstream queues)
121 state = state + nphases(ist,r);
123 % Queue station: check if it receives arrivals from any Source
124 arrival_rate_to_queue = 0;
126 if source_arrivals(src_ist, r) > 0
127 % Get routing probability from Source to this queue for this class
128 src_row = (src_ist - 1) * K + r;
129 queue_col = (ist - 1) * K + r;
130 routing_prob = P(src_row, queue_col);
131 arrival_rate_to_queue = arrival_rate_to_queue + source_arrivals(src_ist, r) * routing_prob;
135 if arrival_rate_to_queue > 0
136 % Apply arrivals according to entrance probability ζ^{i,r}
137 for k=1:nphases(ist,r)
139 Alambda_full(state) = pie{ist}{r}(k) * arrival_rate_to_queue;
142 state = state + nphases(ist,r);
146 % Add placeholder for disabled class to match W matrix structure
148 % Alambda_full(state) stays 0 since there are no arrivals
153% remove disabled transitions
154keep = find(~isnan(sum(W,1)));
157Alambda = Alambda_full(keep); % Also filter arrival vector
159% Honor explicit hide_immediate configuration only. Auto-detection based on
160% W matrix stiffness triggered incorrectly on LN layers (GAMMA=1e8 immediate
161% class-switching rates) and routed through a lossy stochcomp path that
162% distorted utilization/throughput. Users needing stochcomp for LSODA
163% stiffness handling must set options.config.hide_immediate=true explicitly.
164if isfield(options.config, 'hide_immediate
')
165 hide_imm_requested = options.config.hide_immediate;
167 hide_imm_requested = false;
170% Eliminate immediate transitions if requested or auto-detected
173Alambda_pre_elim = [];
175 W_pre_elim = W; % Save for Alambda correction
176 Alambda_pre_elim = Alambda;
177 [W, state_map_imm] = eliminate_immediate_matrix(W, sn, options);
180Qa = []; % state mapping to queues (called Q(a) in Ruuskanen et al.)
181SQC = zeros(M*K,0); % to compute per-class queue length at the end
182SUC = zeros(M*K,0); % to compute per-class utilizations at the end
183STC = zeros(M*K,0); % to compute per-class throughput at the end
184x0_build = []; % Build x0 with same structure as W matrix
185%x0 = []; % initial state
187init_sol_idx = 0; % Index into options.init_sol for enabled classes
191 % Add placeholder for disabled transition (matching W matrix structure)
193 Qa(1,state) = ist; %#ok<*AGROW>
194 SQC((ist-1)*K+r,state) = 0; % No queue contribution
195 SUC((ist-1)*K+r,state) = 0; % No utilization contribution
196 STC((ist-1)*K+r,state) = 0; % No throughput contribution
197 x0_build(state,1) = 0; % No initial population
199 for k=1:nphases(ist,r)
202 if isnan(sn.rates(ist,r))
203 % Class has phases but is disabled (NaN rate) -
204 % solver_fluid_initsol skips these, so do not
205 % increment init_sol_idx
206 SQC((ist-1)*K+r,state) = 0;
207 SUC((ist-1)*K+r,state) = 0;
208 STC((ist-1)*K+r,state) = 0;
209 x0_build(state,1) = 0;
211 init_sol_idx = init_sol_idx + 1;
212 SQC((ist-1)*K+r,state) = 1;
213 SUC((ist-1)*K+r,state) = 1/S(ist);
214 STC((ist-1)*K+r,state) = sum(sn.proc{ist}{r}{2}(k,:));
215 x0_build(state,1) = options.init_sol(init_sol_idx);
219 % code to initialize all jobs at ref station
221 % x0 = [x0; NK(r)*pie{i}{r}']; % initial probability of PH
223 % x0 = [x0; zeros(nphases(i,r),1)];
229% Apply keep filtering
for disabled transitions (NaN in W matrix)
236% Save full matrices before immediate elimination
for metric reconstruction
239imm_states_in_keep = []; % Indices of immediate states within keep-filtered space
241% Apply state mapping
if immediate transitions were eliminated
242if ~isempty(state_map_imm)
243 % Identify eliminated (immediate) states
244 imm_states_in_keep = setdiff(1:length(Qa), state_map_imm);
246 Qa = Qa(state_map_imm);
247 SQC = SQC(:, state_map_imm);
248 SUC = SUC(:, state_map_imm);
249 STC = STC(:, state_map_imm);
250 x0 = x0(state_map_imm);
252 % Correct Alambda
for arrivals directed at eliminated immediate states.
253 % From quasi-steady-state assumption (dx_I/dt = 0):
254 % lambda_red = lambda_T - Q_IT
' * (Q_II')^{-1} * lambda_I
255 % where T = timed states, I = immediate states.
256 lambda_T = Alambda_pre_elim(state_map_imm);
257 lambda_I = Alambda_pre_elim(imm_states_in_keep);
258 if any(lambda_I ~= 0)
259 Q_IT = W_pre_elim(imm_states_in_keep, state_map_imm);
260 Q_II = W_pre_elim(imm_states_in_keep, imm_states_in_keep);
261 Alambda = lambda_T - Q_IT' * (Q_II' \ lambda_I);
267% Build SQ matrix to compute total queue length per station in ODEs
268% SQ(s,:) sums all states at
the same station as state s
270SQ = zeros(nstates, nstates);
273 SQ(s, Qa == ist) = 1; % Sum all states at
the same station
276% Identify Source station states (EXT scheduler)
277% For Source stations, theta should be 0.0 to effectively bypass Source in dynamics.
278% This matches
the ground truth implementation where Source
is excluded from state space.
279% Arrivals are injected directly into queue phases via Alambda.
280isSourceState = false(nstates, 1);
283 if sn.sched(ist) == SchedStrategy.EXT
284 isSourceState(s) = true;
285 x0(s) = 0; % Initialize Source phases to 0 (no mass at Source)
292timespan = options.timespan;
293itermax = options.iter_max;
294odeopt = odeset('AbsTol', tol, 'RelTol', tol, 'NonNegative', 1:length(x0));
295nonZeroRates = abs(W(abs(W)>0)); nonZeroRates=nonZeroRates(:);
296if isempty(nonZeroRates)
297 trange = [timespan(1), timespan(2)];
298 if ~isfinite(trange(2))
302 trange = [timespan(1),min(timespan(2),abs(10*itermax/min(nonZeroRates)))];
305% Check if p-norm smoothing should be used (pstar parameter)
306use_pnorm = isfield(options, 'pstar') && ~isempty(options.pstar) || ...
307 (isfield(options.config, 'pstar') && ~isempty(options.config.pstar));
310 % Get pstar values - expand scalar to per-station array
311 if isfield(options, 'pstar') && ~isempty(options.pstar)
312 pstar_val = options.pstar;
314 pstar_val = options.config.pstar;
316 if isscalar(pstar_val)
317 pstar_val = pstar_val * ones(M, 1);
319 % Create per-phase pstar array (pQa) using
the filtered Qa mapping
320 pQa = pstar_val(Qa(:)); % Use Qa which
is already filtered by keep and state_map_imm
321 Sa_pnorm = S(Qa(:)); % Column vector for pnorm_ode
329 % p-norm smoothing ODE as per Ruuskanen et al., PEVA 151 (2021)
330 % ghat = 1 / (1 + (x/c)^p)^(1/p) where x
is queue length, c
is servers, p
is pstar
331 % dx/dt = W^T * θ̂(x,p) + Aλ (Eq. 27 for mixed networks)
332 ode_pnorm_func = @(t,x) pnorm_ode(x, W, SQ, Sa_pnorm, pQa, Alambda, isSourceState);
334 [t, xvec_t] = ode_solve_stiff(ode_pnorm_func, trange, x0, odeopt, options);
336 [t, xvec_t] = ode_solve(ode_pnorm_func, trange, x0, odeopt, options);
339 % Standard matrix method without smoothing
340 % dx/dt = W^T * θ(x) + Aλ (Eq. 12 for mixed networks)
341 Sa_ode = S(Qa(:)); % Column vector for element-wise operations
342 % Define theta function with special handling for Source stations
343 theta_func = @(x) compute_theta(x, SQ, Sa_ode, isSourceState);
345 [t, xvec_t] = ode_solve_stiff(@(t,x) W'*theta_func(x) + Alambda, trange, x0, odeopt, options);
347 [t, xvec_t] = ode_solve(@(t,x) W'*theta_func(x) + Alambda, trange, x0, odeopt, options);
351 if contains(me.identifier, 'lsoda')
358% On LSODA failure, retry with hide_immediate toggled (only once)
360 is_retry = isfield(options.config, 'lsoda_retry') && options.config.lsoda_retry;
362 options_retry = options;
363 options_retry.config.lsoda_retry = true;
364 if hide_imm_requested
365 % hide_immediate was active (auto-detected or explicit) and failed — retry without it
366 options_retry.config.hide_immediate = false;
368 % Normal solve failed — retry with hide_immediate
369 options_retry.config.hide_immediate = true;
371 [QN,UN,RN,TN,xvec_it,QNt,UNt,TNt,xvec_t,t,iters,runtime] = solver_fluid_matrix(sn, options_retry);
374 % Both attempts failed — return empty lastSol to signal failure
375 warning('lsoda:failed', 'LSODA failed on both normal and hide_immediate attempts');
380 xvec_it = {}; % empty signals failure to caller (runAnalyzer checks isempty)
384 xvec_t = zeros(1, sum(nphases(:)));
393Tmax = size(xvec_t,1);
398Sa = S(Qa(:)); % Column vector
for element-wise operations
399S = repmat(S,1,K)
'; S=S(:);
402 QNtmp{j} = zeros(K,M);
403 TNtmp{j} = zeros(K,M);
404 UNtmp{j} = zeros(K,M);
405 RNtmp{j} = zeros(K,M);
408 % Use compute_theta
for consistent handling of Source stations
409 theta_j = compute_theta(x, SQ, Sa, isSourceState);
410 TNtmp{j}(:) = STC*theta_j;
411 UNtmp{j}(:) = SUC*theta_j;
412 % Little
's law is invalid in transient so this vector is not returned
413 % except the last element as an approximation of the actual RN
414 RNtmp{j}(:) = QNtmp{j}(:)./TNtmp{j}(:);
416 QNtmp{j} = QNtmp{j}';
417 UNtmp{j} = UNtmp{j}
';
418 RNtmp{j} = RNtmp{j}';
419 TNtmp{j} = TNtmp{j}
';
421% steady state metrics
423 QNtmp{j} = QNtmp{j}(:);
424 UNtmp{j} = UNtmp{j}(:);
425 RNtmp{j} = RNtmp{j}(:);
426 TNtmp{j} = TNtmp{j}(:);
429% compute cell array with time-varying metrics for stations and classes
430QNtmp = cell2mat(QNtmp)';
431UNtmp = cell2mat(UNtmp)
';
432RNtmp = cell2mat(RNtmp)';
433TNtmp = cell2mat(TNtmp)
';
440 QNt{ist,r} = QNtmp(:,(r-1)*M+ist);
441 UNt{ist,r} = UNtmp(:,(r-1)*M+ist);
442 RNt{ist,r} = RNtmp(:,(r-1)*M+ist);
443 TNt{ist,r} = TNtmp(:,(r-1)*M+ist);
446QN = reshape(QNtmp(end,:),M,K);
447UN = reshape(UNtmp(end,:),M,K);
448RN = reshape(RNtmp(end,:),M,K);
449TN = reshape(TNtmp(end,:),M,K);
451% Set throughput at Source stations to arrival rates for open classes
452% Source stations have theta = 0 in the ODE (to bypass Source in dynamics),
453% but their throughput should equal the external arrival rate.
455 if sn.sched(ist) == SchedStrategy.EXT
457 if ~isnan(sn.rates(ist, r)) && sn.rates(ist, r) > 0
458 TN(ist, r) = sn.rates(ist, r);
464% Reconstruct throughput for eliminated immediate states using flow conservation
465if ~isempty(imm_states_in_keep)
466 % For each eliminated state, throughput = sum of incoming throughputs via routing
467 for idx = 1:length(imm_states_in_keep)
468 s = imm_states_in_keep(idx);
469 ist = Qa_full(s); % Station of this eliminated state
470 % Find which class this state belongs to
474 state_count = state_count + nphases(ist, rr);
475 if s <= sum(Qa_full == ist & (1:length(Qa_full)) <= state_count)
481 % Fallback: find class by examining STC_full
483 if STC_full((ist-1)*K+rr, s) > 0
490 % Compute incoming throughput using routing matrix P
491 % T(ist, r) = sum over all (j, l) of T(j, l) * P((j,l) -> (ist,r))
495 p_jl_ir = P((j-1)*K+l, (ist-1)*K+r);
497 incoming_tput = incoming_tput + TN(j, l) * p_jl_ir;
501 % Also add external arrivals if this is a source station
502 if sn.sched(ist) == SchedStrategy.EXT && ~isnan(sn.rates(ist, r))
503 incoming_tput = incoming_tput + sn.rates(ist, r);
505 TN(ist, r) = incoming_tput;
510xvec_it = {xvec_t(end,:)};
513function dxdt = pnorm_ode(x, W, SQ, Sa, pQa, Alambda, isSourceState)
514% PNORM_ODE - ODE derivative using p-norm smoothing
515% As per Ruuskanen et al., PEVA 151 (2021)
516% dxdt = W' * (x .* ghat) + Aλ (Eq. 27)
517% where ghat = 1 / (1 + (sumXQa/Sa)^pQa)^(1/pQa)
519sumXQa = GlobalConstants.FineTol + SQ * x;
520ghat = zeros(size(x));
525 if cVal > 0 && pVal > 0
526 ghatVal = 1.0 / (1 + (xVal / cVal)^pVal)^(1/pVal);
537% Compute effective rate (x .* ghat), with special handling
for Source stations
538theta_eff = x .* ghat;
539% For Source stations,
override to 0.0 to bypass Source in dynamics
540% (matching ground truth where Source
is excluded from state space)
541theta_eff(isSourceState) = 0.0;
543dxdt = W
' * theta_eff + Alambda;
546function theta = compute_theta(x, SQ, Sa, isSourceState)
547% COMPUTE_THETA - Compute theta vector for fluid ODE
548% For regular stations: theta = x./(SQ*x) .* min(Sa, SQ*x)
549% For Source stations (EXT scheduler): theta = 0.0
550% - Source is conceptually excluded from state space (matching ground truth)
551% - Arrivals are injected directly into queues via Alambda
552% - Setting theta = 0 prevents Source from contributing to W' * theta
554sumXQa = GlobalConstants.FineTol + SQ * x;
555theta = x ./ sumXQa .* min(Sa, sumXQa);
557% Override theta
for Source stations
558% Source stations have theta = 0.0 to bypass Source in dynamics
559theta(isSourceState) = 0.0;