openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,223 @@
1
+ """PNML: the ISO/IEC 15909-2 interchange format for Petri nets.
2
+
3
+ PNML is what ProM, PM4Py, WoPeD and most academic tools read and write, so
4
+ it is the way to move a discovered model between this app and anything else.
5
+
6
+ The subset we handle (the "P/T net" type)::
7
+
8
+ <pnml>
9
+ <net id="net1" type="http://www.pnml.org/version-2009/grammar/ptnet">
10
+ <name><text>My net</text></name>
11
+ <page id="page1">
12
+ <place id="p1">
13
+ <name><text>start</text></name>
14
+ <initialMarking><text>1</text></initialMarking>
15
+ <graphics><position x="10" y="20"/></graphics>
16
+ </place>
17
+ <transition id="t1">
18
+ <name><text>register</text></name>
19
+ <toolspecific tool="ProM" version="6.4" activity="$invisible$"/>
20
+ </transition>
21
+ <arc id="a1" source="p1" target="t1">
22
+ <inscription><text>1</text></inscription>
23
+ </arc>
24
+ </page>
25
+ <finalmarkings> <- ProM extension
26
+ <marking><place idref="p9"><text>1</text></place></marking>
27
+ </finalmarkings>
28
+ </net>
29
+ </pnml>
30
+
31
+ Silent transitions are marked with ProM's ``toolspecific`` element
32
+ ``activity="$invisible$"``; that is the convention PM4Py follows too.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from pathlib import Path
38
+ from xml.etree import ElementTree as ET
39
+ from xml.sax.saxutils import escape, quoteattr
40
+
41
+ from .petrinet import Marking, PetriNet
42
+
43
+ _INVISIBLE = "$invisible$"
44
+
45
+
46
+ def _local(tag: str) -> str:
47
+ return tag.rsplit("}", 1)[-1]
48
+
49
+
50
+ def _child(element: ET.Element, name: str) -> ET.Element | None:
51
+ for child in element:
52
+ if _local(child.tag) == name:
53
+ return child
54
+ return None
55
+
56
+
57
+ def _text_of(element: ET.Element | None) -> str | None:
58
+ """The ``<text>`` content of a PNML label such as ``<name>``."""
59
+ if element is None:
60
+ return None
61
+ text = _child(element, "text")
62
+ return text.text.strip() if text is not None and text.text else None
63
+
64
+
65
+ def parse_pnml(text: str | bytes) -> PetriNet:
66
+ root = ET.fromstring(text)
67
+ net_element = root if _local(root.tag) == "net" else next(
68
+ (e for e in root.iter() if _local(e.tag) == "net"), None)
69
+ if net_element is None:
70
+ raise ValueError("No <net> element found; is this a PNML file?")
71
+
72
+ net = PetriNet(_text_of(_child(net_element, "name")) or net_element.get("id") or "Petri net")
73
+ initial: dict[str, int] = {}
74
+ arcs: list[tuple[str, str, int]] = []
75
+
76
+ # Nodes may sit inside (nested) <page> elements or directly under <net>.
77
+ for element in net_element.iter():
78
+ tag = _local(element.tag)
79
+ # <place idref=...> inside <finalmarkings> is a reference, not a node:
80
+ # only elements with an id define places, transitions and arcs.
81
+ if element.get("id") is None:
82
+ continue
83
+ if tag == "place":
84
+ place = net.add_place(_text_of(_child(element, "name")) or element.get("id"),
85
+ id=element.get("id"))
86
+ place.position = _position(element)
87
+ place.name_offset = _name_offset(element)
88
+ tokens = _text_of(_child(element, "initialMarking"))
89
+ if tokens and tokens.isdigit() and int(tokens) > 0:
90
+ initial[place.id] = int(tokens)
91
+ elif tag == "transition":
92
+ name = _text_of(_child(element, "name")) or element.get("id")
93
+ invisible = any(
94
+ _local(sub.tag) == "toolspecific" and sub.get("activity") == _INVISIBLE
95
+ for sub in element)
96
+ transition = net.add_transition(None if invisible else name, name=name,
97
+ id=element.get("id"))
98
+ transition.position = _position(element)
99
+ transition.name_offset = _name_offset(element)
100
+ elif tag == "arc":
101
+ weight_text = _text_of(_child(element, "inscription"))
102
+ weight = int(weight_text) if weight_text and weight_text.isdigit() else 1
103
+ graphics = _child(element, "graphics")
104
+ points = []
105
+ if graphics is not None:
106
+ for sub in graphics:
107
+ if _local(sub.tag) == "position":
108
+ points.append((float(sub.get("x", 0)), float(sub.get("y", 0))))
109
+ arcs.append((element.get("source"), element.get("target"), weight, points))
110
+
111
+ for source, target, weight, points in arcs:
112
+ net.add_arc(source, target, weight).points = points
113
+ for element in net_element:
114
+ if _local(element.tag) == "toolspecific" and element.get("tool") == "OpenProcess" \
115
+ and element.get("namesOutside") == "true":
116
+ net.info["names_outside"] = "true"
117
+ net.initial_marking = Marking(initial)
118
+
119
+ final: dict[str, int] = {}
120
+ finals = _child(net_element, "finalmarkings")
121
+ if finals is not None:
122
+ marking = _child(finals, "marking")
123
+ if marking is not None:
124
+ for place in marking:
125
+ tokens = _text_of(place)
126
+ if tokens and tokens.isdigit() and int(tokens) > 0:
127
+ final[place.get("idref")] = int(tokens)
128
+ if final:
129
+ net.final_marking = Marking(final)
130
+ else:
131
+ # No final marking stored: for a workflow net the obvious choice is
132
+ # one token in the sink place.
133
+ sinks = net.sink_places()
134
+ if len(sinks) == 1:
135
+ net.final_marking = Marking({sinks[0]: 1})
136
+ return net
137
+
138
+
139
+ def _position(element: ET.Element) -> tuple[float, float] | None:
140
+ graphics = _child(element, "graphics")
141
+ position = _child(graphics, "position") if graphics is not None else None
142
+ if position is None:
143
+ return None
144
+ try:
145
+ return float(position.get("x", 0)), float(position.get("y", 0))
146
+ except ValueError:
147
+ return None
148
+
149
+
150
+ def _name_offset(element: ET.Element) -> tuple[float, float] | None:
151
+ """``<name><graphics><offset x y/></graphics></name>``: where the label is."""
152
+ name = _child(element, "name")
153
+ graphics = _child(name, "graphics") if name is not None else None
154
+ offset = _child(graphics, "offset") if graphics is not None else None
155
+ if offset is None:
156
+ return None
157
+ try:
158
+ return float(offset.get("x", 0)), float(offset.get("y", 0))
159
+ except ValueError:
160
+ return None
161
+
162
+
163
+ def read_pnml(path: str | Path) -> PetriNet:
164
+ net = parse_pnml(Path(path).read_bytes())
165
+ net.info.setdefault("source", str(path))
166
+ return net
167
+
168
+
169
+ def pnml_string(net: PetriNet, positions: dict[str, tuple[float, float]] | None = None) -> str:
170
+ """Serialise ``net``. ``positions`` (e.g. from the layout) overrides stored ones."""
171
+ positions = positions or {}
172
+
173
+ def graphics(node_id: str, stored) -> str:
174
+ point = positions.get(node_id, stored)
175
+ if point is None:
176
+ return ""
177
+ return (f'<graphics><position x="{point[0]:.1f}" y="{point[1]:.1f}"/>'
178
+ f'<dimension x="40" y="40"/></graphics>')
179
+
180
+ def name(node) -> str:
181
+ """The node's name, with the label position when one was set."""
182
+ offset = getattr(node, "name_offset", None)
183
+ where = (f'<graphics><offset x="{offset[0]:.1f}" y="{offset[1]:.1f}"/></graphics>'
184
+ if offset is not None else "")
185
+ return f"<name><text>{escape(node.name)}</text>{where}</name>"
186
+
187
+ lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<pnml>",
188
+ f' <net id="net1" type="http://www.pnml.org/version-2009/grammar/pnmlcoremodel">',
189
+ f" <name><text>{escape(net.name)}</text></name>"]
190
+ if net.info.get("names_outside") == "true":
191
+ # OpenProcess's drawing option: names next to the nodes, not inside.
192
+ lines.append(' <toolspecific tool="OpenProcess" version="1" namesOutside="true"/>')
193
+ lines.append(' <page id="page1">')
194
+ for place in net.places.values():
195
+ tokens = net.initial_marking[place.id]
196
+ marking = (f"<initialMarking><text>{tokens}</text></initialMarking>" if tokens else "")
197
+ lines.append(f" <place id={quoteattr(place.id)}>{name(place)}"
198
+ f"{marking}{graphics(place.id, place.position)}</place>")
199
+ for transition in net.transitions.values():
200
+ tool = ('<toolspecific tool="ProM" version="6.4" activity="$invisible$" '
201
+ 'localNodeID="" />' if transition.silent else "")
202
+ lines.append(f" <transition id={quoteattr(transition.id)}>{name(transition)}{tool}"
203
+ f"{graphics(transition.id, transition.position)}</transition>")
204
+ for index, arc in enumerate(net.arcs, 1):
205
+ inscription = (f"<inscription><text>{arc.weight}</text></inscription>"
206
+ if arc.weight != 1 else "")
207
+ bends = "".join(f'<position x="{x:.1f}" y="{y:.1f}"/>' for x, y in arc.points)
208
+ bends = f"<graphics>{bends}</graphics>" if bends else ""
209
+ lines.append(f" <arc id=\"arc{index}\" source={quoteattr(arc.source)} "
210
+ f"target={quoteattr(arc.target)}>{inscription}{bends}</arc>")
211
+ lines.append(" </page>")
212
+ if net.final_marking:
213
+ lines.append(" <finalmarkings><marking>")
214
+ for place_id, tokens in net.final_marking.items():
215
+ lines.append(f" <place idref={quoteattr(place_id)}><text>{tokens}</text></place>")
216
+ lines.append(" </marking></finalmarkings>")
217
+ lines += [" </net>", "</pnml>", ""]
218
+ return "\n".join(lines)
219
+
220
+
221
+ def write_pnml(net: PetriNet, path: str | Path,
222
+ positions: dict[str, tuple[float, float]] | None = None) -> None:
223
+ Path(path).write_text(pnml_string(net, positions), encoding="utf-8")
@@ -0,0 +1,216 @@
1
+ """Process trees: block-structured models that are sound by construction.
2
+
3
+ A process tree (Leemans, Fahland & van der Aalst) is a tree whose leaves are
4
+ activities or τ (silent), and whose inner nodes are operators:
5
+
6
+ ========= ====== =====================================================
7
+ operator symbol meaning
8
+ ========= ====== =====================================================
9
+ sequence → execute the children in order, left to right
10
+ choice × execute exactly one child (exclusive choice, XOR)
11
+ parallel ∧ execute all children, interleaved in any order (AND)
12
+ loop ↺ execute the first child ("do"); then, any number of
13
+ times, a redo child followed by the do child again
14
+ ========= ====== =====================================================
15
+
16
+ Example: ``→(a, ×(b, c), ↺(d, τ))`` means "a, then b or c, then d one or
17
+ more times".
18
+
19
+ Why the Inductive Miner produces trees rather than nets directly: every tree
20
+ translates into a *sound* workflow net, so the IM can never return a model
21
+ that deadlocks or leaves tokens behind -- unlike the α-algorithm.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+
29
+ from .petrinet import Marking, PetriNet
30
+
31
+
32
+ class Operator(Enum):
33
+ SEQUENCE = "→"
34
+ XOR = "×"
35
+ PARALLEL = "∧"
36
+ LOOP = "↺"
37
+
38
+
39
+ @dataclass(eq=False)
40
+ class ProcessTree:
41
+ """A node: an operator with children, or a leaf (``label`` or τ)."""
42
+
43
+ operator: Operator | None = None
44
+ children: list["ProcessTree"] = field(default_factory=list)
45
+ label: str | None = None # leaves only; None = τ
46
+
47
+ # -- constructors -----------------------------------------------------
48
+ @classmethod
49
+ def leaf(cls, label: str | None) -> "ProcessTree":
50
+ return cls(label=label)
51
+
52
+ @classmethod
53
+ def tau(cls) -> "ProcessTree":
54
+ return cls(label=None)
55
+
56
+ @classmethod
57
+ def node(cls, operator: Operator, children: list["ProcessTree"]) -> "ProcessTree":
58
+ return cls(operator=operator, children=list(children))
59
+
60
+ @property
61
+ def is_leaf(self) -> bool:
62
+ return self.operator is None
63
+
64
+ @property
65
+ def is_tau(self) -> bool:
66
+ return self.is_leaf and self.label is None
67
+
68
+ # -- display ----------------------------------------------------------
69
+ def __str__(self) -> str:
70
+ if self.is_leaf:
71
+ return "τ" if self.label is None else self.label
72
+ return f"{self.operator.value}(" + ", ".join(map(str, self.children)) + ")"
73
+
74
+ __repr__ = __str__
75
+
76
+ def pretty(self, indent: str = "") -> str:
77
+ """Multi-line indented rendering."""
78
+ if self.is_leaf:
79
+ return indent + str(self)
80
+ lines = [indent + self.operator.value]
81
+ lines += [child.pretty(indent + " ") for child in self.children]
82
+ return "\n".join(lines)
83
+
84
+ def activities(self) -> set[str]:
85
+ if self.is_leaf:
86
+ return set() if self.label is None else {self.label}
87
+ return set().union(*(child.activities() for child in self.children))
88
+
89
+ def simplified(self) -> "ProcessTree":
90
+ """Flatten nested identical operators: →(a, →(b, c)) becomes →(a, b, c).
91
+
92
+ Loops are not flattened -- ↺ is not associative.
93
+ """
94
+ if self.is_leaf:
95
+ return self
96
+ children = [child.simplified() for child in self.children]
97
+ flat: list[ProcessTree] = []
98
+ for child in children:
99
+ if (self.operator is not Operator.LOOP and child.operator is self.operator):
100
+ flat.extend(child.children)
101
+ else:
102
+ flat.append(child)
103
+ if len(flat) == 1 and self.operator is not Operator.LOOP:
104
+ return flat[0]
105
+ return ProcessTree.node(self.operator, flat)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Translation to a workflow net
110
+ # ---------------------------------------------------------------------------
111
+ def to_petri_net(tree: ProcessTree, name: str = "Process tree") -> PetriNet:
112
+ """Translate a tree into a sound WF-net, block by block.
113
+
114
+ Every block is built between an *entry* place and an *exit* place:
115
+
116
+ * leaf ``a`` / τ: entry → [a] → exit
117
+ * ``→(C1..Cn)``: chain the children through fresh intermediate places
118
+ * ``×(C1..Cn)``: every child between the same entry and exit
119
+ * ``∧(C1..Cn)``: a τ *split* puts a token in each child's own entry
120
+ place; a τ *join* waits for all children's exit places
121
+ * ``↺(do, r1..rn)``: τ from entry to a loop place ``s``; ``do`` from s to
122
+ ``e``; each redo from ``e`` back to ``s``; τ from ``e`` to exit. The
123
+ extra τs keep the loop's places private, so a redo can never re-enter
124
+ a choice made *outside* the loop.
125
+
126
+ Afterwards, τ transitions that merely connect two private places in
127
+ series are removed (Murata's "fusion of series places"), which keeps the
128
+ picture readable without changing behaviour.
129
+ """
130
+ net = PetriNet(name)
131
+ source = net.add_place("source", id="source")
132
+ sink = net.add_place("sink", id="sink")
133
+
134
+ def build(node: ProcessTree, entry, exit_) -> None:
135
+ if node.is_leaf:
136
+ transition = net.add_transition(node.label)
137
+ net.add_arc(entry, transition)
138
+ net.add_arc(transition, exit_)
139
+ elif node.operator is Operator.SEQUENCE:
140
+ current = entry
141
+ for index, child in enumerate(node.children):
142
+ following = exit_ if index == len(node.children) - 1 else net.add_place()
143
+ build(child, current, following)
144
+ current = following
145
+ elif node.operator is Operator.XOR:
146
+ for child in node.children:
147
+ build(child, entry, exit_)
148
+ elif node.operator is Operator.PARALLEL:
149
+ split = net.add_transition(None, name="τ split")
150
+ join = net.add_transition(None, name="τ join")
151
+ net.add_arc(entry, split)
152
+ net.add_arc(join, exit_)
153
+ for child in node.children:
154
+ child_entry, child_exit = net.add_place(), net.add_place()
155
+ net.add_arc(split, child_entry)
156
+ net.add_arc(child_exit, join)
157
+ build(child, child_entry, child_exit)
158
+ elif node.operator is Operator.LOOP:
159
+ loop_start, loop_end = net.add_place(), net.add_place()
160
+ enter = net.add_transition(None, name="τ enter")
161
+ leave = net.add_transition(None, name="τ exit")
162
+ net.add_arc(entry, enter)
163
+ net.add_arc(enter, loop_start)
164
+ net.add_arc(loop_end, leave)
165
+ net.add_arc(leave, exit_)
166
+ build(node.children[0], loop_start, loop_end)
167
+ for redo in node.children[1:]:
168
+ build(redo, loop_end, loop_start)
169
+
170
+ build(tree, source, sink)
171
+ net.initial_marking = Marking({source.id: 1})
172
+ net.final_marking = Marking({sink.id: 1})
173
+ reduce_silent_transitions(net)
174
+ net.info["algorithm"] = "Process tree"
175
+ return net
176
+
177
+
178
+ def reduce_silent_transitions(net: PetriNet) -> None:
179
+ """Remove τ transitions that only connect two places in series.
180
+
181
+ Rule (fusion of series places): if τ has exactly one input place ``p`` and
182
+ one output place ``q``, ``p``'s only consumer is τ, and ``q``'s only
183
+ producer is τ, then firing τ is the *only* thing a token in p can do and
184
+ the *only* way into q -- so p and q can be merged and τ deleted without
185
+ changing the visible behaviour. The source and sink are never merged
186
+ away, so the result is still a WF-net with the same markings.
187
+ """
188
+ changed = True
189
+ while changed:
190
+ changed = False
191
+ for transition in list(net.transitions.values()):
192
+ if not transition.silent:
193
+ continue
194
+ inputs, outputs = net.preset(transition.id), net.postset(transition.id)
195
+ if len(inputs) != 1 or len(outputs) != 1:
196
+ continue
197
+ (p,), (q,) = inputs, outputs
198
+ if p == q or net.postset(p) != {transition.id} or net.preset(q) != {transition.id}:
199
+ continue
200
+ if any(a.weight != 1 for a in net.arcs
201
+ if transition.id in (a.source, a.target)):
202
+ continue
203
+ # Keep source/sink ids stable: merge the other place into them.
204
+ keep, drop = (q, p) if p not in ("source", "sink") else (p, q)
205
+ if drop in ("source", "sink"):
206
+ continue
207
+ net.remove_transition(transition.id)
208
+ for arc in net.arcs:
209
+ if arc.source == drop:
210
+ arc.source = keep
211
+ if arc.target == drop:
212
+ arc.target = keep
213
+ net.places.pop(drop)
214
+ net._cache = None
215
+ changed = True
216
+ break