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
12P_full = sn.rt; % Full routing matrix (stateful
nodes)
13NK = sn.njobs
'; %initial population
16S(infServers) = sum(NK);
18%refstat = sn.refstat; % reference station
21% Extract station-to-station routing matrix from stateful-to-stateful matrix
22% Build mapping of station indices in the class-blocked routing matrix
25 isf = sn.stationToStateful(ist);
27 station_indices = [station_indices, (isf-1)*K + r];
30P = P_full(station_indices, station_indices);
32% Remove Sink->Source feedback routing for open classes
33% In open networks, jobs exit at Sink and should not recirculate back to Source.
34% The routing matrix includes this feedback (added by getRoutingMatrix.m for
35% pseudo-closed network analysis), but it causes incorrect flow balance in the
36% fluid ODE formulation because arrivals are already accounted for via Alambda.
38 if sn.sched(src_ist) == SchedStrategy.EXT
39 % This is a Source station - remove feedback routing TO it for open classes
41 % Check if this is an open class with external arrivals
42 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
43 % Zero out routing TO this Source for this class from all other stations
44 src_col = (src_ist - 1) * K + r; % Column index in P for (Source, class r)
46 if from_ist ~= src_ist % Don't modify Source
's own outgoing routing
48 from_row = (from_ist - 1) * K + from_r;
49 P(from_row, src_col) = 0; % Remove feedback to Source
58% ODE building as per Ruuskanen et al., PEVA 151 (2021).
69 Psi = blkdiag(Psi,PH{ist}{r}{1});
70 B = blkdiag(B,sum(PH{ist}{r}{2},2));
71 A = blkdiag(A,pie{ist}{r}');
77% Build arrival rate vector Aλ for mixed/open networks (Ruuskanen et al., PEVA 151 (2021), Eq. 7)
78% Following the ground truth implementation: Source is conceptually excluded from state space.
79% Arrivals go directly to queue phases (not Source phases) weighted by routing probabilities.
80% This matches dx = W' * theta + A * lambda where lambda represents arrivals INTO queues.
82% First, identify Source stations and their arrival rates per
class
83source_arrivals = zeros(M, K); % source_arrivals(src, r) = arrival rate at source src
for class r
85 if sn.sched(src_ist) == SchedStrategy.EXT
87 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
88 source_arrivals(src_ist, r) = sn.rates(src_ist, r);
94% Build Alambda: arrivals go to QUEUE phases (where jobs route from Source), not Source phases
95Alambda_full = zeros(size(A,1), 1);
100 if sn.sched(ist) == SchedStrategy.EXT
101 % Source station:
do NOT add arrivals here (arrivals go to downstream queues)
102 state = state + nphases(ist,r);
104 % Queue station: check
if it receives arrivals from any Source
105 arrival_rate_to_queue = 0;
107 if source_arrivals(src_ist, r) > 0
108 % Get routing probability from Source to
this queue
for this class
109 src_row = (src_ist - 1) * K + r;
110 queue_col = (ist - 1) * K + r;
111 routing_prob =
P(src_row, queue_col);
112 arrival_rate_to_queue = arrival_rate_to_queue + source_arrivals(src_ist, r) * routing_prob;
116 if arrival_rate_to_queue > 0
117 % Apply arrivals according to entrance probability ζ^{i,r}
118 for k=1:nphases(ist,r)
120 Alambda_full(state) = pie{ist}{r}(k) * arrival_rate_to_queue;
123 state = state + nphases(ist,r);
127 % Add placeholder
for disabled
class to match W matrix structure
129 % Alambda_full(state) stays 0 since there are no arrivals
134% remove disabled transitions
135keep = find(~isnan(sum(W,1)));
138Alambda = Alambda_full(keep); % Also filter arrival vector
140% Eliminate immediate transitions
if requested
142if isfield(options.config,
'hide_immediate') && options.config.hide_immediate
143 [W, state_map_imm] = eliminate_immediate_matrix(W, sn, options);
146Qa = []; % state mapping to queues (called Q(a) in Ruuskanen et al.)
147SQC = zeros(M*K,0); % to compute per-
class queue length at the end
148SUC = zeros(M*K,0); % to compute per-
class utilizations at the end
149STC = zeros(M*K,0); % to compute per-
class throughput at the end
150x0_build = []; % Build x0 with same structure as W matrix
151%x0 = []; % initial state
153init_sol_idx = 0; % Index into options.init_sol
for enabled
classes
157 % Add placeholder
for disabled transition (matching W matrix structure)
159 Qa(1,state) = ist; %#ok<*AGROW>
160 SQC((ist-1)*K+r,state) = 0; % No queue contribution
161 SUC((ist-1)*K+r,state) = 0; % No utilization contribution
162 STC((ist-1)*K+r,state) = 0; % No throughput contribution
163 x0_build(state,1) = 0; % No initial population
165 for k=1:nphases(ist,r)
167 init_sol_idx = init_sol_idx + 1;
169 SQC((ist-1)*K+r,state) = 1;
170 SUC((ist-1)*K+r,state) = 1/S(ist);
171 STC((ist-1)*K+r,state) = sum(sn.proc{ist}{r}{2}(k,:));
172 x0_build(state,1) = options.init_sol(init_sol_idx);
175 % code to initialize all jobs at ref station
177 % x0 = [x0; NK(r)*pie{i}{r}
']; % initial probability of PH
179 % x0 = [x0; zeros(nphases(i,r),1)];
185% Apply keep filtering for disabled transitions (NaN in W matrix)
192% Save full matrices before immediate elimination for metric reconstruction
195imm_states_in_keep = []; % Indices of immediate states within keep-filtered space
197% Apply state mapping if immediate transitions were eliminated
198if ~isempty(state_map_imm)
199 % Identify eliminated (immediate) states
200 imm_states_in_keep = setdiff(1:length(Qa), state_map_imm);
202 Qa = Qa(state_map_imm);
203 SQC = SQC(:, state_map_imm);
204 SUC = SUC(:, state_map_imm);
205 STC = STC(:, state_map_imm);
206 x0 = x0(state_map_imm);
207 Alambda = Alambda(state_map_imm); % Filter arrival vector for eliminated states
210% Build SQ matrix to compute total queue length per station in ODEs
211% SQ(s,:) sums all states at the same station as state s
213SQ = zeros(nstates, nstates);
216 SQ(s, Qa == ist) = 1; % Sum all states at the same station
219% Identify Source station states (EXT scheduler)
220% For Source stations, theta should be 0.0 to effectively bypass Source in dynamics.
221% This matches the ground truth implementation where Source is excluded from state space.
222% Arrivals are injected directly into queue phases via Alambda.
223isSourceState = false(nstates, 1);
226 if sn.sched(ist) == SchedStrategy.EXT
227 isSourceState(s) = true;
228 x0(s) = 0; % Initialize Source phases to 0 (no mass at Source)
235timespan = options.timespan;
236itermax = options.iter_max;
237odeopt = odeset('AbsTol
', tol, 'RelTol
', tol, 'NonNegative
', 1:length(x0));
238nonZeroRates = abs(W(abs(W)>0)); nonZeroRates=nonZeroRates(:);
239trange = [timespan(1),min(timespan(2),abs(10*itermax/min(nonZeroRates)))];
241% Check if p-norm smoothing should be used (pstar parameter)
242use_pnorm = isfield(options, 'pstar
') && ~isempty(options.pstar) || ...
243 (isfield(options.config, 'pstar
') && ~isempty(options.config.pstar));
246 % Get pstar values - expand scalar to per-station array
247 if isfield(options, 'pstar
') && ~isempty(options.pstar)
248 pstar_val = options.pstar;
250 pstar_val = options.config.pstar;
252 if isscalar(pstar_val)
253 pstar_val = pstar_val * ones(M, 1);
255 % Create per-phase pstar array (pQa) using the filtered Qa mapping
256 pQa = pstar_val(Qa(:)); % Use Qa which is already filtered by keep and state_map_imm
257 Sa_pnorm = S(Qa(:)); % Column vector for pnorm_ode
263 % p-norm smoothing ODE as per Ruuskanen et al., PEVA 151 (2021)
264 % ghat = 1 / (1 + (x/c)^p)^(1/p) where x is queue length, c is servers, p is pstar
265 % dx/dt = W^T * θ̂(x,p) + Aλ (Eq. 27 for mixed networks)
266 ode_pnorm_func = @(t,x) pnorm_ode(x, W, SQ, Sa_pnorm, pQa, Alambda, isSourceState);
268 [t, xvec_t] = ode_solve_stiff(ode_pnorm_func, trange, x0, odeopt, options);
270 [t, xvec_t] = ode_solve(ode_pnorm_func, trange, x0, odeopt, options);
273 % Standard matrix method without smoothing
274 % dx/dt = W^T * θ(x) + Aλ (Eq. 12 for mixed networks)
275 Sa_ode = S(Qa(:)); % Column vector for element-wise operations
276 % Define theta function with special handling for Source stations
277 theta_func = @(x) compute_theta(x, SQ, Sa_ode, isSourceState);
279 [t, xvec_t] = ode_solve_stiff(@(t,x) W'*theta_func(x) + Alambda, trange, x0, odeopt, options);
281 [t, xvec_t] = ode_solve(@(t,x) W
'*theta_func(x) + Alambda, trange, x0, odeopt, options);
286Tmax = size(xvec_t,1);
291Sa = S(Qa(:)); % Column vector for element-wise operations
292S = repmat(S,1,K)'; S=S(:);
295 QNtmp{j} = zeros(K,M);
296 TNtmp{j} = zeros(K,M);
297 UNtmp{j} = zeros(K,M);
298 RNtmp{j} = zeros(K,M);
301 % Use compute_theta for consistent handling of Source stations
302 theta_j = compute_theta(x, SQ, Sa, isSourceState);
303 TNtmp{j}(:) = STC*theta_j;
304 UNtmp{j}(:) = SUC*theta_j;
305 % Little's law
is invalid in transient so this vector
is not returned
306 % except the last element as an approximation of the actual RN
307 RNtmp{j}(:) = QNtmp{j}(:)./TNtmp{j}(:);
309 QNtmp{j} = QNtmp{j}
';
310 UNtmp{j} = UNtmp{j}';
311 RNtmp{j} = RNtmp{j}
';
312 TNtmp{j} = TNtmp{j}';
314% steady state metrics
316 QNtmp{j} = QNtmp{j}(:);
317 UNtmp{j} = UNtmp{j}(:);
318 RNtmp{j} = RNtmp{j}(:);
319 TNtmp{j} = TNtmp{j}(:);
322% compute cell array with time-varying metrics
for stations and
classes
323QNtmp = cell2mat(QNtmp)
';
324UNtmp = cell2mat(UNtmp)';
325RNtmp = cell2mat(RNtmp)
';
326TNtmp = cell2mat(TNtmp)';
333 QNt{ist,r} = QNtmp(:,(r-1)*M+ist);
334 UNt{ist,r} = UNtmp(:,(r-1)*M+ist);
335 RNt{ist,r} = RNtmp(:,(r-1)*M+ist);
336 TNt{ist,r} = TNtmp(:,(r-1)*M+ist);
339QN = reshape(QNtmp(end,:),M,K);
340UN = reshape(UNtmp(end,:),M,K);
341RN = reshape(RNtmp(end,:),M,K);
342TN = reshape(TNtmp(end,:),M,K);
344% Set throughput at Source stations to arrival rates
for open
classes
345% Source stations have theta = 0 in the ODE (to bypass Source in dynamics),
346% but their throughput should equal the external arrival rate.
348 if sn.sched(ist) == SchedStrategy.EXT
350 if ~isnan(sn.rates(ist, r)) && sn.rates(ist, r) > 0
351 TN(ist, r) = sn.rates(ist, r);
357% Reconstruct throughput
for eliminated immediate states
using flow conservation
358if ~isempty(imm_states_in_keep)
359 % For each eliminated state, throughput = sum of incoming throughputs via routing
360 for idx = 1:length(imm_states_in_keep)
361 s = imm_states_in_keep(idx);
362 ist = Qa_full(s); % Station of
this eliminated state
363 % Find which
class this state belongs to
367 state_count = state_count + nphases(ist, rr);
368 if s <= sum(Qa_full == ist & (1:length(Qa_full)) <= state_count)
374 % Fallback: find
class by examining STC_full
376 if STC_full((ist-1)*K+rr, s) > 0
383 % Compute incoming throughput
using routing matrix
P
384 % T(ist, r) = sum over all (j, l) of T(j, l) *
P((j,l) -> (ist,r))
388 p_jl_ir =
P((j-1)*K+l, (ist-1)*K+r);
390 incoming_tput = incoming_tput + TN(j, l) * p_jl_ir;
394 % Also add external arrivals
if this is a source station
395 if sn.sched(ist) == SchedStrategy.EXT && ~isnan(sn.rates(ist, r))
396 incoming_tput = incoming_tput + sn.rates(ist, r);
398 TN(ist, r) = incoming_tput;
403xvec_it = {xvec_t(end,:)};
406function dxdt = pnorm_ode(x, W, SQ, Sa, pQa, Alambda, isSourceState)
407% PNORM_ODE - ODE derivative
using p-norm smoothing
408% As per Ruuskanen et al., PEVA 151 (2021)
409% dxdt = W
' * (x .* ghat) + Aλ (Eq. 27)
410% where ghat = 1 / (1 + (sumXQa/Sa)^pQa)^(1/pQa)
412sumXQa = GlobalConstants.FineTol + SQ * x;
413ghat = zeros(size(x));
418 if cVal > 0 && pVal > 0
419 ghatVal = 1.0 / (1 + (xVal / cVal)^pVal)^(1/pVal);
430% Compute effective rate (x .* ghat), with special handling for Source stations
431theta_eff = x .* ghat;
432% For Source stations, override to 0.0 to bypass Source in dynamics
433% (matching ground truth where Source is excluded from state space)
434theta_eff(isSourceState) = 0.0;
436dxdt = W' * theta_eff + Alambda;
439function theta = compute_theta(x, SQ, Sa, isSourceState)
440% COMPUTE_THETA - Compute theta vector
for fluid ODE
441% For regular stations: theta = x./(SQ*x) .* min(Sa, SQ*x)
442% For Source stations (EXT scheduler): theta = 0.0
443% - Source
is conceptually excluded from state space (matching ground truth)
444% - Arrivals are injected directly into queues via Alambda
445% - Setting theta = 0 prevents Source from contributing to W
' * theta
447sumXQa = GlobalConstants.FineTol + SQ * x;
448theta = x ./ sumXQa .* min(Sa, sumXQa);
450% Override theta for Source stations
451% Source stations have theta = 0.0 to bypass Source in dynamics
452theta(isSourceState) = 0.0;