1function [QNlqn_t, UNlqn_t, TNlqn_t] = getTranAvgCoupled(self, Qt, Ut, Tt) %#ok<INUSD>
2% [QNLQN_T,UNLQN_T,TNLQN_T] = GETTRANAVGCOUPLED(SELF,QT,UT,TT)
4% Coupled layered transient by waveform relaxation over
the LQN ensemble.
5% Unlike getTranAvgDecoupled, which freezes inter-layer demands at
the
6% converged fixed point,
this reconciles
the per-layer transients iteratively:
7% each layer
's fluid transient is driven by TIME-VARYING inter-layer demand
8% trajectories taken from the other layers' latest transients, and
the loop
9% repeats until
the trajectories stop changing (sup-norm gap over time). The
10% time-varying demands are injected into each layer
's closing ODE through the
11% per-event rate multiplier (options.config.rate_sched -> solver_fluid_ratemult).
13% Iteration 0 uses the frozen equilibrium demands, so it reproduces
14% getTranAvgDecoupled exactly; at convergence every layer relaxes to its fixed
15% point, so the endpoint equals getAvg. The return layout is the same
16% block-diagonal (station x class per layer) as getTranAvgDecoupled.
18% Coupled channels: task think times (client delay) and synchronous-call
19% service demands (caller client station). Both are the dominant inter-layer
20% couplings; intra-layer host service stays at its equilibrium value.
22% Copyright (c) 2012-2026, Imperial College London
26% Capture the transient horizon BEFORE getAvg: the layered fixed-point solve
27% strips options.timespan (each layer getAvg rejects a timespan), so reading it
28% afterwards would spuriously trigger the no-horizon fallback.
29ts = self.options.timespan;
30self.getAvg; % converge the fixed point; layer solvers primed with equilibrium
32if ~(numel(ts) >= 2 && all(isfinite(ts)))
33 % No finite transient horizon: nothing to co-evolve, defer to decoupled.
34 [QNlqn_t, UNlqn_t, TNlqn_t] = self.getTranAvgDecoupled();
40% relaxation controls (reuse the ensemble iteration budget / tolerance)
42if isfield(self.options,'config
') && isfield(self.options.config,'ln_transient_iter_max
') ...
43 && ~isempty(self.options.config.ln_transient_iter_max)
44 maxit = self.options.config.ln_transient_iter_max;
47if isfield(self.options,'config
') && isfield(self.options.config,'ln_transient_tol
') ...
48 && ~isempty(self.options.config.ln_transient_tol)
49 tol = self.options.config.ln_transient_tol;
52tgrid = linspace(ts(1), ts(2), Ngrid)';
54% Per-layer sn (
for node->station and
class bookkeeping), cached once.
57 layerSn{e} = self.ensemble{e}.getStruct;
60% Iteration 0: decoupled transients (frozen equilibrium demands already set by
61% getAvg). local_run_layers returns per-layer {M,K} structs on
the layer
's own
62% time grid, plus per-(station,class) trajectories resampled onto tgrid.
63[blocks, traj] = local_run_layers(self, ts, tgrid, cell(1,E), layerSn);
67 % 1) recompute inter-layer demand trajectories from the latest layer traj
68 demand = local_recompute_demand(self, traj, tgrid, layerSn);
69 % 2) build the per-layer rate_sched injections from those demands
70 schedByLayer = local_build_rate_sched(self, demand, tgrid, layerSn);
71 % 3) re-run each layer with its injected time-varying demand
72 [blocks, traj] = local_run_layers(self, ts, tgrid, schedByLayer, layerSn);
73 % 4) convergence: sup-norm gap of the queue-length trajectories
74 gap = local_supnorm_gap(trajPrev, traj);
75 if self.options.verbose >= VerboseLevel.STD
76 line_printf('\nLN coupled transient: iter %d, sup-norm gap %.3e
', iter, gap);
83% Assemble the block-diagonal aggregate exactly as getTranAvgDecoupled does.
88 [crows, ccols] = size(QNlqn_t);
89 Qe = blocks{e}.Q; Ue = blocks{e}.U; Te = blocks{e}.T;
90 QNlqn_t(crows+1:crows+size(Qe,1), ccols+1:ccols+size(Qe,2)) = Qe;
91 UNlqn_t(crows+1:crows+size(Ue,1), ccols+1:ccols+size(Ue,2)) = Ue;
92 TNlqn_t(crows+1:crows+size(Te,1), ccols+1:ccols+size(Te,2)) = Te;
96% -------------------------------------------------------------------------
97function [blocks, traj] = local_run_layers(self, ts, tgrid, schedByLayer, layerSn)
98% Run each layer's transient (optionally with an injected rate_sched) and
99%
return: blocks{e} =
struct with {M,K} cell fields Q,U,T (native handles,
for
100% block-diagonal assembly); traj{e} =
struct with M x K x numel(tgrid) arrays
101% Q,U,T,R resampled onto tgrid (R = Q./T residence via Little).
108 savedTs = s.options.timespan;
109 hadSched = isfield(s.options.config,
'rate_sched');
111 savedSched = s.options.config.rate_sched;
113 s.options.timespan = ts;
114 if ~isempty(schedByLayer{e})
115 s.options.config.rate_sched = schedByLayer{e};
117 s.options.config.rate_sched = [];
119 % Drop any cached transient: NetworkSolver.getTranAvg returns
the stored
120 % self.result.Tran.Avg on repeat calls and does NOT re-solve on an
121 % options.config change, so without
this the injected rate_sched would be
122 % silently ignored across waveform-relaxation iterations. reset() only
123 % clears
the cached result, not
the model's equilibrium service priming.
125 [Qe, Ue, Te] = s.getTranAvg();
126 s.options.timespan = savedTs;
128 s.options.config.rate_sched = savedSched;
129 elseif isfield(s.options.config,'rate_sched')
130 s.options.config = rmfield(s.options.config,'rate_sched');
132 be = struct(); % avoid struct('Q',{cell}) which builds a
struct ARRAY
133 be.Q = Qe; be.U = Ue; be.T = Te;
135 M = size(Qe,1); K = size(Qe,2);
136 Q = zeros(M,K,ng); U = zeros(M,K,ng); T = zeros(M,K,ng); R = zeros(M,K,ng);
139 qv = local_resample(Qe{i,r}, tgrid);
140 uv = local_resample(Ue{i,r}, tgrid);
141 tv = local_resample(Te{i,r}, tgrid);
142 Q(i,r,:) = qv; U(i,r,:) = uv; T(i,r,:) = tv;
143 R(i,r,:) = qv ./ max(tv, GlobalConstants.FineTol);
146 traj{e} =
struct(
'Q',Q,
'U',U,
'T',T,
'R',R);
150% -------------------------------------------------------------------------
151function v = local_resample(h, tgrid)
152% Resample a transient handle (
struct with .t/.metric, or a scalar) onto tgrid.
153if isstruct(h) && isfield(h,
't') && isfield(h,
'metric') && numel(h.t) >= 2
154 v = interp1(h.t(:), h.metric(:), tgrid,
'linear',
'extrap');
155elseif isstruct(h) && isfield(h,
'metric')
156 v = repmat(h.metric(end), numel(tgrid), 1);
158 v = zeros(numel(tgrid),1);
162% -------------------------------------------------------------------------
163function demand = local_recompute_demand(self, traj, tgrid, layerSn)
164% Recompute
the time-varying inter-layer demands from
the layer trajectories,
165% pointwise in t, mirroring
the scalar updateThinkTimes / updateMetricsDefault
166% formulas. Returns struct with:
167% thinkt : containers.Map tidx -> (ng x 1) think-time trajectory
168% callservt : containers.Map cidx -> (ng x 1) call service-time trajectory
171thinkt = containers.Map('KeyType','
double','ValueType','any');
172callservt = containers.Map('KeyType','
double','ValueType','any');
174% Task think times: from
the task's own server-layer utilization/throughput.
176 tidx = lqn.tshift + t;
177 if isnan(self.idxhash(tidx)) || lqn.isref(tidx)
180 e = self.idxhash(tidx);
181 sIdx = self.ensemble{e}.attribute.serverIdx;
182 K = layerSn{e}.nclasses;
183 Uti = zeros(ng,1); Tti = zeros(ng,1);
185 Uti = Uti + squeeze(traj{e}.U(sIdx,r,:));
186 Tti = Tti + squeeze(traj{e}.T(sIdx,r,:));
188 njobs = max(self.njobs(tidx,:));
189 userthink = lqn.think{tidx}.getMean;
190 Tsafe = max(Tti, GlobalConstants.FineTol);
191 if lqn.sched(tidx) == SchedStrategy.INF
192 tk = (njobs - Uti) ./ Tsafe - userthink;
194 tk = njobs .* abs(1 - Uti) ./ Tsafe - userthink;
196 tk = max(GlobalConstants.Zero, tk) + userthink; % total mean incl. user think
200% Synchronous-call service demands: callee entry response time * call mean.
201for cidx = 1:lqn.ncalls
202 if lqn.calltype(cidx) ~= CallType.SYNC
205 eidx = lqn.callpair(cidx,2); % callee entry
206 tidx = lqn.parent(eidx); % callee task
207 if isnan(self.idxhash(tidx))
210 e = self.idxhash(tidx);
211 sIdx = self.ensemble{e}.attribute.serverIdx;
212 % response time of
the callee at its server, summed over
the entry classes
213 K = layerSn{e}.nclasses;
216 typ = self.ensemble{e}.classes{r}.attribute(1);
217 if typ == LayeredNetworkElement.ENTRY && self.ensemble{e}.classes{r}.attribute(2) == eidx
218 Rc = Rc + squeeze(traj{e}.R(sIdx,r,:));
222 % fall back to
the entry
's activities response time
223 Rc = squeeze(sum(traj{e}.R(sIdx,:,:),2));
226 if ~isempty(lqn.callproc{cidx}) && isa(lqn.callproc{cidx},'Distribution
')
227 callmean = lqn.callproc{cidx}.getMean;
229 callservt(cidx) = Rc * callmean;
232demand = struct('thinkt
', thinkt, 'callservt
', callservt);
235% -------------------------------------------------------------------------
236function schedByLayer = local_build_rate_sched(self, demand, tgrid, layerSn)
237% Map the recomputed demand trajectories to per-layer rate_sched injections,
238% using the same update maps updateLayers uses to place setService calls.
239% options.config.ln_transient_channels selects which inter-layer coupling
240% channels are injected: 'both
' (default), 'thinkt
' (client-delay only), or
241% 'callservt
' (synchronous-call service only). Used to isolate each channel's
242% contribution to
the coupled transient.
244schedByLayer = cell(1,E);
247if isfield(self.options,
'config') && isfield(self.options.config,
'ln_transient_channels') ...
248 && ~isempty(self.options.config.ln_transient_channels)
249 channels = lower(self.options.config.ln_transient_channels);
252% think-time channel (client delay of caller tasks)
253if any(strcmp(channels, {
'both',
'thinkt'}))
254 map = self.thinkt_classes_updmap;
255 for r = 1:size(
map,1)
256 idx =
map(r,1); aidx =
map(r,2); nodeidx =
map(r,3); classidx =
map(r,4);
257 e = self.idxhash(idx);
258 if isnan(e) || nodeidx ~= self.ensemble{e}.attribute.clientIdx
261 if lqn.type(aidx) == LayeredNetworkElement.TASK && lqn.sched(aidx) ~= SchedStrategy.REF ...
262 && isKey(demand.thinkt, aidx)
263 d = demand.thinkt(aidx);
264 schedByLayer{e} = local_add_sched(schedByLayer{e}, layerSn{e}, nodeidx, classidx, tgrid, d);
269% call-service channel (client station of caller
for each sync call)
270if any(strcmp(channels, {
'both',
'callservt'}))
271 map = self.call_classes_updmap;
272 for c = 1:size(
map,1)
273 idx =
map(c,1); cidx =
map(c,2); nodeidx =
map(c,3); classidx =
map(c,4);
274 e = self.idxhash(idx);
275 if isnan(e) || nodeidx ~= self.ensemble{e}.attribute.clientIdx
278 if isKey(demand.callservt, cidx)
279 d = demand.callservt(cidx);
280 schedByLayer{e} = local_add_sched(schedByLayer{e}, layerSn{e}, nodeidx, classidx, tgrid, d);
286% -------------------------------------------------------------------------
287function sched = local_add_sched(sched, sn, nodeidx, classidx, tgrid, demand)
288% Append one rate_sched entry that MODULATES
the layer
's equilibrium rate by
289% the ratio of the transient demand to its steady-state (end-of-horizon) value:
290% effective_rate(t) = nominal * demand(end)/demand(t).
291% Passing rate = 1/demand(t) and nominal_denominator = 1/demand(end) makes the
292% multiplier = demand(end)/demand(t), which is exactly 1 at the horizon end, so
293% the layer relaxes to its unmodified fixed point (oracle 1: endpoint == getAvg)
294% regardless of any small mismatch between the fluid residence Q/T and the
295% scalar equilibrium demand. During the transient the ratio modulates the rate.
296ist = sn.nodeToStation(nodeidx);
302if ~(dend > GlobalConstants.FineTol)
303 return % degenerate steady-state demand; skip
this channel
305% Bound
the transient demand to a physical band around its steady-state value.
306% The Little
's-law demand estimates (thinkt = njobs*(1-util)/tput, R = Q/T)
307% diverge at early t when the layer transient throughput is still ~0 (no jobs
308% have completed yet), producing unbounded multipliers. An inter-layer demand
309% cannot realistically deviate from its equilibrium by an unbounded factor; the
310% fluid ODE tolerates the spikes but the CTMC uniformization does not (an
311% enormous generator scaling ejects all mass from a state within one segment).
312% Clamping d to [dend/Cap, dend*Cap] keeps the multiplier m=dend/d in
313% [1/Cap, Cap] and is exactly 1 at the horizon end (endpoint preserved).
315d = min(max(d, dend/Cap), dend*Cap);
316% self-normalising rate: nominal is the reciprocal of the steady-state demand,
317% so solver_fluid_ratemult's multiplier = rate/nominal = dend/d(t).
320entry =
struct(
'station', ist,
'class', classidx,
'tgrid', tgrid(:)
', ...
321 'rates
', rates, 'nominal
', nominal);
325 sched(end+1) = entry; %#ok<AGROW>
329% -------------------------------------------------------------------------
330function gap = local_supnorm_gap(trajPrev, traj)
331% Sup-norm of the queue-length trajectory change across all layers.
334 d = abs(traj{e}.Q(:) - trajPrev{e}.Q(:));
336 gap = max(gap, max(d));