1% renv_container_terminal - Daily-cycle container terminal in a random environment
3% Advanced example
for SolverENV
's STATE-VECTOR analyzer (options.method =
4% 'statevec
'), which carries the full per-stage state distribution across
5% environment switches instead of collapsing it to marginal mean queue lengths.
7% Case study (after the fyp26 SOQN blending port): the Rotterdam container
8% terminal of Dhingra et al. Container handling demand varies over the 24 hours
9% of a day; each hour is one environment "stage" with its own demand intensity
10% and (random, exponential) duration. The environment visits the 24 hourly
11% stages in a fixed daily cycle 1->2->...->24->1.
13% Here the semi-open SOQN of the original study is rendered as the equivalent
14% finite-token CLOSED network required by SolverENV: N straddle carriers cycle
15% between a yard staging Delay and a multi-server quay-crane Queue. The hourly
16% demand modulates the yard staging rate (the closed-network analog of the
17% time-varying external arrival intensity): busier hours stage containers to the
18% cranes faster, so the cranes congest during the daily peaks.
20% The script compares, for the day-averaged metrics:
21% (a) statevec - full state-vector blending (this feature),
22% (b) meanfield - the default mean-field (marginal mean-queue-length) coupling,
23% (c) exact - the stationary solution of the full joint
24% (hour x network-state) random-environment CTMC.
25% The state-vector blend tracks the exact joint solution far more closely than
26% the mean-field collapse.
28% A second section repeats the comparison with the internal terminal handling
29% (quay cranes -> stacking cranes) collapsed by Norton's theorem into a single
30% closed, load-dependent Flow-Equivalent Server (FES). This shows that a closed
31% FES (load-dependent station, sn.lldscaling)
is fully supported by the
32% state-vector analyzer's CTMC backend.
34% A third section analyses the OPEN counterpart: the hourly demand becomes an
35% external Markov-modulated Poisson arrival stream (MMPP(24)) into a multi-server
36% queue with an unbounded buffer, solved exactly by SolverMAM as a QBD. This
is
37% the open / infinite-buffer regime that the closed-CTMC state-vector path cannot
38% represent, and corresponds to the joint-MMPP baseline of the fyp26 study.
40% Copyright (c) 2012-2026, Imperial College London
45%% Daily demand schedule (Dhingra Rotterdam terminal, 24 hours)
46% Hourly container demand (moves/hr) and the mean duration of each hour-stage.
47dailyRateHr = [ 6, 30, 40, 62, 76, 79, 119, 164, 152, 130, 79, 70, ...
48 57, 57, 113, 130, 162, 202, 148, 118, 92, 62, 36, 8];
49dailyDurationHr = [0.51,0.84,0.76,0.98,0.67,2.33,1.80,0.62,0.36,1.30,1.02,0.98, ...
50 0.86,1.56,1.30,1.57,0.34,0.22,0.47,1.33,1.56,0.93,0.71,1.11];
51E = numel(dailyRateHr); % 24 hourly environment stages
53%% Terminal physical parameters (kept small so the CTMC
is exact and fast)
54N = 6; % straddle carriers / containers circulating (closed tokens)
55nCranes = 2; % quay cranes (multi-server FCFS queue)
56craneRate = 16; % moves/hr served per crane
57% Yard staging rate per hour: scale demand so peak hours saturate the cranes.
58stageRate = dailyRateHr / N; % per-token yard completion rate in each hour
60%% Build the random environment: one stage per hour, cyclic daily transitions
61env = Environment('RotterdamDailyCycle');
63 env.addStage(sprintf('Hour%02d', h-1), 'operational', ...
64 terminalModel(stageRate(h), craneRate, nCranes, N));
66% Deterministic-on-average daily cycle h -> h+1 with exponential hour durations.
68 hNext = mod(h, E) + 1;
69 env.addTransition(sprintf('Hour%02d', h-1), sprintf('Hour%02d', hNext-1), ...
70 Exp(1/dailyDurationHr(h)));
74fprintf('Rotterdam container terminal: %d hourly stages, %d carriers, %d cranes.\n', E, N, nCranes);
76%% Inner solver: CTMC with a finite transient horizon (required by statevec)
77T = 12; % hours; covers the hour-duration CDF tails (>99%)
78ctmcFactory = @(m) CTMC(m, 'timespan', [0,T], 'verbose', false);
80baseOpt = Solver.defaultOptions;
81baseOpt.iter_max = 100;
82baseOpt.iter_tol = 1e-5;
83baseOpt.verbose = false;
85%% (a) State-vector analyzer
86optStatevec = baseOpt; optStatevec.method = 'statevec';
87solverStatevec = ENV(env, ctmcFactory, optStatevec);
88[Qsv, Usv, ~, Tsv] = solverStatevec.getAvg();
90%% (b) Mean-field analyzer (default coupling)
91optMeanfield = baseOpt; optMeanfield.method = 'meanfield';
92solverMeanfield = ENV(env, ctmcFactory, optMeanfield);
93[Qmf, Umf, ~, Tmf] = solverMeanfield.getAvg();
95%% (c) Exact joint random-environment CTMC (ground truth)
96[Qex, Uex, Tex] = exactJointMetrics(env, ctmcFactory);
98%% Report day-averaged quay-crane metrics
99crane = 2; % station index of the QuayCranes queue
100fprintf('\n=== Day-averaged quay-crane metrics (container terminal) ===\n');
101fprintf('%-10s %12s %12s %12s\n', 'analyzer', 'QLen', 'Util', 'Tput');
102fprintf('%-10s %12.5f %12.5f %12.5f\n', 'exact', Qex(crane), Uex(crane), Tex(crane));
103fprintf('%-10s %12.5f %12.5f %12.5f\n', 'statevec', Qsv(crane), Usv(crane), Tsv(crane));
104fprintf('%-10s %12.5f %12.5f %12.5f\n', 'meanfield', Qmf(crane), Umf(crane), Tmf(crane));
106fprintf('\nQLen error vs exact: statevec = %.3e , meanfield = %.3e\n', ...
107 abs(Qsv(crane)-Qex(crane)), abs(Qmf(crane)-Qex(crane)));
108fprintf('Util error vs exact: statevec = %.3e , meanfield = %.3e\n', ...
109 abs(Usv(crane)-Uex(crane)), abs(Umf(crane)-Uex(crane)));
111fprintf('\nDay-averaged crane-queue table (state-vector analyzer):\n');
112solverStatevec.getAvgTable()
114%% ===== Closed flow-equivalent-server (FES) variant =========================
115% The internal terminal handling (quay cranes -> stacking cranes)
is collapsed
116% by Norton's theorem into a single load-dependent FES whose rate mu(n)
is the
117% throughput of that subnetwork at n containers in process. The load-dependent
118% station populates sn.lldscaling, which SolverCTMC honours, so the state-vector
119% analyzer applies unchanged to this closed FES network. Each hour-stage
is now
120% Yard(stageRate_h) -> FES; the physical terminal
is fixed across the day, so the
121% FES rate curve
is computed once and reused by every stage.
122fprintf('\n=== Closed FES variant (Norton-aggregated terminal internals) ===\n');
123muQuay = 16; muStack = 20; % internal quay / stacking rates
124fesRate = fesRateCurve(muQuay, muStack, N); % load-dependent mu(n), n=1..N
125fprintf('Norton FES rate curve mu(n) = %s\n', mat2str(fesRate, 5));
127envFES = Environment('RotterdamDailyCycleFES');
129 envFES.addStage(sprintf('Hour%02d', h-1), 'operational', ...
130 terminalFESModel(stageRate(h), fesRate));
133 hNext = mod(h, E) + 1;
134 envFES.addTransition(sprintf('Hour%02d', h-1), sprintf('Hour%02d', hNext-1), ...
135 Exp(1/dailyDurationHr(h)));
139[Qsf, Usf, ~, Tsf] = ENV(envFES, ctmcFactory, optStatevec).getAvg();
140[Qff, Uff, ~, Tff] = ENV(envFES, ctmcFactory, optMeanfield).getAvg();
141[Qxf, Uxf, Txf] = exactJointMetrics(envFES, ctmcFactory);
143fes = 2; % station index of the load-dependent FES
144fprintf('%-10s %12s %12s %12s\n', 'analyzer', 'QLen', 'Util', 'Tput');
145fprintf('%-10s %12.5f %12.5f %12.5f\n', 'exact', Qxf(fes), Uxf(fes), Txf(fes));
146fprintf('%-10s %12.5f %12.5f %12.5f\n', 'statevec', Qsf(fes), Usf(fes), Tsf(fes));
147fprintf('%-10s %12.5f %12.5f %12.5f\n', 'meanfield', Qff(fes), Uff(fes), Tff(fes));
148fprintf('\nQLen error vs exact: statevec = %.3e , meanfield = %.3e\n', ...
149 abs(Qsf(fes)-Qxf(fes)), abs(Qff(fes)-Qxf(fes)));
150fprintf('Util error vs exact: statevec = %.3e , meanfield = %.3e\n', ...
151 abs(Usf(fes)-Uxf(fes)), abs(Uff(fes)-Uxf(fes)));
153%% ===== Open system via MAM (MMPP(24)/M/c, infinite buffer) =================
154% The semi-open SOQN's open counterpart: rather than a finite carrier pool, the
155% 24-hour demand
is an external arrival stream whose rate
is modulated by the
156% hour of day. This
is a Markov-modulated Poisson process (MMPP): the 24-state
157% ring
is the environment generator and the per-state arrival rate
is the hourly
158% demand. The resulting MMPP(24)/M/c queue has an UNBOUNDED buffer, so it
is
159% outside the closed-CTMC state-vector path; SolverMAM solves it exactly as a QBD
160% (this
is the joint-MMPP baseline of the fyp26 study). Because the open stream
161%
is not throttled by a finite token pool, more crane capacity
is needed for
162% stability than in the closed models.
163fprintf('\n=== Open system via MAM (MMPP(24)/M/c, infinite buffer) ===\n');
166 hNext = mod(h, E) + 1;
167 Qenv(h, hNext) = 1/dailyDurationHr(h); % cyclic hour transition
168 Qenv(h, h) = -1/dailyDurationHr(h);
170mmpp = MAP({Qenv - diag(dailyRateHr), diag(dailyRateHr)}); % rate-modulated arrivals
171cOpen = 8; % cranes (open regime)
173openModel = Network(
'OpenTerminal');
174ships = Source(openModel,
'Ships');
175quayOpen = Queue(openModel,
'QuayCranes', SchedStrategy.FCFS);
176done = Sink(openModel,
'Departures');
177oc = OpenClass(openModel,
'Containers');
178ships.setArrival(oc, mmpp);
179quayOpen.setService(oc, Exp(craneRate));
180quayOpen.setNumberOfServers(cOpen);
181openModel.link(Network.serialRouting(ships, quayOpen, done));
183meanLambda = sum(dailyDurationHr .* dailyRateHr) / sum(dailyDurationHr);
184fprintf(
'MMPP mean arrival = %.2f/hr, %d cranes @ %.0f/hr, rho = %.3f\n', ...
185 meanLambda, cOpen, craneRate, meanLambda/(cOpen*craneRate));
186SolverMAM(openModel).getAvgTable
188%% ----- local functions -----------------------------------------------------
189function qn = terminalModel(stageRate, craneRate, nCranes, N)
190% One hour-stage network: N carriers cycling yard-Delay -> quay-crane Queue.
191qn = Network(
'Terminal');
192yard = Delay(qn,
'Yard');
193cranes = Queue(qn,
'QuayCranes', SchedStrategy.FCFS);
194cranes.setNumberOfServers(nCranes);
195containers = ClosedClass(qn,
'Containers', N, yard);
196yard.setService(containers, Exp(stageRate));
197cranes.setService(containers, Exp(craneRate));
198qn.link(Network.serialRouting(yard, cranes));
201function qn = terminalFESModel(stageRate, fesRate)
202% One hour-stage with the internal terminal handling as a closed load-dependent
203% FES: N containers cycling yard-Delay -> FES, where the FES serves at rate
204% mu(n) = fesRate(n) when n containers are inside it (set via setLoadDependence).
206qn = Network(
'TerminalFES');
207yard = Delay(qn,
'Yard');
208fesq = Queue(qn,
'TerminalFES', SchedStrategy.PS);
209containers = ClosedClass(qn,
'Containers', N, yard);
210yard.setService(containers, Exp(stageRate));
211fesq.setService(containers, Exp(fesRate(1))); % base rate mu(1)
212fesq.setLoadDependence(fesRate / fesRate(1)); % scaling alpha(n) = mu(n)/mu(1)
213qn.link(Network.serialRouting(yard, fesq));
216function mu = fesRateCurve(muQuay, muStack, N)
217% Norton flow-equivalent rates: throughput of the isolated internal subnetwork
218% (quay cranes -> stacking cranes, processor sharing) at n = 1..N containers,
219% with the rest of the terminal
short-circuited by a near-instantaneous delay.
221mvaOpt = SolverMVA.defaultOptions; mvaOpt.method = 'exact'; mvaOpt.verbose = false;
223 sub = Network('TerminalInternals');
224 ref = Delay(sub, 'ShortCircuit');
225 quay = Queue(sub, 'QuayCranes', SchedStrategy.PS);
226 stack = Queue(sub, 'StackingCranes', SchedStrategy.PS);
227 cls = ClosedClass(sub, 'Containers', n, ref);
228 ref.setService(cls, Exp(1e6)); % ~instantaneous
short-circuit
229 quay.setService(cls, Exp(muQuay));
230 stack.setService(cls, Exp(muStack));
231 sub.link(Network.serialRouting(ref, quay, stack));
232 T = SolverMVA(sub, mvaOpt).getAvgTable;
233 mu(n) = T.Tput(1); % subnetwork throughput at n
237function [QN, UN, TN] = exactJointMetrics(env, ctmcFactory)
238% Day-averaged metrics from the exact joint (hour x network-state) CTMC.
239solverX = ENV(env, ctmcFactory, Solver.defaultOptions);
240renvQ = solverX.getGenerator(); % flattened exact generator
241piJoint = ctmc_solve(renvQ); piJoint = piJoint(:)';
243mdl = env.getEnsemble;
245M = mdl{1}.getNumberOfStations;
246K = mdl{1}.getNumberOfClasses;
247QN = zeros(M,K); UN = zeros(M,K); TN = zeros(M,K); probEnv = zeros(1,E);
250 opts = ctmcFactory(mdl{e}).getOptions;
251 [Qe, SSe, SSae, ~, arve, depe, sne] = solver_ctmc(mdl{e}.getStruct, opts);
253 blk = piJoint(off+1:off+ns); off = off + ns;
254 probEnv(e) = sum(blk);
255 if sum(blk) > 0, blk = blk/sum(blk); end
256 [QNe, UNe, ~, TNe] = solver_ctmc_avg_from_pi(sne, blk, SSe, SSae, arve, depe);
257 QN = QN + probEnv(e)*QNe;
258 UN = UN + probEnv(e)*UNe;
259 TN = TN + probEnv(e)*TNe;