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% see _kb/06-solver-catalog.md
for rationale
13if isfield(sn,
'procid')
16 if sn.procid(ist,r) == ProcessType.NHPP
17 lam = sn.rates(ist,r);
18 if isfinite(lam) && lam > 0
19 PH{ist}{r} = {-lam, lam};
26P_full = sn.rt; % Full routing matrix (stateful
nodes)
27NK = sn.njobs
'; %initial population
30S(infServers) = sum(NK);
32%refstat = sn.refstat; % reference station
35% Extract station-to-station routing matrix from stateful-to-stateful matrix
36% using stochastic complementation to resolve routing through non-station
37% stateful nodes (e.g., Router nodes)
40 isf = sn.stationToStateful(ist);
42 station_indices = [station_indices, (isf-1)*K + r];
45P = dtmc_stochcomp(P_full, station_indices);
47% see _kb/06-solver-catalog.md for rationale
49 if sn.sched(src_ist) == SchedStrategy.EXT
50 % This is a Source station - remove feedback routing TO it for open classes
52 % Check if this is an open class with external arrivals
53 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
54 % Zero out routing TO this Source for this class from all other stations
55 src_col = (src_ist - 1) * K + r; % Column index in P for (Source, class r)
57 if from_ist ~= src_ist % Don't modify Source
's own outgoing routing
59 from_row = (from_ist - 1) * K + from_r;
60 P(from_row, src_col) = 0; % Remove feedback to Source
69% ODE building as per Ruuskanen et al., PEVA 151 (2021).
80 Psi = blkdiag(Psi,PH{ist}{r}{1});
81 B = blkdiag(B,sum(PH{ist}{r}{2},2));
82 A = blkdiag(A,pie{ist}{r}');
88% see _kb/06-solver-catalog.md for rationale
90% First, identify Source stations and their arrival rates per class
91source_arrivals = zeros(M, K); % source_arrivals(src, r) = arrival rate at source src for class r
93 if sn.sched(src_ist) == SchedStrategy.EXT
95 if ~isnan(sn.rates(src_ist, r)) && sn.rates(src_ist, r) > 0
96 source_arrivals(src_ist, r) = sn.rates(src_ist, r);
102% Build Alambda: arrivals go to QUEUE phases (where jobs route from Source), not Source phases
103Alambda_full = zeros(size(A,1), 1);
107 if nphases(ist,r) > 0
108 if sn.sched(ist) == SchedStrategy.EXT
109 % Source station: do NOT add arrivals here (arrivals go to downstream queues)
110 state = state + nphases(ist,r);
112 % Queue station: check if it receives arrivals from any Source
113 arrival_rate_to_queue = 0;
115 if source_arrivals(src_ist, r) > 0
116 % Get routing probability from Source to this queue for this class
117 src_row = (src_ist - 1) * K + r;
118 queue_col = (ist - 1) * K + r;
119 routing_prob = P(src_row, queue_col);
120 arrival_rate_to_queue = arrival_rate_to_queue + source_arrivals(src_ist, r) * routing_prob;
124 if arrival_rate_to_queue > 0
125 % Apply arrivals according to entrance probability ζ^{i,r}
126 for k=1:nphases(ist,r)
128 Alambda_full(state) = pie{ist}{r}(k) * arrival_rate_to_queue;
131 state = state + nphases(ist,r);
135 % Add placeholder for disabled class to match W matrix structure
137 % Alambda_full(state) stays 0 since there are no arrivals
142% remove disabled transitions
143keep = find(~isnan(sum(W,1)));
146Alambda = Alambda_full(keep); % Also filter arrival vector
148% see _kb/06-solver-catalog.md for rationale
149if isfield(options.config, 'hide_immediate
')
150 hide_imm_requested = options.config.hide_immediate;
152 hide_imm_requested = false;
155% Eliminate immediate transitions if requested or auto-detected
158Alambda_pre_elim = [];
160 W_pre_elim = W; % Save for Alambda correction
161 Alambda_pre_elim = Alambda;
162 [W, state_map_imm] = eliminate_immediate_matrix(W, sn, options);
165Qa = []; % state mapping to queues (called Q(a) in Ruuskanen et al.)
166SQC = zeros(M*K,0); % to compute per-class queue length at the end
167SUC = zeros(M*K,0); % to compute per-class utilizations at the end
168STC = zeros(M*K,0); % to compute per-class throughput at the end
169x0_build = []; % Build x0 with same structure as W matrix
170%x0 = []; % initial state
172init_sol_idx = 0; % Index into options.init_sol for enabled classes
176 % Add placeholder for disabled transition (matching W matrix structure)
178 Qa(1,state) = ist; %#ok<*AGROW>
179 SQC((ist-1)*K+r,state) = 0; % No queue contribution
180 SUC((ist-1)*K+r,state) = 0; % No utilization contribution
181 STC((ist-1)*K+r,state) = 0; % No throughput contribution
182 x0_build(state,1) = 0; % No initial population
184 for k=1:nphases(ist,r)
187 if isnan(sn.rates(ist,r))
188 % Class has phases but is disabled (NaN rate) -
189 % solver_fluid_initsol skips these, so do not
190 % increment init_sol_idx
191 SQC((ist-1)*K+r,state) = 0;
192 SUC((ist-1)*K+r,state) = 0;
193 STC((ist-1)*K+r,state) = 0;
194 x0_build(state,1) = 0;
196 init_sol_idx = init_sol_idx + 1;
197 SQC((ist-1)*K+r,state) = 1;
198 SUC((ist-1)*K+r,state) = 1/S(ist);
199 STC((ist-1)*K+r,state) = sum(sn.proc{ist}{r}{2}(k,:));
200 x0_build(state,1) = options.init_sol(init_sol_idx);
204 % code to initialize all jobs at ref station
206 % x0 = [x0; NK(r)*pie{i}{r}']; % initial probability of PH
208 % x0 = [x0; zeros(nphases(i,r),1)];
214% Apply keep filtering
for disabled transitions (NaN in W matrix)
221% Save full matrices before immediate elimination
for metric reconstruction
224imm_states_in_keep = []; % Indices of immediate states within keep-filtered space
226% Apply state mapping
if immediate transitions were eliminated
227if ~isempty(state_map_imm)
228 % Identify eliminated (immediate) states
229 imm_states_in_keep = setdiff(1:length(Qa), state_map_imm);
231 Qa = Qa(state_map_imm);
232 SQC = SQC(:, state_map_imm);
233 SUC = SUC(:, state_map_imm);
234 STC = STC(:, state_map_imm);
235 x0 = x0(state_map_imm);
237 % see _kb/06-solver-catalog.md
for rationale
238 lambda_T = Alambda_pre_elim(state_map_imm);
239 lambda_I = Alambda_pre_elim(imm_states_in_keep);
240 if any(lambda_I ~= 0)
241 Q_IT = W_pre_elim(imm_states_in_keep, state_map_imm);
242 Q_II = W_pre_elim(imm_states_in_keep, imm_states_in_keep);
243 Alambda = lambda_T - Q_IT' * (Q_II' \ lambda_I);
249% Build SQ matrix to compute total queue length per station in ODEs
250% SQ(s,:) sums all states at the same station as state s
252SQ = zeros(nstates, nstates);
255 SQ(s, Qa == ist) = 1; % Sum all states at the same station
258% Identify Source station states (EXT scheduler)
259% For Source stations, theta should be 0.0 to effectively bypass Source in dynamics.
260% This matches the ground truth implementation where Source
is excluded from state space.
261% Arrivals are injected directly into queue phases via Alambda.
262isSourceState = false(nstates, 1);
265 if sn.sched(ist) == SchedStrategy.EXT
266 isSourceState(s) = true;
267 x0(s) = 0; % Initialize Source phases to 0 (no mass at Source)
274timespan = options.timespan;
275itermax = options.iter_max;
276odeopt = odeset('AbsTol', tol, 'RelTol', tol, 'NonNegative', 1:length(x0));
277nonZeroRates = abs(W(abs(W)>0)); nonZeroRates=nonZeroRates(:);
278if isempty(nonZeroRates)
279 trange = [timespan(1), timespan(2)];
280 if ~isfinite(trange(2))
284 trange = [timespan(1),min(timespan(2),abs(10*itermax/min(nonZeroRates)))];
287% Check if p-norm smoothing should be used (pstar parameter)
288use_pnorm = isfield(options, 'pstar') && ~isempty(options.pstar) || ...
289 (isfield(options.config, 'pstar') && ~isempty(options.config.pstar));
292 % Get pstar values - expand scalar to per-station array
293 if isfield(options, 'pstar') && ~isempty(options.pstar)
294 pstar_val = options.pstar;
296 pstar_val = options.config.pstar;
298 if isscalar(pstar_val)
299 pstar_val = pstar_val * ones(M, 1);
301 % Create per-phase pstar array (pQa) using the filtered Qa mapping
302 pQa = pstar_val(Qa(:)); % Use Qa which
is already filtered by keep and state_map_imm
303 Sa_pnorm = S(Qa(:)); % Column vector for pnorm_ode
311 % p-norm smoothing ODE as per Ruuskanen et al., PEVA 151 (2021)
312 % ghat = 1 / (1 + (x/c)^p)^(1/p) where x
is queue length, c
is servers, p
is pstar
313 % dx/dt = W^T * θ̂(x,p) + Aλ (Eq. 27 for mixed networks)
314 ode_pnorm_func = @(t,x) pnorm_ode(x, W, SQ, Sa_pnorm, pQa, Alambda, isSourceState);
316 [t, xvec_t] = ode_solve_stiff(ode_pnorm_func, trange, x0, odeopt, options);
318 [t, xvec_t] = ode_solve(ode_pnorm_func, trange, x0, odeopt, options);
321 % Standard matrix method without smoothing
322 % dx/dt = W^T * θ(x) + Aλ (Eq. 12 for mixed networks)
323 Sa_ode = S(Qa(:)); % Column vector for element-wise operations
324 % Define theta function with special handling for Source stations
325 theta_func = @(x) compute_theta(x, SQ, Sa_ode, isSourceState);
327 [t, xvec_t] = ode_solve_stiff(@(t,x) W'*theta_func(x) + Alambda, trange, x0, odeopt, options);
329 [t, xvec_t] = ode_solve(@(t,x) W'*theta_func(x) + Alambda, trange, x0, odeopt, options);
333 if contains(me.identifier, 'lsoda')
340% On LSODA failure, retry with hide_immediate toggled (only once)
342 is_retry = isfield(options.config, 'lsoda_retry') && options.config.lsoda_retry;
344 options_retry = options;
345 options_retry.config.lsoda_retry = true;
346 if hide_imm_requested
347 % hide_immediate was active (auto-detected or explicit) and failed — retry without it
348 options_retry.config.hide_immediate = false;
350 % Normal solve failed — retry with hide_immediate
351 options_retry.config.hide_immediate = true;
353 [QN,UN,RN,TN,xvec_it,QNt,UNt,TNt,xvec_t,t,iters,runtime] = solver_fluid_matrix(sn, options_retry);
356 % Both attempts failed — return empty lastSol to signal failure
357 warning('lsoda:failed', 'LSODA failed on both normal and hide_immediate attempts');
362 xvec_it = {}; % empty signals failure to caller (runAnalyzer checks isempty)
366 xvec_t = zeros(1, sum(nphases(:)));
375Tmax = size(xvec_t,1);
380Sa = S(Qa(:)); % Column vector
for element-wise operations
381S = repmat(S,1,K)
'; S=S(:);
384 QNtmp{j} = zeros(K,M);
385 TNtmp{j} = zeros(K,M);
386 UNtmp{j} = zeros(K,M);
387 RNtmp{j} = zeros(K,M);
390 % Use compute_theta
for consistent handling of Source stations
391 theta_j = compute_theta(x, SQ, Sa, isSourceState);
392 TNtmp{j}(:) = STC*theta_j;
393 UNtmp{j}(:) = SUC*theta_j;
394 % Little
's law is invalid in transient so this vector is not returned
395 % except the last element as an approximation of the actual RN
396 RNtmp{j}(:) = QNtmp{j}(:)./TNtmp{j}(:);
398 QNtmp{j} = QNtmp{j}';
399 UNtmp{j} = UNtmp{j}
';
400 RNtmp{j} = RNtmp{j}';
401 TNtmp{j} = TNtmp{j}
';
403% steady state metrics
405 QNtmp{j} = QNtmp{j}(:);
406 UNtmp{j} = UNtmp{j}(:);
407 RNtmp{j} = RNtmp{j}(:);
408 TNtmp{j} = TNtmp{j}(:);
411% compute cell array with time-varying metrics for stations and classes
412QNtmp = cell2mat(QNtmp)';
413UNtmp = cell2mat(UNtmp)
';
414RNtmp = cell2mat(RNtmp)';
415TNtmp = cell2mat(TNtmp)
';
422 QNt{ist,r} = QNtmp(:,(r-1)*M+ist);
423 UNt{ist,r} = UNtmp(:,(r-1)*M+ist);
424 RNt{ist,r} = RNtmp(:,(r-1)*M+ist);
425 TNt{ist,r} = TNtmp(:,(r-1)*M+ist);
428QN = reshape(QNtmp(end,:),M,K);
429UN = reshape(UNtmp(end,:),M,K);
430RN = reshape(RNtmp(end,:),M,K);
431TN = reshape(TNtmp(end,:),M,K);
433% Set throughput at Source stations to arrival rates for open classes
434% Source stations have theta = 0 in the ODE (to bypass Source in dynamics),
435% but their throughput should equal the external arrival rate.
437 if sn.sched(ist) == SchedStrategy.EXT
439 if ~isnan(sn.rates(ist, r)) && sn.rates(ist, r) > 0
440 TN(ist, r) = sn.rates(ist, r);
446% Reconstruct throughput for eliminated immediate states using flow conservation
447if ~isempty(imm_states_in_keep)
448 % For each eliminated state, throughput = sum of incoming throughputs via routing
449 for idx = 1:length(imm_states_in_keep)
450 s = imm_states_in_keep(idx);
451 ist = Qa_full(s); % Station of this eliminated state
452 % Find which class this state belongs to
456 state_count = state_count + nphases(ist, rr);
457 if s <= sum(Qa_full == ist & (1:length(Qa_full)) <= state_count)
463 % Fallback: find class by examining STC_full
465 if STC_full((ist-1)*K+rr, s) > 0
472 % Compute incoming throughput using routing matrix P
473 % T(ist, r) = sum over all (j, l) of T(j, l) * P((j,l) -> (ist,r))
477 p_jl_ir = P((j-1)*K+l, (ist-1)*K+r);
479 incoming_tput = incoming_tput + TN(j, l) * p_jl_ir;
483 % Also add external arrivals if this is a source station
484 if sn.sched(ist) == SchedStrategy.EXT && ~isnan(sn.rates(ist, r))
485 incoming_tput = incoming_tput + sn.rates(ist, r);
487 TN(ist, r) = incoming_tput;
492xvec_it = {xvec_t(end,:)};
495function dxdt = pnorm_ode(x, W, SQ, Sa, pQa, Alambda, isSourceState)
496% PNORM_ODE - ODE derivative using p-norm smoothing
497% As per Ruuskanen et al., PEVA 151 (2021)
498% dxdt = W' * (x .* ghat) + Aλ (Eq. 27)
499% where ghat = 1 / (1 + (sumXQa/Sa)^pQa)^(1/pQa)
501sumXQa = GlobalConstants.FineTol + SQ * x;
502ghat = zeros(size(x));
507 if cVal > 0 && pVal > 0
508 ghatVal = 1.0 / (1 + (xVal / cVal)^pVal)^(1/pVal);
519% Compute effective rate (x .* ghat), with special handling
for Source stations
520theta_eff = x .* ghat;
521% For Source stations,
override to 0.0 to bypass Source in dynamics
522% (matching ground truth where Source
is excluded from state space)
523theta_eff(isSourceState) = 0.0;
525dxdt = W
' * theta_eff + Alambda;
528function theta = compute_theta(x, SQ, Sa, isSourceState)
529% COMPUTE_THETA - Compute theta vector for fluid ODE
530% For regular stations: theta = x./(SQ*x) .* min(Sa, SQ*x)
531% For Source stations (EXT scheduler): theta = 0.0
532% - Source is conceptually excluded from state space (matching ground truth)
533% - Arrivals are injected directly into queues via Alambda
534% - Setting theta = 0 prevents Source from contributing to W' * theta
536sumXQa = GlobalConstants.FineTol + SQ * x;
537theta = x ./ sumXQa .* min(Sa, sumXQa);
539% Override theta
for Source stations
540% Source stations have theta = 0.0 to bypass Source in dynamics
541theta(isSourceState) = 0.0;