2% SN_FJ_VISITS_SPN Compute fork-join node visit ratios via auxiliary SPN models
4% NODEVISITS = SN_FJ_VISITS_SPN(SN) builds,
for each class that passes
5% through a fork-join pair, an auxiliary closed Stochastic Petri Net
6% capturing the fork/join synchronization semantics. The SPN
is solved
7% with SolverCTMC and the throughput ratios give the per-node visit ratios.
9% Population-preserving approach: the SPN uses population = B (number of
10% fork branches). The pre-fork station consumes B tokens and produces 1
11% per branch. The Join consumes 1 per branch and produces B. This ensures
12% token conservation and correct CTMC analysis.
14% The function handles:
15% - Multiple fork-join pairs (including serial fork-joins)
19%
nodevisits - cell(1, nchains), each entry
is (nnodes x nclasses)
20% with visit ratios normalized to reference station = 1
22% Copyright (c) 2012-2026, Imperial College London
40% For each chain, build and solve an SPN for each class in the chain
42 for kidx = 1:length(inchain{c})
45 % Extract the single-
class sub-routing from rtnodes
49 P_r(i, j) = sn.rtnodes((i-1)*K + r, (j-1)*K + r);
53 % Find
nodes visited by
this class (reachable from reference station)
54 refnode = sn.stationToNode(refstat(r));
55 visited =
false(1, I);
56 visited(refnode) =
true;
63 if P_r(i, j) > 0 && ~visited(j)
72 % Skip
if this class doesn't pass through any fork
75 if visited(i) && sn.nodetype(i) == NodeType.Fork
89 % Build auxiliary SPN model
90 nodevisits{c}(:, r) = build_and_solve_spn(sn, P_r, visited, r, refnode);
93 % Normalize by reference station
94 refnode_c = sn.stationToNode(refstat(inchain{c}(1)));
95 for kidx = 1:length(inchain{c})
98 if normVal > GlobalConstants.FineTol
105function visits_r = build_and_solve_spn(sn, P_r, visited, r, refnode)
106% BUILD_AND_SOLVE_SPN Construct and solve auxiliary SPN
for one
class
108% Uses a population-preserving approach:
109% - Population = total leaf branches (B) across all fork-join pairs
110% - Pre-fork station:
requires B tokens, produces 1 per leaf branch
111% - Branch stations: normal 1-in-1-out service
112% - Join:
requires 1 from each branch done, produces B in post-join dest
113% - Throughput ratios are uniform across all station Places =>
visits = 1
116visits_r = zeros(I, 1);
118visitedNodes = find(visited);
119nVisited = length(visitedNodes);
124% Classify visited
nodes
129 nd = visitedNodes(idx);
130 if sn.nodetype(nd) == NodeType.Fork
131 forkNodes(end+1) = nd; %#ok<AGROW>
132 elseif sn.nodetype(nd) == NodeType.Join
133 joinNodes(end+1) = nd; %#ok<AGROW>
134 elseif sn.isstation(nd) && sn.nodetype(nd) ~= NodeType.Source && sn.nodetype(nd) ~= NodeType.Sink
135 stationNodes(end+1) = nd; %#ok<AGROW>
139% Compute leaf count
for each Join node (bottom-up, handles arbitrary nesting)
140% join_leaves(jnd) = number of leaf tokens that flow through
this Join
141% (indexed by node index; NaN entries denote Joins not yet resolved)
142join_leaves = nan(1, I);
143for pass = 1:length(joinNodes) % iterate until stable
144 for jidx = 1:length(joinNodes)
145 jnd = joinNodes(jidx);
146 srcNodes_j = find(P_r(:, jnd) > 0 & visited
');
148 for si = 1:length(srcNodes_j)
149 srcNd = srcNodes_j(si);
150 if sn.nodetype(srcNd) == NodeType.Join && ~isnan(join_leaves(srcNd))
151 lc = lc + join_leaves(srcNd);
153 lc = lc + 1; % station or unresolved
156 join_leaves(jnd) = lc;
160% Compute total leaf branches B (= population for conservation)
161% For each fork, resolve to leaf stations; B = max leaf count across outermost forks
163for idx = 1:length(forkNodes)
164 fnd = forkNodes(idx);
165 % Check if this Fork is an outermost fork (not a descendant of another fork)
166 % An outermost fork has its upstream being a station, not another fork
167 srcNodes = find(P_r(:, fnd) > 0 & visited');
169 for si = 1:length(srcNodes)
170 if sn.nodetype(srcNodes(si)) == NodeType.Join
171 is_outermost =
false; % serial fork - Join feeds
this Fork
176 leafs = resolve_fork_dests(sn, P_r, visited, fnd);
177 B = max(B, length(leafs));
185model = Network(
'fj_spn_aux');
187% Create Places
for station
nodes
189for idx = 1:length(stationNodes)
190 nd = stationNodes(idx);
191 places{nd} = Place(model, sprintf(
'P_%s', sn.nodenames{nd}));
194% Create
"done" Places
for stations that route to a Join
195pre_join = cell(1, I);
196for idx = 1:length(stationNodes)
197 nd = stationNodes(idx);
198 dstNodes = find(P_r(nd, :) > 0 & visited);
199 for di = 1:length(dstNodes)
200 if sn.nodetype(dstNodes(di)) == NodeType.Join
201 pre_join{nd} = Place(model, sprintf(
'P_%s_done', sn.nodenames{nd}));
207% For inner Joins that route to outer Joins (nested fork-join)
208for idx = 1:length(joinNodes)
209 jnd = joinNodes(idx);
210 dstNodes = find(P_r(jnd, :) > 0 & visited);
211 for di = 1:length(dstNodes)
212 if sn.nodetype(dstNodes(di)) == NodeType.Join
213 pre_join{jnd} = Place(model, sprintf(
'P_%s_done', sn.nodenames{jnd}));
219% Intermediate Places
for Join -> Fork connections (serial fork-join)
220inter_jf = cell(1, I);
221for idx = 1:length(joinNodes)
222 jnd = joinNodes(idx);
223 dstNodes = find(P_r(jnd, :) > 0 & visited);
224 for di = 1:length(dstNodes)
225 if sn.nodetype(dstNodes(di)) == NodeType.Fork
226 inter_jf{jnd} = Place(model, sprintf(
'P_%s_to_%s', sn.nodenames{jnd}, sn.nodenames{dstNodes(di)}));
232% ClosedClass with B tokens at reference Place
233jobclass = ClosedClass(model,
'Token', B, places{refnode});
238% Create timed service Transitions
for each station
239for idx = 1:length(stationNodes)
240 nd = stationNodes(idx);
241 ist = sn.nodeToStation(nd);
246 if ist > 0 && ~isempty(sn.rates) && ist <= size(sn.rates, 1) && r <= size(sn.rates, 2) && sn.rates(ist, r) > 0 && ~isinf(sn.rates(ist, r))
247 rate = sn.rates(ist, r);
251 % Determine effective output Places by resolving through Fork/Join routing
252 dstNodes = find(P_r(nd, :) > 0 & visited);
254 enable_count = 1; % how many tokens to require/consume
255 for di = 1:length(dstNodes)
256 dstNd = dstNodes(di);
257 if sn.nodetype(dstNd) == NodeType.Fork
258 % Resolve Fork to leaf stations; require B tokens (population-preserving)
259 forkDests = resolve_fork_dests(sn, P_r, visited, dstNd);
260 enable_count = B; % require all B tokens
261 for fi = 1:length(forkDests)
262 if ~isempty(places{forkDests(fi)})
263 out_places{end+1} = places{forkDests(fi)}; %#ok<AGROW>
266 elseif sn.nodetype(dstNd) == NodeType.Join
267 if ~isempty(pre_join{nd})
268 out_places{end+1} = pre_join{nd}; %#ok<AGROW>
270 elseif sn.isstation(dstNd)
271 if ~isempty(places{dstNd})
272 out_places{end+1} = places{dstNd}; %#ok<AGROW>
277 if isempty(out_places),
continue; end
279 tName = sprintf(
'T_svc_%s', sn.nodenames{nd});
280 T = Transition(model, tName);
281 mode = T.addMode(
'serve');
284 T.setDistribution(mode, Exp(rate));
286 T.setTimingStrategy(mode, TimingStrategy.IMMEDIATE);
287 T.setFiringWeights(mode, 1.0);
290 T.setEnablingConditions(mode,
jobclass, places{nd}, enable_count);
291 T.setFiringOutcome(mode,
jobclass, places{nd}, -enable_count);
292 for oi = 1:length(out_places)
293 T.setFiringOutcome(mode,
jobclass, out_places{oi}, 1);
296 transitions{end+1} = T; %#ok<AGROW>
297 transInfo{end+1} =
struct(
'type',
'service',
'nd', nd, ...
298 'inPlaces', {{places{nd}}},
'outPlaces', {out_places}); %#ok<AGROW>
301% Create Immediate Join Transitions
302for idx = 1:length(joinNodes)
303 jnd = joinNodes(idx);
304 srcNodes = find(P_r(:, jnd) > 0 & visited
');
305 dstNodes = find(P_r(jnd, :) > 0 & visited);
307 % Collect input "done" Places and their source nodes
310 for si = 1:length(srcNodes)
311 srcNd = srcNodes(si);
312 if ~isempty(pre_join{srcNd})
313 in_places{end+1} = pre_join{srcNd}; %#ok<AGROW>
314 in_src_nodes(end+1) = srcNd; %#ok<AGROW>
317 if isempty(in_places), continue; end
319 % Compute enabling counts per input: 1 for stations, join_leaves for inner joins
320 en_counts = ones(1, length(in_places));
321 for ii = 1:length(in_src_nodes)
322 srcNd = in_src_nodes(ii);
323 if sn.nodetype(srcNd) == NodeType.Join && ~isnan(join_leaves(srcNd))
324 en_counts(ii) = join_leaves(srcNd);
328 % produce_count = join_leaves(jnd) = total leaf tokens through this Join
329 if ~isnan(join_leaves(jnd))
330 produce_count = join_leaves(jnd);
332 produce_count = sum(en_counts);
335 % Determine post-Join output Places
337 for di = 1:length(dstNodes)
338 dstNd = dstNodes(di);
339 if sn.nodetype(dstNd) == NodeType.Fork
341 if ~isempty(inter_jf{jnd})
342 out_places{end+1} = inter_jf{jnd}; %#ok<AGROW>
344 elseif sn.nodetype(dstNd) == NodeType.Join
345 % Nested: inner Join -> outer Join
346 if ~isempty(pre_join{jnd})
347 out_places{end+1} = pre_join{jnd}; %#ok<AGROW>
349 elseif sn.isstation(dstNd)
350 if ~isempty(places{dstNd})
351 out_places{end+1} = places{dstNd}; %#ok<AGROW>
355 if isempty(out_places), continue; end
357 tName = sprintf('T_join_%s
', sn.nodenames{jnd});
358 T = Transition(model, tName);
359 mode = T.addMode('sync
');
360 T.setTimingStrategy(mode, TimingStrategy.IMMEDIATE);
361 T.setFiringWeights(mode, 1.0);
363 for ii = 1:length(in_places)
364 T.setEnablingConditions(mode, jobclass, in_places{ii}, en_counts(ii));
365 T.setFiringOutcome(mode, jobclass, in_places{ii}, -en_counts(ii));
367 for oi = 1:length(out_places)
368 T.setFiringOutcome(mode, jobclass, out_places{oi}, produce_count);
371 transitions{end+1} = T; %#ok<AGROW>
372 transInfo{end+1} = struct('type
', 'join
', 'nd
', jnd, ...
373 'inPlaces
', {in_places}, 'outPlaces
', {out_places}); %#ok<AGROW>
376% Create Immediate Fork Transitions for Join -> Fork connections (serial FJ)
377for idx = 1:length(joinNodes)
378 jnd = joinNodes(idx);
379 if isempty(inter_jf{jnd}), continue; end
381 dstNodes = find(P_r(jnd, :) > 0 & visited);
382 for di = 1:length(dstNodes)
383 dstNd = dstNodes(di);
384 if sn.nodetype(dstNd) ~= NodeType.Fork, continue; end
386 forkDests = resolve_fork_dests(sn, P_r, visited, dstNd);
387 if isempty(forkDests), continue; end
389 tName = sprintf('T_fork_%s_%s
', sn.nodenames{jnd}, sn.nodenames{dstNd});
390 T = Transition(model, tName);
391 mode = T.addMode('fork
');
392 T.setTimingStrategy(mode, TimingStrategy.IMMEDIATE);
393 T.setFiringWeights(mode, 1.0);
395 T.setEnablingConditions(mode, jobclass, inter_jf{jnd}, B);
396 T.setFiringOutcome(mode, jobclass, inter_jf{jnd}, -B);
398 for fi = 1:length(forkDests)
399 if ~isempty(places{forkDests(fi)})
400 T.setFiringOutcome(mode, jobclass, places{forkDests(fi)}, 1);
401 fork_out{end+1} = places{forkDests(fi)}; %#ok<AGROW>
405 transitions{end+1} = T; %#ok<AGROW>
406 transInfo{end+1} = struct('type
', 'fork
', 'nd
', jnd, ...
407 'inPlaces
', {{inter_jf{jnd}}}, 'outPlaces
', {fork_out}); %#ok<AGROW>
411% Set up routing matrix
412R = model.initRoutingMatrix();
413for tidx = 1:length(transitions)
414 T = transitions{tidx};
415 info = transInfo{tidx};
416 for ii = 1:length(info.inPlaces)
417 R{1,1}(info.inPlaces{ii}, T) = 1;
419 for oi = 1:length(info.outPlaces)
420 R{1,1}(T, info.outPlaces{oi}) = 1;
425% Set initial state: B tokens at reference, 0 everywhere else
426places{refnode}.setState(B);
428 if ~isempty(places{i}) && i ~= refnode
429 places{i}.setState(0);
431 if ~isempty(pre_join{i})
432 pre_join{i}.setState(0);
434 if ~isempty(inter_jf{i})
435 inter_jf{i}.setState(0);
441 solver = SolverCTMC(model);
442 avg = solver.getAvgTable();
444 % Extract throughput ratios as visit ratios
445 stationNames = avg.Station;
449 nd = visitedNodes(idx);
450 if ~isempty(places{nd})
451 placeName = places{nd}.name;
452 rowIdx = find(strcmp(string(stationNames), placeName));
454 visits_r(nd) = tputVals(rowIdx(1));
459 line_warning(mfilename, sprintf('SPN CTMC solve failed for class %d: %s
', r, ME.message));
463function stDests = resolve_fork_dests(sn, P_r, visited, forkNd)
464% RESOLVE_FORK_DESTS Recursively resolve Fork destinations to station nodes
466branchDests = find(P_r(forkNd, :) > 0 & visited);
467for bdi = 1:length(branchDests)
468 bd = branchDests(bdi);
469 if sn.nodetype(bd) == NodeType.Fork
470 stDests = [stDests, resolve_fork_dests(sn, P_r, visited, bd)]; %#ok<AGROW>
471 elseif sn.isstation(bd)
472 stDests = [stDests, bd]; %#ok<AGROW>