LINE Solver
MATLAB API documentation
Loading...
Searching...
No Matches
Workflow.m
1classdef Workflow < Model
2 % Workflow - A computational workflow that can be converted to a PH distribution.
3 %
4 % Workflow allows declaring computational workflows using the same
5 % activity graph syntax as LayeredNetwork. The workflow can then be
6 % converted to an equivalent phase-type (PH) distribution via explicit
7 % CTMC construction.
8 %
9 % Supported precedence patterns:
10 % - Serial: Sequential execution
11 % - AndFork/AndJoin: Parallel execution with synchronization
12 % - OrFork/OrJoin: Probabilistic branching
13 % - Loop: Repeated execution
14 %
15 % Example:
16 % wf = Workflow('myWorkflow');
17 % A = wf.addActivity('A', Exp.fitMean(1.0));
18 % B = wf.addActivity('B', Exp.fitMean(2.0));
19 % wf.addPrecedence(Workflow.Serial(A, B));
20 % ph = wf.toPH();
21 %
22 % Copyright (c) 2012-2026, Imperial College London
23 % All rights reserved.
24
25 properties
26 activities = {}; % Cell array of WorkflowActivity objects
27 precedences = []; % Array of ActivityPrecedence objects
28 activityMap; % Map from activity name to index
29 end
30
31 properties (Hidden)
32 cachedPH; % Cached phase-type distribution result
33 end
34
35 methods
36 function self = Workflow(name)
37 % WORKFLOW Create a new Workflow instance
38 %
39 % SELF = WORKFLOW(NAME)
40 %
41 % Parameters:
42 % name - Name for the workflow
43
44 self@Model(name);
45 self.activities = {};
46 self.precedences = [];
47 self.activityMap = containers.Map();
48 self.cachedPH = [];
49 end
50
51 function act = addActivity(self, name, hostDemand)
52 % ADDACTIVITY Add an activity to the workflow
53 %
54 % ACT = ADDACTIVITY(SELF, NAME, HOSTDEMAND)
55 %
56 % Parameters:
57 % name - Activity name (string)
58 % hostDemand - Service time distribution or numeric mean
59 %
60 % Returns:
61 % act - WorkflowActivity object
62
63 if nargin < 3
64 hostDemand = GlobalConstants.FineTol;
65 end
66
67 act = WorkflowActivity(self, name, hostDemand);
68 self.activities{end+1} = act;
69 act.index = length(self.activities);
70 self.activityMap(name) = act.index;
71 self.cachedPH = []; % Invalidate cache
72 end
73
74 function self = addPrecedence(self, prec)
75 % ADDPRECEDENCE Add precedence constraints to the workflow
76 %
77 % SELF = ADDPRECEDENCE(SELF, PREC)
78 %
79 % Parameters:
80 % prec - ActivityPrecedence object or cell array (from Serial)
81
82 if iscell(prec)
83 for m = 1:length(prec)
84 self.precedences = [self.precedences; prec{m}];
85 end
86 else
87 self.precedences = [self.precedences; prec];
88 end
89 self.cachedPH = []; % Invalidate cache
90 end
91
92 function act = getActivity(self, name)
93 % GETACTIVITY Get activity by name
94 %
95 % ACT = GETACTIVITY(SELF, NAME)
96 %
97 % Parameters:
98 % name - Activity name (string)
99 %
100 % Returns:
101 % act - WorkflowActivity object
102
103 if isa(name, 'WorkflowActivity')
104 act = name;
105 return;
106 end
107 if ~isKey(self.activityMap, name)
108 line_error(mfilename, sprintf('Activity "%s" not found in workflow.', name));
109 end
110 idx = self.activityMap(name);
111 act = self.activities{idx};
112 end
113
114 function idx = getActivityIndex(self, name)
115 % GETACTIVITYINDEX Get activity index by name
116 %
117 % IDX = GETACTIVITYINDEX(SELF, NAME)
118
119 if isa(name, 'WorkflowActivity')
120 idx = name.index;
121 elseif isKey(self.activityMap, name)
122 idx = self.activityMap(name);
123 else
124 idx = -1;
125 end
126 end
127
128 function [isValid, msg] = validate(self)
129 % VALIDATE Validate workflow structure
130 %
131 % [ISVALID, MSG] = VALIDATE(SELF)
132 %
133 % Returns:
134 % isValid - true if workflow is valid
135 % msg - Error message if invalid
136
137 isValid = true;
138 msg = '';
139
140 % Check 1: At least one activity
141 if isempty(self.activities)
142 isValid = false;
143 msg = 'Workflow must have at least one activity.';
144 return;
145 end
146
147 % Check 2: All activities in precedences exist
148 for p = 1:length(self.precedences)
149 prec = self.precedences(p);
150 for a = 1:length(prec.preActs)
151 if ~isKey(self.activityMap, prec.preActs{a})
152 isValid = false;
153 msg = sprintf('Activity "%s" referenced in precedence not found in workflow.', prec.preActs{a});
154 return;
155 end
156 end
157 for a = 1:length(prec.postActs)
158 if ~isKey(self.activityMap, prec.postActs{a})
159 isValid = false;
160 msg = sprintf('Activity "%s" referenced in precedence not found in workflow.', prec.postActs{a});
161 return;
162 end
163 end
164 end
165
166 % Check 3: OR-fork probabilities sum to 1
167 for p = 1:length(self.precedences)
168 prec = self.precedences(p);
169 if prec.postType == ActivityPrecedenceType.POST_OR
170 if isempty(prec.postParams)
171 isValid = false;
172 msg = 'OR-fork must have probabilities specified.';
173 return;
174 end
175 if abs(sum(prec.postParams) - 1.0) > GlobalConstants.FineTol
176 isValid = false;
177 msg = sprintf('OR-fork probabilities must sum to 1 (got %.4f).', sum(prec.postParams));
178 return;
179 end
180 end
181 end
182
183 % Check 4: Loop counts must be positive
184 for p = 1:length(self.precedences)
185 prec = self.precedences(p);
186 if prec.postType == ActivityPrecedenceType.POST_LOOP
187 if isempty(prec.postParams) || prec.postParams <= 0
188 isValid = false;
189 msg = 'Loop count must be a positive number.';
190 return;
191 end
192 end
193 end
194 end
195
196 function ph = toPH(self)
197 % TOPH Convert workflow to phase-type distribution
198 %
199 % PH = TOPH(SELF)
200 %
201 % Returns:
202 % ph - APH distribution representing the workflow execution time
203
204 if ~isempty(self.cachedPH)
205 ph = self.cachedPH;
206 return;
207 end
208
209 [isValid, msg] = self.validate();
210 if ~isValid
211 line_error(mfilename, msg);
212 end
213
214 [alpha, T] = self.buildCTMC();
215 ph = APH(alpha, T);
216 self.cachedPH = ph;
217 end
218
219 function [alpha, T] = buildCTMC(self)
220 % BUILDCTMC Build the CTMC representation of the workflow
221 %
222 % [ALPHA, T] = BUILDCTMC(SELF)
223 %
224 % Returns:
225 % alpha - Initial probability vector
226 % T - Subgenerator matrix
227
228 % Handle trivial case: single activity
229 if length(self.activities) == 1
230 [alpha, T] = self.activities{1}.getPHRepresentation();
231 return;
232 end
233
234 % Build adjacency structures from precedences
235 [adjList, inDegree, outDegree, forkInfo, joinInfo, loopInfo] = self.analyzeStructure();
236
237 % Find start activities (no predecessors in precedence graph)
238 startActs = find(inDegree == 0);
239 if isempty(startActs)
240 % If all activities have predecessors, use first activity
241 startActs = 1;
242 end
243
244 % Find end activities (no successors in precedence graph)
245 endActs = find(outDegree == 0);
246 if isempty(endActs)
247 endActs = length(self.activities);
248 end
249
250 % Build the workflow CTMC using recursive block composition
251 [alpha, T] = self.composeWorkflow(startActs, endActs, adjList, forkInfo, joinInfo, loopInfo);
252 end
253 end
254
255 methods (Access = private)
256 function [adjList, inDegree, outDegree, forkInfo, joinInfo, loopInfo] = analyzeStructure(self)
257 % ANALYZESTRUCTURE Analyze workflow structure from precedences
258 %
259 % Build adjacency list and identify fork/join/loop structures
260
261 n = length(self.activities);
262 adjList = cell(n, 1);
263 inDegree = zeros(n, 1);
264 outDegree = zeros(n, 1);
265 forkInfo = struct('type', {}, 'preAct', {}, 'postActs', {}, 'probs', {});
266 joinInfo = struct('type', {}, 'preActs', {}, 'postAct', {}, 'quorum', {});
267 loopInfo = struct('preAct', {}, 'loopActs', {}, 'endAct', {}, 'count', {});
268
269 for p = 1:length(self.precedences)
270 prec = self.precedences(p);
271
272 % Get activity indices
273 preInds = zeros(length(prec.preActs), 1);
274 for a = 1:length(prec.preActs)
275 preInds(a) = self.getActivityIndex(prec.preActs{a});
276 end
277
278 postInds = zeros(length(prec.postActs), 1);
279 for a = 1:length(prec.postActs)
280 postInds(a) = self.getActivityIndex(prec.postActs{a});
281 end
282
283 % Update adjacency and degrees
284 for i = 1:length(preInds)
285 for j = 1:length(postInds)
286 adjList{preInds(i)} = [adjList{preInds(i)}, postInds(j)];
287 outDegree(preInds(i)) = outDegree(preInds(i)) + 1;
288 inDegree(postInds(j)) = inDegree(postInds(j)) + 1;
289 end
290 end
291
292 % Record fork/join/loop info
293 switch prec.postType
294 case ActivityPrecedenceType.POST_AND
295 % AND-Fork
296 forkInfo(end+1) = struct('type', 'and', ...
297 'preAct', preInds(1), ...
298 'postActs', postInds(:)', ...
299 'probs', []);
300
301 case ActivityPrecedenceType.POST_OR
302 % OR-Fork
303 forkInfo(end+1) = struct('type', 'or', ...
304 'preAct', preInds(1), ...
305 'postActs', postInds(:)', ...
306 'probs', prec.postParams(:)');
307
308 case ActivityPrecedenceType.POST_LOOP
309 % Loop
310 count = prec.postParams;
311 if length(postInds) >= 2
312 loopInfo(end+1) = struct('preAct', preInds(1), ...
313 'loopActs', postInds(1:end-1)', ...
314 'endAct', postInds(end), ...
315 'count', count);
316 else
317 loopInfo(end+1) = struct('preAct', preInds(1), ...
318 'loopActs', postInds(1)', ...
319 'endAct', [], ...
320 'count', count);
321 end
322 end
323
324 switch prec.preType
325 case ActivityPrecedenceType.PRE_AND
326 % AND-Join
327 quorum = prec.preParams;
328 if isempty(quorum)
329 quorum = length(preInds);
330 end
331 joinInfo(end+1) = struct('type', 'and', ...
332 'preActs', preInds(:)', ...
333 'postAct', postInds(1), ...
334 'quorum', quorum);
335
336 case ActivityPrecedenceType.PRE_OR
337 % OR-Join
338 joinInfo(end+1) = struct('type', 'or', ...
339 'preActs', preInds(:)', ...
340 'postAct', postInds(1), ...
341 'quorum', []);
342 end
343 end
344 end
345
346 function [alpha, T] = composeWorkflow(self, startActs, endActs, adjList, forkInfo, joinInfo, loopInfo)
347 % COMPOSEWORKFLOW Compose the workflow CTMC
348 %
349 % Uses a simplified approach: process precedences in order,
350 % building up the CTMC incrementally.
351
352 n = length(self.activities);
353
354 % Handle special cases
355 if n == 1
356 [alpha, T] = self.activities{1}.getPHRepresentation();
357 return;
358 end
359
360 % Determine workflow structure and compose accordingly
361 % Check for simple serial workflow (no forks/joins/loops)
362 if isempty(forkInfo) && isempty(joinInfo) && isempty(loopInfo)
363 [alpha, T] = self.composeSerialWorkflow(adjList, startActs);
364 return;
365 end
366
367 % For complex workflows, build block-by-block
368 [alpha, T] = self.composeComplexWorkflow(adjList, forkInfo, joinInfo, loopInfo);
369 end
370
371 function [alpha, T] = composeSerialWorkflow(self, adjList, startActs)
372 % COMPOSESERIALWORKFLOW Compose a purely serial workflow
373 %
374 % Activities are concatenated in topological order.
375
376 n = length(self.activities);
377
378 % Topological sort
379 order = self.topologicalSort(adjList);
380
381 % Compose in order
382 [alpha, T] = self.activities{order(1)}.getPHRepresentation();
383
384 for i = 2:length(order)
385 [alpha_i, T_i] = self.activities{order(i)}.getPHRepresentation();
386 [alpha, T] = Workflow.composeSerial(alpha, T, alpha_i, T_i);
387 end
388 end
389
390 function order = topologicalSort(self, adjList)
391 % TOPOLOGICALSORT Perform topological sort on activity graph
392
393 n = length(self.activities);
394 inDeg = zeros(n, 1);
395
396 % Calculate in-degrees
397 for i = 1:n
398 for j = adjList{i}
399 inDeg(j) = inDeg(j) + 1;
400 end
401 end
402
403 % BFS-based topological sort
404 queue = find(inDeg == 0);
405 if isempty(queue)
406 queue = 1; % Start with first activity if no clear start
407 end
408 order = [];
409
410 while ~isempty(queue)
411 curr = queue(1);
412 queue(1) = [];
413 order = [order, curr];
414
415 for next = adjList{curr}
416 inDeg(next) = inDeg(next) - 1;
417 if inDeg(next) == 0
418 queue = [queue, next];
419 end
420 end
421 end
422
423 % Add any remaining activities not in precedence graph
424 remaining = setdiff(1:n, order);
425 order = [order, remaining];
426 end
427
428 function [alpha, T] = composeComplexWorkflow(self, adjList, forkInfo, joinInfo, loopInfo)
429 % COMPOSECOMPLEXWORKFLOW Compose workflow with forks/joins/loops
430 %
431 % Strategy: Identify matched fork-join pairs and compose them
432 % as blocks, then combine blocks serially.
433
434 n = length(self.activities);
435
436 % Build a representation where each activity or block has a PH
437 blocks = cell(n, 1);
438 blockAlpha = cell(n, 1);
439 blockT = cell(n, 1);
440 isProcessed = false(n, 1);
441
442 % Initialize each activity as its own block
443 for i = 1:n
444 [blockAlpha{i}, blockT{i}] = self.activities{i}.getPHRepresentation();
445 end
446
447 % Process loops first (they modify the activity's PH)
448 for k = 1:length(loopInfo)
449 loop = loopInfo(k);
450 preIdx = loop.preAct;
451 loopActInds = loop.loopActs;
452 endIdx = loop.endAct;
453 count = loop.count;
454
455 % Compose loop body
456 if length(loopActInds) == 1
457 [alpha_loop, T_loop] = self.activities{loopActInds(1)}.getPHRepresentation();
458 else
459 % Serial composition of loop activities
460 [alpha_loop, T_loop] = self.activities{loopActInds(1)}.getPHRepresentation();
461 for j = 2:length(loopActInds)
462 [a_j, T_j] = self.activities{loopActInds(j)}.getPHRepresentation();
463 [alpha_loop, T_loop] = Workflow.composeSerial(alpha_loop, T_loop, a_j, T_j);
464 end
465 end
466
467 % Create count-fold convolution
468 [alpha_conv, T_conv] = Workflow.composeRepeat(alpha_loop, T_loop, count);
469
470 % Compose with pre-activity
471 alpha_pre = blockAlpha{preIdx};
472 T_pre = blockT{preIdx};
473 [alpha_result, T_result] = Workflow.composeSerial(alpha_pre, T_pre, alpha_conv, T_conv);
474
475 % Compose with end activity if present
476 if ~isempty(endIdx)
477 [alpha_end, T_end] = self.activities{endIdx}.getPHRepresentation();
478 [alpha_result, T_result] = Workflow.composeSerial(alpha_result, T_result, alpha_end, T_end);
479 isProcessed(endIdx) = true;
480 end
481
482 blockAlpha{preIdx} = alpha_result;
483 blockT{preIdx} = T_result;
484 isProcessed(preIdx) = true;
485 isProcessed(loopActInds) = true;
486 end
487
488 % Process AND-forks and their matching AND-joins
489 for k = 1:length(forkInfo)
490 fork = forkInfo(k);
491 if strcmp(fork.type, 'and')
492 % Find matching AND-join
493 joinIdx = self.findMatchingJoin(fork.postActs, joinInfo, 'and');
494
495 if ~isempty(joinIdx)
496 join = joinInfo(joinIdx);
497 preIdx = fork.preAct;
498 postIdx = join.postAct;
499 parallelInds = fork.postActs;
500
501 % Compose parallel activities
502 [alpha_par, T_par] = self.composeAndForkBlock(parallelInds, blockAlpha, blockT);
503
504 % Compose: pre -> parallel -> post
505 if ~isProcessed(preIdx)
506 [alpha_result, T_result] = Workflow.composeSerial(...
507 blockAlpha{preIdx}, blockT{preIdx}, alpha_par, T_par);
508 else
509 alpha_result = alpha_par;
510 T_result = T_par;
511 end
512
513 if ~isProcessed(postIdx)
514 [alpha_result, T_result] = Workflow.composeSerial(...
515 alpha_result, T_result, blockAlpha{postIdx}, blockT{postIdx});
516 end
517
518 % Store result in pre-activity's block
519 blockAlpha{preIdx} = alpha_result;
520 blockT{preIdx} = T_result;
521
522 % Mark as processed
523 isProcessed(preIdx) = true;
524 isProcessed(parallelInds) = true;
525 isProcessed(postIdx) = true;
526 end
527 end
528 end
529
530 % Process OR-forks and their matching OR-joins
531 for k = 1:length(forkInfo)
532 fork = forkInfo(k);
533 if strcmp(fork.type, 'or')
534 % Find matching OR-join
535 joinIdx = self.findMatchingJoin(fork.postActs, joinInfo, 'or');
536
537 preIdx = fork.preAct;
538 branchInds = fork.postActs;
539 probs = fork.probs;
540
541 % Compose branches as probabilistic mixture
542 [alpha_or, T_or] = self.composeOrForkBlock(branchInds, probs, blockAlpha, blockT);
543
544 % Compose: pre -> or-block
545 if ~isProcessed(preIdx)
546 [alpha_result, T_result] = Workflow.composeSerial(...
547 blockAlpha{preIdx}, blockT{preIdx}, alpha_or, T_or);
548 else
549 alpha_result = alpha_or;
550 T_result = T_or;
551 end
552
553 % If there's a join, compose with post activity
554 if ~isempty(joinIdx)
555 join = joinInfo(joinIdx);
556 postIdx = join.postAct;
557 if ~isProcessed(postIdx)
558 [alpha_result, T_result] = Workflow.composeSerial(...
559 alpha_result, T_result, blockAlpha{postIdx}, blockT{postIdx});
560 isProcessed(postIdx) = true;
561 end
562 end
563
564 blockAlpha{preIdx} = alpha_result;
565 blockT{preIdx} = T_result;
566 isProcessed(preIdx) = true;
567 isProcessed(branchInds) = true;
568 end
569 end
570
571 % Compose remaining unprocessed activities in topological order
572 order = self.topologicalSort(adjList);
573
574 % Find first unprocessed or block-start activity
575 alpha = [];
576 T = [];
577
578 for i = 1:length(order)
579 idx = order(i);
580 if ~isProcessed(idx) || (i == 1 && isempty(alpha))
581 if isempty(alpha)
582 alpha = blockAlpha{idx};
583 T = blockT{idx};
584 else
585 [alpha, T] = Workflow.composeSerial(alpha, T, blockAlpha{idx}, blockT{idx});
586 end
587 elseif isProcessed(idx) && isempty(alpha)
588 % This is a processed block, use it as starting point
589 alpha = blockAlpha{idx};
590 T = blockT{idx};
591 end
592 end
593
594 % If still empty (shouldn't happen), use first activity
595 if isempty(alpha)
596 [alpha, T] = self.activities{1}.getPHRepresentation();
597 end
598 end
599
600 function joinIdx = findMatchingJoin(self, postActs, joinInfo, joinType)
601 % FINDMATCHINGJOIN Find the join that matches a fork's post activities
602
603 joinIdx = [];
604 for j = 1:length(joinInfo)
605 join = joinInfo(j);
606 if strcmp(join.type, joinType)
607 % Check if this join's pre-activities match the fork's post-activities
608 if isequal(sort(join.preActs), sort(postActs))
609 joinIdx = j;
610 return;
611 end
612 end
613 end
614 end
615
616 function [alpha, T] = composeAndForkBlock(self, parallelInds, blockAlpha, blockT)
617 % COMPOSEANDFORKBLOCK Compose parallel activities using Kronecker sum
618
619 % Start with first parallel activity
620 alpha = blockAlpha{parallelInds(1)};
621 T = blockT{parallelInds(1)};
622
623 % Combine with remaining activities using Kronecker sum
624 for i = 2:length(parallelInds)
625 idx = parallelInds(i);
626 alpha_i = blockAlpha{idx};
627 T_i = blockT{idx};
628
629 [alpha, T] = Workflow.composeParallel(alpha, T, alpha_i, T_i);
630 end
631 end
632
633 function [alpha, T] = composeOrForkBlock(self, branchInds, probs, blockAlpha, blockT)
634 % COMPOSEORFORKBLOCK Compose branches as probabilistic mixture
635
636 % Calculate total phases
637 totalPhases = 0;
638 for i = 1:length(branchInds)
639 totalPhases = totalPhases + size(blockT{branchInds(i)}, 1);
640 end
641
642 T = zeros(totalPhases);
643 alpha = zeros(1, totalPhases);
644
645 offset = 0;
646 for i = 1:length(branchInds)
647 idx = branchInds(i);
648 alpha_i = blockAlpha{idx};
649 T_i = blockT{idx};
650 n_i = size(T_i, 1);
651
652 T(offset+1:offset+n_i, offset+1:offset+n_i) = T_i;
653 alpha(offset+1:offset+n_i) = probs(i) * alpha_i;
654
655 offset = offset + n_i;
656 end
657 end
658 end
659
660 methods (Static)
661 function [alpha_out, T_out] = composeSerial(alpha1, T1, alpha2, T2)
662 % COMPOSESERIAL Serial composition of two PH distributions
663 %
664 % The second PH starts when the first one absorbs.
665 %
666 % T_serial = [T1, -T1*e*alpha2]
667 % [0, T2 ]
668
669 n1 = size(T1, 1);
670 n2 = size(T2, 1);
671
672 e1 = ones(n1, 1);
673 absRate1 = -T1 * e1; % Absorption rate from each state
674
675 T_out = zeros(n1 + n2);
676 T_out(1:n1, 1:n1) = T1;
677 T_out(1:n1, n1+1:end) = absRate1 * alpha2;
678 T_out(n1+1:end, n1+1:end) = T2;
679
680 alpha_out = [alpha1, zeros(1, n2)];
681 end
682
683 function [alpha_out, T_out] = composeParallel(alpha1, T1, alpha2, T2)
684 % COMPOSEPARALLEL Parallel fork-join composition
685 %
686 % Models two PH distributions running in parallel with synchronization
687 % at completion (AND-join). The resulting PH represents the time until
688 % BOTH activities complete (i.e., the maximum).
689 %
690 % State space:
691 % - States (i,j) where both are active: i=1..n1, j=1..n2
692 % - States where only activity 1 is active (2 completed): i=1..n1
693 % - States where only activity 2 is active (1 completed): j=1..n2
694 %
695 % Absorption occurs only when both have completed.
696
697 n1 = size(T1, 1);
698 n2 = size(T2, 1);
699 e1 = ones(n1, 1);
700 e2 = ones(n2, 1);
701
702 % Absorption rates for each activity
703 absRate1 = -T1 * e1; % Rate of leaving each phase in activity 1
704 absRate2 = -T2 * e2; % Rate of leaving each phase in activity 2
705
706 % Total state space: n1*n2 (both active) + n1 (only 1 active) + n2 (only 2 active)
707 % States 1..n1*n2: both active, indexed as (i-1)*n2 + j for phase (i,j)
708 % States n1*n2+1..n1*n2+n1: only activity 1 active (activity 2 completed)
709 % States n1*n2+n1+1..n1*n2+n1+n2: only activity 2 active (activity 1 completed)
710
711 nBoth = n1 * n2;
712 nOnly1 = n1;
713 nOnly2 = n2;
714 nTotal = nBoth + nOnly1 + nOnly2;
715
716 T_out = zeros(nTotal);
717
718 % Transitions within "both active" states
719 % Use Kronecker sum for simultaneous evolution
720 T_both = krons(T1, T2);
721 T_out(1:nBoth, 1:nBoth) = T_both;
722
723 % Transitions from "both active" to "only 1 active" (activity 2 completes)
724 % When in state (i,j), activity 2 can complete with rate absRate2(j)
725 % This moves to state "only 1 active, phase i"
726 for i = 1:n1
727 for j = 1:n2
728 bothIdx = (i-1)*n2 + j;
729 only1Idx = nBoth + i;
730 T_out(bothIdx, only1Idx) = T_out(bothIdx, only1Idx) + absRate2(j);
731 end
732 end
733
734 % Transitions from "both active" to "only 2 active" (activity 1 completes)
735 % When in state (i,j), activity 1 can complete with rate absRate1(i)
736 % This moves to state "only 2 active, phase j"
737 for i = 1:n1
738 for j = 1:n2
739 bothIdx = (i-1)*n2 + j;
740 only2Idx = nBoth + nOnly1 + j;
741 T_out(bothIdx, only2Idx) = T_out(bothIdx, only2Idx) + absRate1(i);
742 end
743 end
744
745 % Transitions within "only 1 active" states
746 T_out(nBoth+1:nBoth+nOnly1, nBoth+1:nBoth+nOnly1) = T1;
747
748 % Transitions within "only 2 active" states
749 T_out(nBoth+nOnly1+1:end, nBoth+nOnly1+1:end) = T2;
750
751 % Absorption from "only 1 active" and "only 2 active" states happens
752 % when the remaining activity completes - this is handled by the
753 % negative row sums (implicit absorption)
754
755 % Initial probability: start in "both active" states
756 % with probability alpha1(i) * alpha2(j) for state (i,j)
757 alpha_out = zeros(1, nTotal);
758 for i = 1:n1
759 for j = 1:n2
760 bothIdx = (i-1)*n2 + j;
761 alpha_out(bothIdx) = alpha1(i) * alpha2(j);
762 end
763 end
764 end
765
766 function [alpha_out, T_out] = composeRepeat(alpha, T, count)
767 % COMPOSEREPEAT Repeat a PH distribution count times (convolution)
768 %
769 % Equivalent to serial composition of the same PH count times.
770
771 if count <= 0
772 alpha_out = 1;
773 T_out = -1e10; % Immediate
774 return;
775 end
776
777 if count == 1
778 alpha_out = alpha;
779 T_out = T;
780 return;
781 end
782
783 % Use map_sum for efficient convolution if available
784 % Otherwise, compose serially
785 try
786 % Convert to MAP format
787 n = size(T, 1);
788 e = ones(n, 1);
789 D0 = T;
790 D1 = -T * e * alpha;
791 MAP = {D0, D1};
792
793 % Use map_sum for count-fold convolution
794 MAP_conv = map_sum(MAP, count);
795
796 T_out = MAP_conv{1};
797 D1_conv = MAP_conv{2};
798
799 % Extract alpha from D1
800 n_out = size(T_out, 1);
801 e_out = ones(n_out, 1);
802 absRate = -T_out * e_out;
803 idx = find(absRate > GlobalConstants.FineTol, 1);
804 if ~isempty(idx)
805 alpha_out = D1_conv(idx, :) / absRate(idx);
806 else
807 alpha_out = zeros(1, n_out);
808 alpha_out(1) = 1;
809 end
810 catch
811 % Fallback: serial composition
812 alpha_out = alpha;
813 T_out = T;
814 for i = 2:count
815 [alpha_out, T_out] = Workflow.composeSerial(alpha_out, T_out, alpha, T);
816 end
817 end
818 end
819
820 % Static precedence factory methods (delegate to ActivityPrecedence)
821 function ap = Serial(varargin)
822 % SERIAL Create serial precedence
823 %
824 % AP = WORKFLOW.SERIAL(A1, A2, ...)
825
826 % Convert WorkflowActivity to names
827 args = cell(1, nargin);
828 for i = 1:nargin
829 if isa(varargin{i}, 'WorkflowActivity')
830 args{i} = varargin{i}.name;
831 else
832 args{i} = varargin{i};
833 end
834 end
835 ap = ActivityPrecedence.Serial(args{:});
836 end
837
838 function ap = AndFork(preAct, postActs)
839 % ANDFORK Create AND-fork precedence
840 %
841 % AP = WORKFLOW.ANDFORK(PREACT, {POSTACT1, POSTACT2, ...})
842
843 if isa(preAct, 'WorkflowActivity')
844 preAct = preAct.name;
845 end
846 for a = 1:length(postActs)
847 if isa(postActs{a}, 'WorkflowActivity')
848 postActs{a} = postActs{a}.name;
849 end
850 end
851 ap = ActivityPrecedence.AndFork(preAct, postActs);
852 end
853
854 function ap = AndJoin(preActs, postAct, quorum)
855 % ANDJOIN Create AND-join precedence
856 %
857 % AP = WORKFLOW.ANDJOIN({PREACT1, PREACT2, ...}, POSTACT)
858 % AP = WORKFLOW.ANDJOIN({PREACT1, PREACT2, ...}, POSTACT, QUORUM)
859
860 for a = 1:length(preActs)
861 if isa(preActs{a}, 'WorkflowActivity')
862 preActs{a} = preActs{a}.name;
863 end
864 end
865 if isa(postAct, 'WorkflowActivity')
866 postAct = postAct.name;
867 end
868 if nargin < 3
869 quorum = [];
870 end
871 ap = ActivityPrecedence.AndJoin(preActs, postAct, quorum);
872 end
873
874 function ap = OrFork(preAct, postActs, probs)
875 % ORFORK Create OR-fork (probabilistic) precedence
876 %
877 % AP = WORKFLOW.ORFORK(PREACT, {POSTACT1, POSTACT2, ...}, [P1, P2, ...])
878
879 if isa(preAct, 'WorkflowActivity')
880 preAct = preAct.name;
881 end
882 for a = 1:length(postActs)
883 if isa(postActs{a}, 'WorkflowActivity')
884 postActs{a} = postActs{a}.name;
885 end
886 end
887 ap = ActivityPrecedence.OrFork(preAct, postActs, probs);
888 end
889
890 function ap = Xor(preAct, postActs, probs)
891 % XOR Create XOR precedence (alias for OrFork)
892 %
893 % AP = WORKFLOW.XOR(PREACT, {POSTACT1, POSTACT2, ...}, [P1, P2, ...])
894
895 ap = Workflow.OrFork(preAct, postActs, probs);
896 end
897
898 function ap = OrJoin(preActs, postAct)
899 % ORJOIN Create OR-join precedence
900 %
901 % AP = WORKFLOW.ORJOIN({PREACT1, PREACT2, ...}, POSTACT)
902
903 for a = 1:length(preActs)
904 if isa(preActs{a}, 'WorkflowActivity')
905 preActs{a} = preActs{a}.name;
906 end
907 end
908 if isa(postAct, 'WorkflowActivity')
909 postAct = postAct.name;
910 end
911 ap = ActivityPrecedence.OrJoin(preActs, postAct);
912 end
913
914 function ap = Loop(preAct, postActs, counts)
915 % LOOP Create loop precedence
916 %
917 % AP = WORKFLOW.LOOP(PREACT, {LOOPACT, ENDACT}, COUNT)
918 % AP = WORKFLOW.LOOP(PREACT, LOOPACT, ENDACT, COUNT)
919
920 if isa(preAct, 'WorkflowActivity')
921 preAct = preAct.name;
922 end
923 if iscell(postActs)
924 for a = 1:length(postActs)
925 if isa(postActs{a}, 'WorkflowActivity')
926 postActs{a} = postActs{a}.name;
927 end
928 end
929 elseif isa(postActs, 'WorkflowActivity')
930 postActs = postActs.name;
931 end
932 ap = ActivityPrecedence.Loop(preAct, postActs, counts);
933 end
934
935 function wf = fromWfCommons(jsonFile, options)
936 % FROMWFCOMMONS Load a workflow from a WfCommons JSON file.
937 %
938 % WF = WORKFLOW.FROMWFCOMMONS(JSONFILE)
939 % WF = WORKFLOW.FROMWFCOMMONS(JSONFILE, OPTIONS)
940 %
941 % Loads a workflow trace from the WfCommons format
942 % (https://github.com/wfcommons/workflow-schema) into a
943 % LINE Workflow object for queueing analysis.
944 %
945 % Parameters:
946 % jsonFile - Path to WfCommons JSON file
947 % options - Optional struct with:
948 % .distributionType - 'exp' (default), 'det', 'aph', 'hyperexp'
949 % .defaultSCV - Default SCV for APH/HyperExp (default: 1.0)
950 % .defaultRuntime - Default runtime when missing (default: 1.0)
951 % .useExecutionData - Use execution data if available (default: true)
952 % .storeMetadata - Store WfCommons metadata (default: true)
953 %
954 % Returns:
955 % wf - Workflow object
956 %
957 % Example:
958 % wf = Workflow.fromWfCommons('montage-workflow.json');
959 % ph = wf.toPH();
960 % fprintf('Mean execution time: %.2f\n', ph.getMean());
961
962 if nargin < 2
963 options = struct();
964 end
965 wf = WfCommonsLoader.load(jsonFile, options);
966 end
967 end
968end