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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- 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) + "]"
|