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,279 @@
1
+ """Automatic left-to-right layout of process graphs (Sugiyama's method).
2
+
3
+ Discovered models have no coordinates, so the app must place every node
4
+ itself. ProM and PM4Py hand this to Graphviz; we do it in pure Python so the
5
+ app needs no external binary. The method is the classic *layered* drawing
6
+ of Sugiyama, Tagawa & Toda (1981), which is also what Graphviz ``dot`` uses:
7
+
8
+ 1. **Break cycles.** Layering needs a DAG, so a depth-first search reverses
9
+ every back edge (loops in the process). Reversed edges are drawn in
10
+ their original direction afterwards.
11
+ 2. **Assign layers** (columns). Longest-path layering from the sources, so
12
+ the start of the process is on the left.
13
+ 3. **Insert dummy nodes** on edges spanning several layers, so every edge
14
+ connects adjacent layers and can be routed around other nodes.
15
+ 4. **Reduce crossings.** Reorder nodes within each layer by the barycentre
16
+ (average position) of their neighbours, sweeping left-to-right and back;
17
+ keep the best ordering seen.
18
+ 5. **Assign coordinates.** x from the layer, y by repeatedly pulling each
19
+ node towards the median of its neighbours while keeping a minimum gap.
20
+
21
+ The result gives a centre point per node and a polyline per edge (through
22
+ its dummy nodes), which the canvas turns into smooth curves.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from collections import defaultdict
28
+ from dataclasses import dataclass, field
29
+
30
+
31
+ @dataclass
32
+ class LayoutResult:
33
+ positions: dict[str, tuple[float, float]] # node -> centre
34
+ routes: dict[int, list[tuple[float, float]]] = field(default_factory=dict) # edge index -> points
35
+ width: float = 0.0
36
+ height: float = 0.0
37
+
38
+
39
+ def layered_layout(nodes: dict[str, tuple[float, float]], edges: list[tuple[str, str]],
40
+ layer_gap: float = 70.0, node_gap: float = 28.0,
41
+ sweeps: int = 16, soft_edges: frozenset[int] | set[int] = frozenset()
42
+ ) -> LayoutResult:
43
+ """Lay out ``nodes`` (id -> (width, height)) connected by ``edges``.
44
+
45
+ Self-loops are ignored for layering (the canvas draws them as small
46
+ arcs). Returns centre positions and a route per edge index.
47
+
48
+ ``soft_edges`` (indices into ``edges``) do not decide the layers. A node
49
+ reached *only* through soft edges -- a resource place that a transition
50
+ reads and writes back, say -- is put in the layer of its neighbours,
51
+ right next to them, instead of dragging the whole drawing out of shape.
52
+ """
53
+ ids = list(nodes)
54
+ if not ids:
55
+ return LayoutResult({})
56
+ hard = [(a, b) for index, (a, b) in enumerate(edges) if index not in soft_edges]
57
+
58
+ # -- 1. break cycles ----------------------------------------------------
59
+ successors: dict[str, list[str]] = defaultdict(list)
60
+ for a, b in hard:
61
+ if a != b:
62
+ successors[a].append(b)
63
+ order = {node: i for i, node in enumerate(ids)}
64
+ # Start the DFS from nodes without predecessors (process starts) so that
65
+ # the natural direction of the process is kept and loops get reversed.
66
+ has_pred = {b for a, b in hard if a != b}
67
+ roots = [n for n in ids if n not in has_pred] + [n for n in ids if n in has_pred]
68
+ state: dict[str, int] = {} # 1 = on stack, 2 = done
69
+ back_edges: set[tuple[str, str]] = set()
70
+ for root in roots:
71
+ if root in state:
72
+ continue
73
+ stack = [(root, iter(sorted(successors[root], key=order.get)))]
74
+ state[root] = 1
75
+ while stack:
76
+ node, children = stack[-1]
77
+ for child in children:
78
+ if state.get(child) == 1:
79
+ back_edges.add((node, child))
80
+ elif child not in state:
81
+ state[child] = 1
82
+ stack.append((child, iter(sorted(successors[child], key=order.get))))
83
+ break
84
+ else:
85
+ state[node] = 2
86
+ stack.pop()
87
+
88
+ dag: list[tuple[str, str]] = []
89
+ for a, b in hard:
90
+ if a == b:
91
+ continue
92
+ dag.append((b, a) if (a, b) in back_edges else (a, b))
93
+
94
+ # -- 2. layers: longest path from sources ------------------------------
95
+ preds: dict[str, set[str]] = defaultdict(set)
96
+ succs: dict[str, set[str]] = defaultdict(set)
97
+ for a, b in dag:
98
+ preds[b].add(a)
99
+ succs[a].add(b)
100
+ layer: dict[str, int] = {}
101
+ pending = {n: len(preds[n]) for n in ids}
102
+ ready = [n for n in ids if pending[n] == 0]
103
+ while ready:
104
+ node = ready.pop(0)
105
+ layer[node] = max((layer[p] + 1 for p in preds[node]), default=0)
106
+ for child in sorted(succs[node], key=order.get):
107
+ pending[child] -= 1
108
+ if pending[child] == 0:
109
+ ready.append(child)
110
+ for n in ids: # safety net; cannot happen after cycle breaking
111
+ layer.setdefault(n, 0)
112
+
113
+ # Nodes attached by soft edges only join the layer of their neighbours.
114
+ in_hard = {n for edge in hard for n in edge if edge[0] != edge[1]}
115
+ free = [n for n in ids if n not in in_hard]
116
+ soft_neighbours: dict[str, list[str]] = defaultdict(list)
117
+ for index in soft_edges:
118
+ a, b = edges[index]
119
+ soft_neighbours[a].append(b)
120
+ soft_neighbours[b].append(a)
121
+ placed = set(in_hard)
122
+ for _ in range(3): # a few rounds settle chains of free nodes
123
+ for n in free:
124
+ known = sorted(layer[x] for x in soft_neighbours[n] if x in placed)
125
+ if known:
126
+ layer[n] = known[len(known) // 2]
127
+ placed.add(n)
128
+ # Soft edges between nodes in different layers are drawn like any other;
129
+ # within one layer they are "side" edges: no dummies, but they still pull
130
+ # the two nodes next to each other.
131
+ back_edges |= {(a, b) for index, (a, b) in enumerate(edges)
132
+ if index in soft_edges and layer[a] > layer[b]}
133
+ side: dict[str, list[str]] = defaultdict(list)
134
+ same_layer: set[int] = set()
135
+ for index, (a, b) in enumerate(edges):
136
+ if a != b and layer[a] == layer[b]:
137
+ same_layer.add(index)
138
+ side[a].append(b)
139
+ side[b].append(a)
140
+
141
+ # -- 3. dummy nodes -------------------------------------------------------
142
+ chain_of: dict[int, list[str]] = {}
143
+ adjacency: list[tuple[str, str]] = []
144
+ dummy_count = 0
145
+ all_nodes = list(ids)
146
+ for index, (a, b) in enumerate(edges):
147
+ if a == b or index in same_layer:
148
+ continue
149
+ u, v = (b, a) if (a, b) in back_edges else (a, b)
150
+ chain = [u]
151
+ for level in range(layer[u] + 1, layer[v]):
152
+ dummy = f"\x00d{dummy_count}"
153
+ dummy_count += 1
154
+ layer[dummy] = level
155
+ all_nodes.append(dummy)
156
+ chain.append(dummy)
157
+ chain.append(v)
158
+ for x, y in zip(chain, chain[1:]):
159
+ adjacency.append((x, y))
160
+ chain_of[index] = chain if (a, b) not in back_edges else chain[::-1]
161
+
162
+ layers: list[list[str]] = [[] for _ in range(max(layer.values()) + 1)]
163
+ for node in all_nodes:
164
+ layers[layer[node]].append(node)
165
+
166
+ up: dict[str, list[str]] = defaultdict(list)
167
+ down: dict[str, list[str]] = defaultdict(list)
168
+ for x, y in adjacency:
169
+ down[x].append(y)
170
+ up[y].append(x)
171
+
172
+ # -- 4. crossing reduction --------------------------------------------
173
+ def position_map(current: list[list[str]]) -> dict[str, int]:
174
+ return {n: i for level in current for i, n in enumerate(level)}
175
+
176
+ def crossings(current: list[list[str]]) -> int:
177
+ pos = position_map(current)
178
+ total = 0
179
+ for level in current[:-1]:
180
+ pairs = sorted((pos[x], pos[y]) for x in level for y in down[x])
181
+ # count inversions of the second coordinate (O(n^2) is fine here)
182
+ for i in range(len(pairs)):
183
+ for j in range(i + 1, len(pairs)):
184
+ if pairs[i][0] < pairs[j][0] and pairs[i][1] > pairs[j][1]:
185
+ total += 1
186
+ return total
187
+
188
+ best = [list(level) for level in layers]
189
+ best_score = crossings(best)
190
+ current = [list(level) for level in layers]
191
+ for sweep in range(sweeps):
192
+ pos = position_map(current)
193
+ rng = range(1, len(current)) if sweep % 2 == 0 else range(len(current) - 2, -1, -1)
194
+ for i in rng:
195
+ neighbours = up if sweep % 2 == 0 else down
196
+ def barycentre(node: str, i=i) -> float:
197
+ linked = [pos[x] for x in neighbours[node]]
198
+ # Same-layer partners count too, so a node sits beside them.
199
+ linked += [pos[x] + 0.5 for x in side[node]]
200
+ if not linked:
201
+ return pos[node]
202
+ return sum(linked) / len(linked)
203
+ current[i].sort(key=barycentre)
204
+ for k, node in enumerate(current[i]):
205
+ pos[node] = k
206
+ score = crossings(current)
207
+ if score < best_score:
208
+ best, best_score = [list(level) for level in current], score
209
+ if score == 0:
210
+ break
211
+ layers = best
212
+
213
+ # -- 5. coordinates -----------------------------------------------------
214
+ size = {n: nodes.get(n, (0.0, 0.0)) for n in all_nodes}
215
+ widths = [max((size[n][0] for n in level), default=0) for level in layers]
216
+ xs: list[float] = []
217
+ cursor = 0.0
218
+ for w in widths:
219
+ xs.append(cursor + w / 2)
220
+ cursor += w + layer_gap
221
+
222
+ def gap_between(a: str, b: str) -> float:
223
+ dummy_a, dummy_b = a.startswith("\x00"), b.startswith("\x00")
224
+ pad = node_gap * (0.45 if dummy_a and dummy_b else 0.7 if (dummy_a or dummy_b) else 1)
225
+ return size[a][1] / 2 + size[b][1] / 2 + pad
226
+
227
+ y: dict[str, float] = {}
228
+ for level in layers:
229
+ cursor = 0.0
230
+ for k, node in enumerate(level):
231
+ if k:
232
+ cursor += gap_between(level[k - 1], node)
233
+ y[node] = cursor
234
+
235
+ def pack(level: list[str], wanted: dict[str, float]) -> None:
236
+ """Place nodes near their wanted y without overlap, preserving order."""
237
+ placed: list[float] = []
238
+ for k, node in enumerate(level):
239
+ value = wanted[node]
240
+ if k:
241
+ value = max(value, placed[-1] + gap_between(level[k - 1], node))
242
+ placed.append(value)
243
+ # shift the block so that its mean displacement is zero
244
+ shift = sum(wanted[n] - p for n, p in zip(level, placed)) / len(level)
245
+ for k in range(len(level) - 1, -1, -1):
246
+ candidate = placed[k] + shift
247
+ if k < len(level) - 1:
248
+ candidate = min(candidate, placed[k + 1] - gap_between(level[k], level[k + 1]))
249
+ placed[k] = candidate
250
+ for node, value in zip(level, placed):
251
+ y[node] = value
252
+
253
+ for iteration in range(24):
254
+ forward = iteration % 2 == 0
255
+ sequence = layers if forward else layers[::-1]
256
+ for level in sequence:
257
+ wanted = {}
258
+ for node in level:
259
+ linked = (up[node] if forward else down[node]) + \
260
+ (down[node] if forward else up[node]) + side[node]
261
+ if linked:
262
+ values = sorted(y[x] for x in linked)
263
+ mid = len(values) // 2
264
+ median = values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2
265
+ wanted[node] = 0.5 * y[node] + 0.5 * median
266
+ else:
267
+ wanted[node] = y[node]
268
+ pack(level, wanted)
269
+
270
+ top = min(y[n] - size[n][1] / 2 for n in all_nodes)
271
+ positions = {n: (xs[layer[n]], y[n] - top) for n in ids}
272
+ routes: dict[int, list[tuple[float, float]]] = {}
273
+ for index, chain in chain_of.items():
274
+ routes[index] = [(xs[layer[n]], y[n] - top) for n in chain]
275
+ for index in same_layer:
276
+ a, b = edges[index]
277
+ routes[index] = [(xs[layer[a]], y[a] - top), (xs[layer[b]], y[b] - top)]
278
+ height = max(y[n] - top + size[n][1] / 2 for n in all_nodes)
279
+ return LayoutResult(positions, routes, xs[-1] + widths[-1] / 2, height)
@@ -0,0 +1,364 @@
1
+ """Event logs: the data every process mining technique starts from.
2
+
3
+ Vocabulary (as in van der Aalst, *Process Mining*, ch. 2 and 5)
4
+ ---------------------------------------------------------------
5
+ event
6
+ One recorded occurrence of an activity, carrying attributes such as the
7
+ activity name (``concept:name``), a timestamp (``time:timestamp``), a
8
+ resource (``org:resource``) and a lifecycle transition
9
+ (``lifecycle:transition``: *start*, *complete*, ...).
10
+ trace (case)
11
+ The ordered list of events belonging to one process instance, e.g. one
12
+ passenger boarding a plane. A trace also has attributes of its own; its
13
+ ``concept:name`` is the *case id*.
14
+ event log
15
+ A collection of traces.
16
+
17
+ Two views of the same log
18
+ -------------------------
19
+ The *rich* view (:class:`EventLog`) keeps every attribute, because the dotted
20
+ chart, performance analysis and the case browser need timestamps and
21
+ resources.
22
+
23
+ Most discovery algorithms, however, only look at the *control-flow* view: each
24
+ trace reduced to its sequence of activity names, and the log reduced to a
25
+ **multiset of such sequences**, written in the course as::
26
+
27
+ L = [ <a,b,c,d>^3, <a,c,b,d>^2, <a,e,d> ]
28
+
29
+ A multiset (not a set) because the same sequence can occur many times, and
30
+ frequency matters for filtering and for the quality measures. In Python a
31
+ multiset is a :class:`collections.Counter`; we call it :data:`SimpleLog`.
32
+
33
+ Large logs
34
+ ----------
35
+ A log read with ``read_xes(path, columnar=True)`` (the default above a
36
+ size threshold) keeps its events in a :class:`~.columns.ColumnStore`, one
37
+ array per attribute, and ``traces`` is a :class:`~.columns.LazyTraces`
38
+ that builds each ``Trace`` on first access. The API is the same; the
39
+ sequences, the event count and the variants come straight from the
40
+ arrays, so discovery on a million events does not create a million
41
+ objects.
42
+
43
+ Classifiers
44
+ -----------
45
+ Which attribute(s) turn an event into an *activity label* is a choice, called
46
+ the *event classifier* in XES. The default is ``concept:name``. A log that
47
+ records both *start* and *complete* events (like the plane-boarding logs in
48
+ the course) would, with the default classifier, show every activity twice in a
49
+ row. Two standard remedies are offered as ready-made classifiers:
50
+
51
+ * keep only *complete* events and label them by name, or
52
+ * label every event by ``name + lifecycle`` (so ``Move in+start`` and
53
+ ``Move in+complete`` become different activities).
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import re
59
+ from collections import Counter
60
+ from dataclasses import dataclass, field
61
+ from datetime import datetime
62
+ from typing import Any, Iterable, Iterator
63
+
64
+ #: The control-flow abstraction of a log: a multiset of activity sequences.
65
+ SimpleLog = Counter # Counter[tuple[str, ...]]
66
+
67
+ # Standard XES attribute keys, named once so a typo is a NameError rather
68
+ # than a silently empty column.
69
+ KEY_NAME = "concept:name"
70
+ KEY_TIME = "time:timestamp"
71
+ KEY_LIFECYCLE = "lifecycle:transition"
72
+ KEY_RESOURCE = "org:resource"
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Events and traces
77
+ # ---------------------------------------------------------------------------
78
+ @dataclass(slots=True)
79
+ class Event:
80
+ """One event: a bag of typed attributes.
81
+
82
+ Attribute values are already converted to Python types by the reader:
83
+ ``str``, ``int``, ``float``, ``bool``, :class:`datetime`, or ``list`` for
84
+ XES list attributes.
85
+ """
86
+
87
+ attributes: dict[str, Any] = field(default_factory=dict)
88
+
89
+ # Convenience accessors for the standard extensions --------------------
90
+ @property
91
+ def activity(self) -> str | None:
92
+ return self.attributes.get(KEY_NAME)
93
+
94
+ @property
95
+ def timestamp(self) -> datetime | None:
96
+ value = self.attributes.get(KEY_TIME)
97
+ return value if isinstance(value, datetime) else None
98
+
99
+ @property
100
+ def lifecycle(self) -> str | None:
101
+ return self.attributes.get(KEY_LIFECYCLE)
102
+
103
+ @property
104
+ def resource(self) -> str | None:
105
+ return self.attributes.get(KEY_RESOURCE)
106
+
107
+ def get(self, key: str, default: Any = None) -> Any:
108
+ return self.attributes.get(key, default)
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class Trace:
113
+ """One case: trace-level attributes plus the ordered list of events."""
114
+
115
+ attributes: dict[str, Any] = field(default_factory=dict)
116
+ events: list[Event] = field(default_factory=list)
117
+
118
+ @property
119
+ def case_id(self) -> str:
120
+ value = self.attributes.get(KEY_NAME)
121
+ return "" if value is None else str(value)
122
+
123
+ def __len__(self) -> int:
124
+ return len(self.events)
125
+
126
+ def __iter__(self) -> Iterator[Event]:
127
+ return iter(self.events)
128
+
129
+ def __getitem__(self, index: int) -> Event:
130
+ return self.events[index]
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Classifiers
135
+ # ---------------------------------------------------------------------------
136
+ @dataclass(frozen=True)
137
+ class Classifier:
138
+ """Maps an event to an activity label, optionally dropping some events.
139
+
140
+ Parameters
141
+ ----------
142
+ name:
143
+ Shown in the user interface.
144
+ keys:
145
+ Attribute keys whose values are joined with ``+`` to form the label.
146
+ lifecycles:
147
+ If given, only events whose ``lifecycle:transition`` is in this set
148
+ are kept (case-insensitive). Events *without* a lifecycle attribute
149
+ are always kept: a log that never recorded lifecycles should not come
150
+ out empty.
151
+ """
152
+
153
+ name: str
154
+ keys: tuple[str, ...] = (KEY_NAME,)
155
+ lifecycles: frozenset[str] | None = None
156
+
157
+ def accepts(self, event: Event) -> bool:
158
+ if self.lifecycles is None:
159
+ return True
160
+ lifecycle = event.attributes.get(KEY_LIFECYCLE)
161
+ return lifecycle is None or str(lifecycle).lower() in self.lifecycles
162
+
163
+ def label(self, event: Event) -> str:
164
+ parts = [event.attributes.get(key) for key in self.keys]
165
+ return "+".join("" if part is None else str(part) for part in parts)
166
+
167
+
168
+ #: Label by activity name, keep every event.
169
+ BY_NAME = Classifier("Activity")
170
+ #: Label by activity name, keep only *complete* events.
171
+ BY_NAME_COMPLETE = Classifier("Activity (complete events)",
172
+ lifecycles=frozenset({"complete"}))
173
+ #: Label by activity name and lifecycle transition.
174
+ BY_NAME_LIFECYCLE = Classifier("Activity + lifecycle", keys=(KEY_NAME, KEY_LIFECYCLE))
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # The log
179
+ # ---------------------------------------------------------------------------
180
+ @dataclass
181
+ class EventLog:
182
+ """A list of traces plus log-level metadata.
183
+
184
+ ``classifiers`` holds the classifiers declared in the XES file (if any) in
185
+ addition to the built-in ones; the GUI offers all of them.
186
+ """
187
+
188
+ traces: list[Trace] = field(default_factory=list)
189
+ attributes: dict[str, Any] = field(default_factory=dict)
190
+ extensions: list[dict[str, str]] = field(default_factory=list)
191
+ global_trace_attributes: dict[str, Any] = field(default_factory=dict)
192
+ global_event_attributes: dict[str, Any] = field(default_factory=dict)
193
+ declared_classifiers: list[Classifier] = field(default_factory=list)
194
+ source_path: str | None = None
195
+
196
+ # -- container protocol --------------------------------------------------
197
+ def __len__(self) -> int:
198
+ return len(self.traces)
199
+
200
+ def __iter__(self) -> Iterator[Trace]:
201
+ return iter(self.traces)
202
+
203
+ def __getitem__(self, index: int) -> Trace:
204
+ return self.traces[index]
205
+
206
+ @property
207
+ def name(self) -> str:
208
+ value = self.attributes.get(KEY_NAME)
209
+ if value:
210
+ return str(value)
211
+ if self.source_path:
212
+ from pathlib import Path
213
+ return Path(self.source_path).stem
214
+ return "Untitled log"
215
+
216
+ @property
217
+ def event_count(self) -> int:
218
+ lazy = getattr(self.traces, "event_count", None)
219
+ return lazy if lazy is not None else sum(len(trace) for trace in self.traces)
220
+
221
+ @property
222
+ def columnar(self) -> bool:
223
+ """Are the events kept in columns (a large log, read lazily)?"""
224
+ return hasattr(self.traces, "store") and not getattr(self.traces, "modified", True)
225
+
226
+ @classmethod
227
+ def from_columns(cls, store, **fields) -> "EventLog":
228
+ from .columns import LazyTraces
229
+ return cls(traces=LazyTraces(store), **fields)
230
+
231
+ # -- classifiers ------------------------------------------------------
232
+ def has_lifecycle_pairs(self) -> bool:
233
+ """Does the log record both *start* and *complete* events?"""
234
+ if self.columnar:
235
+ return self.traces.store.has_lifecycle_pairs()
236
+ seen: set[str] = set()
237
+ for trace in self.traces:
238
+ for event in trace:
239
+ lifecycle = event.attributes.get(KEY_LIFECYCLE)
240
+ if lifecycle is not None:
241
+ seen.add(str(lifecycle).lower())
242
+ if {"start", "complete"} <= seen:
243
+ return True
244
+ return False
245
+
246
+ def default_classifier(self) -> Classifier:
247
+ """The most sensible classifier for control-flow discovery.
248
+
249
+ With start/complete pairs, the plain name classifier would produce a
250
+ self-loop on every activity, which is an artefact of logging, not of
251
+ the process. Keeping complete events avoids that.
252
+ """
253
+ return BY_NAME_COMPLETE if self.has_lifecycle_pairs() else BY_NAME
254
+
255
+ def available_classifiers(self) -> list[Classifier]:
256
+ built_in = [BY_NAME, BY_NAME_COMPLETE, BY_NAME_LIFECYCLE]
257
+ names = {c.name for c in built_in}
258
+ extra = [c for c in self.declared_classifiers if c.name not in names]
259
+ return built_in + extra
260
+
261
+ # -- control-flow projections ----------------------------------------
262
+ def sequence(self, trace: Trace, classifier: Classifier | None = None) -> tuple[str, ...]:
263
+ """The activity sequence of one trace under ``classifier``."""
264
+ classifier = classifier or self.default_classifier()
265
+ return tuple(classifier.label(e) for e in trace.events if classifier.accepts(e))
266
+
267
+ def sequences(self, classifier: Classifier | None = None) -> list[tuple[str, ...]]:
268
+ """Activity sequences for every trace, in log order."""
269
+ classifier = classifier or self.default_classifier()
270
+ if self.columnar:
271
+ fast = self.traces.sequences(classifier.keys, classifier.lifecycles)
272
+ if fast is not None:
273
+ return fast
274
+ return [self.sequence(trace, classifier) for trace in self.traces]
275
+
276
+ def simple_log(self, classifier: Classifier | None = None) -> SimpleLog:
277
+ """The multiset of activity sequences: the input to discovery."""
278
+ return Counter(self.sequences(classifier))
279
+
280
+ def filtered(self, keep: Iterable[int]) -> "EventLog":
281
+ """A new log containing only the traces at the given indices."""
282
+ indices = list(keep)
283
+ return EventLog(
284
+ traces=[self.traces[i] for i in indices],
285
+ attributes=dict(self.attributes),
286
+ extensions=list(self.extensions),
287
+ global_trace_attributes=dict(self.global_trace_attributes),
288
+ global_event_attributes=dict(self.global_event_attributes),
289
+ declared_classifiers=list(self.declared_classifiers),
290
+ source_path=self.source_path,
291
+ )
292
+
293
+ # -- construction from a simple log ----------------------------------
294
+ @classmethod
295
+ def from_simple_log(cls, simple: SimpleLog, name: str = "Log") -> "EventLog":
296
+ """Expand a multiset of sequences into a full log (no timestamps).
297
+
298
+ Handy for textbook exercises: the course writes logs as multisets, and
299
+ this turns one into something every view in the app can display.
300
+ """
301
+ log = cls(attributes={KEY_NAME: name})
302
+ case = 0
303
+ for sequence, count in simple.items():
304
+ for _ in range(count):
305
+ case += 1
306
+ log.traces.append(Trace(
307
+ attributes={KEY_NAME: f"case {case}"},
308
+ events=[Event({KEY_NAME: activity}) for activity in sequence],
309
+ ))
310
+ return log
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Textbook notation
315
+ # ---------------------------------------------------------------------------
316
+ _TRACE_PATTERN = re.compile(r"<([^<>]*)>\s*(?:\^\s*(\d+))?")
317
+
318
+ # The textbook and the slides typeset traces as ⟨a,b,c⟩³: angle brackets and
319
+ # superscript counts. Text copied from a PDF therefore arrives in that form.
320
+ _TYPESET = str.maketrans({"⟨": "<", "⟩": ">", "〈": "<", "〉": ">", "‹": "<", "›": ">"})
321
+ _SUPERSCRIPTS = re.compile("[⁰¹²³⁴⁵⁶⁷⁸⁹]+")
322
+ _SUPERSCRIPT_DIGITS = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")
323
+
324
+
325
+ def parse_simple_log(text: str) -> SimpleLog:
326
+ """Parse the notation used in the course and the textbook.
327
+
328
+ Accepted forms (whitespace is free, the outer brackets are optional)::
329
+
330
+ [<a,b,c,d>^3, <a,c,b,d>^2, <a,e,d>]
331
+ [⟨a,b,c,d⟩³, ⟨a,c,b,d⟩², ⟨a,e,d⟩] # as typeset in the book
332
+ [<>^2, <a>] # the empty trace is allowed
333
+
334
+ Activity names may contain spaces (``<register request, pay>``). A
335
+ multiplicity defaults to 1. Repeated sequences are added together, so
336
+ ``<a>, <a>`` is the same as ``<a>^2``; a multiplicity of 0 leaves the
337
+ trace out.
338
+
339
+ Raises ``ValueError`` if nothing trace-like is found, because silently
340
+ returning an empty log would make a typo look like a finding.
341
+ """
342
+ text = text.translate(_TYPESET)
343
+ text = _SUPERSCRIPTS.sub(lambda m: "^" + m.group().translate(_SUPERSCRIPT_DIGITS), text)
344
+ log: SimpleLog = Counter()
345
+ for match in _TRACE_PATTERN.finditer(text):
346
+ body, count = match.group(1), match.group(2)
347
+ activities = tuple(part.strip() for part in body.split(",") if part.strip())
348
+ log[activities] += int(count) if count else 1
349
+ log = Counter({trace: count for trace, count in log.items() if count > 0})
350
+ if not log:
351
+ raise ValueError("No traces found. Write traces as <a,b,c>^n, e.g. [<a,b,c>^3, <a,c>]")
352
+ return log
353
+
354
+
355
+ def format_simple_log(log: SimpleLog, max_traces: int | None = None) -> str:
356
+ """The inverse of :func:`parse_simple_log`, most frequent first."""
357
+ items = sorted(log.items(), key=lambda item: (-item[1], item[0]))
358
+ if max_traces is not None:
359
+ items = items[:max_traces]
360
+ parts = []
361
+ for sequence, count in items:
362
+ body = ",".join(sequence)
363
+ parts.append(f"<{body}>" + (f"^{count}" if count != 1 else ""))
364
+ return "[" + ", ".join(parts) + "]"