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,421 @@
|
|
|
1
|
+
"""Typeset derivations: how a discovery algorithm arrived at its model.
|
|
2
|
+
|
|
3
|
+
α-algorithm
|
|
4
|
+
The eight steps as a numbered list of formulas, typeset like the book:
|
|
5
|
+
``T_L``, ``X_L``, ``Y_L`` with subscripts, sets in braces, one (A, B)
|
|
6
|
+
pair per line, and places written as ``p_(A,B)``.
|
|
7
|
+
|
|
8
|
+
Inductive Miner
|
|
9
|
+
Three views of the same result:
|
|
10
|
+
|
|
11
|
+
* the **process tree drawn as a tree** (operators as circles, activities
|
|
12
|
+
as boxes, τ as a black bar);
|
|
13
|
+
* the tree as a **formula** with the operators highlighted;
|
|
14
|
+
* the **recursion** as an expandable outline: every cut with its
|
|
15
|
+
partition, every base case and fall-through, nested exactly as the
|
|
16
|
+
algorithm recursed.
|
|
17
|
+
|
|
18
|
+
State-based regions
|
|
19
|
+
The transition system, its regions and the elementary checks
|
|
20
|
+
(:class:`.regions_view.RegionsPanel`).
|
|
21
|
+
|
|
22
|
+
Rendering uses Qt's rich-text engine (a subset of HTML): subscripts, italics
|
|
23
|
+
and a serif maths font give readable formulas without a browser engine.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from html import escape
|
|
29
|
+
|
|
30
|
+
from PySide6.QtCore import QSize, Qt, QTimer
|
|
31
|
+
from PySide6.QtGui import QAbstractTextDocumentLayout, QColor, QPalette, QTextDocument
|
|
32
|
+
from PySide6.QtWidgets import (
|
|
33
|
+
QFrame, QLabel, QSizePolicy, QStyle, QStyledItemDelegate, QStyleOptionViewItem, QTreeWidget,
|
|
34
|
+
QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from ...mining.discovery.alpha import AlphaResult
|
|
38
|
+
from ...mining.discovery.inductive import InductiveResult
|
|
39
|
+
from ...mining.processtree import Operator, ProcessTree
|
|
40
|
+
from . import style
|
|
41
|
+
from .graph_view import EdgeSpec, GraphView, NodeSpec, node_size
|
|
42
|
+
from .widgets import Verdict, label
|
|
43
|
+
|
|
44
|
+
MATH_FONT = ("'STIX Two Math', 'STIX Two Text', 'Cambria Math', 'Latin Modern Math', "
|
|
45
|
+
"'Times New Roman', serif")
|
|
46
|
+
OPERATOR_NAMES = {"→": "sequence", "×": "exclusive choice", "∧": "parallel", "↺": "loop"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Small typesetting helpers (return HTML fragments)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
def _var(name: str, sub: str = "L") -> str:
|
|
53
|
+
"""An italic maths symbol with a subscript, e.g. T_L."""
|
|
54
|
+
return f"<i>{escape(name)}</i><sub>{escape(sub)}</sub>"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _activity(name: str) -> str:
|
|
58
|
+
"""Activity names are words, set upright in the UI font."""
|
|
59
|
+
return f"<span style='font-family: sans-serif'>{escape(name)}</span>"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _set(items) -> str:
|
|
63
|
+
items = sorted(items)
|
|
64
|
+
if not items:
|
|
65
|
+
return "∅"
|
|
66
|
+
return "{" + ", ".join(_activity(i) for i in items) + "}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pair(pair) -> str:
|
|
70
|
+
a, b = pair
|
|
71
|
+
return f"({_set(a)}, {_set(b)})"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _place(pair) -> str:
|
|
75
|
+
return f"<i>p</i><sub>{_pair(pair)}</sub>"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _rich(html: str, size: int = 14) -> QLabel:
|
|
79
|
+
t = style.tokens()
|
|
80
|
+
widget = QLabel()
|
|
81
|
+
widget.setTextFormat(Qt.RichText)
|
|
82
|
+
widget.setWordWrap(True)
|
|
83
|
+
widget.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
84
|
+
widget.setText(f"<div style='font-family: {MATH_FONT}; font-size: {size}px; "
|
|
85
|
+
f"color: {t.text}'>{html}</div>")
|
|
86
|
+
widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
|
|
87
|
+
return widget
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# α-algorithm
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
def alpha_view(result: AlphaResult) -> QWidget:
|
|
94
|
+
t = style.tokens()
|
|
95
|
+
host = QWidget()
|
|
96
|
+
layout = QVBoxLayout(host)
|
|
97
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
98
|
+
layout.setSpacing(6)
|
|
99
|
+
for warning in result.warnings:
|
|
100
|
+
layout.addWidget(Verdict("Limitation", "warning", warning))
|
|
101
|
+
|
|
102
|
+
maximal = set(result.Y_L)
|
|
103
|
+
x_lines = "<br>".join(
|
|
104
|
+
(f"<b>{_pair(p)}</b>" if p in maximal else _pair(p)) for p in result.X_L) or "∅"
|
|
105
|
+
y_lines = "<br>".join(_pair(p) for p in result.Y_L) or "∅"
|
|
106
|
+
places = ", ".join([f"<i>i</i><sub>L</sub>"] + [_place(p) for p in result.Y_L]
|
|
107
|
+
+ [f"<i>o</i><sub>L</sub>"])
|
|
108
|
+
arcs = []
|
|
109
|
+
for arc in result.net.arcs:
|
|
110
|
+
arcs.append(f"({escape(result.net.node_name(arc.source))}, "
|
|
111
|
+
f"{escape(result.net.node_name(arc.target))})")
|
|
112
|
+
steps = [
|
|
113
|
+
("1", "all activities", f"{_var('T')} = {_set(result.T_L)}"),
|
|
114
|
+
("2", "start activities", f"{_var('T', 'I')} = {_set(result.T_I)}"),
|
|
115
|
+
("3", "end activities", f"{_var('T', 'O')} = {_set(result.T_O)}"),
|
|
116
|
+
("4", "candidate pairs: A → B, members of A and of B pairwise # "
|
|
117
|
+
"(maximal ones in bold)", f"{_var('X')} = {{<br>{x_lines}<br>}}"),
|
|
118
|
+
("5", "maximal pairs", f"{_var('Y')} = {{<br>{y_lines}<br>}}"),
|
|
119
|
+
("6", "places: one per maximal pair, plus source and sink",
|
|
120
|
+
f"{_var('P')} = {{ {places} }}"),
|
|
121
|
+
("7", f"flow relation: {len(result.net.arcs)} arcs",
|
|
122
|
+
f"{_var('F')} = {{ " + ", ".join(escape(a) for a in arcs[:40])
|
|
123
|
+
+ (" …" if len(arcs) > 40 else "") + " }"),
|
|
124
|
+
("8", "the discovered net", f"α(<i>L</i>) = ({_var('P')}, {_var('T')}, {_var('F')})"),
|
|
125
|
+
]
|
|
126
|
+
rows = []
|
|
127
|
+
for number, caption, formula in steps:
|
|
128
|
+
rows.append(
|
|
129
|
+
f"<tr><td valign='top' style='padding: 6px 10px 6px 0; color: {t.text_muted}; "
|
|
130
|
+
f"font-family: sans-serif; font-size: 12px'><b>{number}</b></td>"
|
|
131
|
+
f"<td style='padding: 6px 0'>{formula}<br><span style='font-family: sans-serif; "
|
|
132
|
+
f"font-size: 11px; color: {t.text_muted}'>{escape(caption)}</span></td></tr>")
|
|
133
|
+
layout.addWidget(_rich("<table cellspacing='0' width='100%'>" + "".join(rows) + "</table>"))
|
|
134
|
+
layout.addStretch(1)
|
|
135
|
+
return host
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Process trees
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
def tree_formula(tree: ProcessTree) -> str:
|
|
142
|
+
"""The tree as a formula with coloured operators (HTML)."""
|
|
143
|
+
accent = style.tokens().accent
|
|
144
|
+
|
|
145
|
+
def render(node: ProcessTree) -> str:
|
|
146
|
+
if node.is_leaf:
|
|
147
|
+
return "<i>τ</i>" if node.label is None else _activity(node.label)
|
|
148
|
+
inner = ", ".join(render(c) for c in node.children)
|
|
149
|
+
return f"<b style='color: {accent}'>{node.operator.value}</b>({inner})"
|
|
150
|
+
return render(tree)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#: Space between neighbouring subtrees, and between one level and the next.
|
|
154
|
+
TREE_SIBLING_GAP = 18.0
|
|
155
|
+
TREE_LEVEL_GAP = 56.0
|
|
156
|
+
#: The drawn tree's height on the Discover tab: at least, at most (pixels).
|
|
157
|
+
TREE_VIEW_HEIGHT = (160, 420)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def tree_specs(tree: ProcessTree) -> tuple[list[NodeSpec], list[EdgeSpec],
|
|
161
|
+
dict[str, tuple[float, float]]]:
|
|
162
|
+
"""Nodes, edges and positions for drawing the tree with the graph canvas.
|
|
163
|
+
|
|
164
|
+
Drawn the way a process tree is in the course: the root at the top, each
|
|
165
|
+
operator's children in a row underneath it, in order (left to right is the
|
|
166
|
+
order of a sequence), each parent centred over its children, with plain
|
|
167
|
+
lines and τ as a leaf like any other (not the Petri net's black bar).
|
|
168
|
+
"""
|
|
169
|
+
t = style.tokens()
|
|
170
|
+
nodes: list[NodeSpec] = []
|
|
171
|
+
edges: list[EdgeSpec] = []
|
|
172
|
+
#: Each node's children, and how wide its subtree is.
|
|
173
|
+
children: dict[str, list[str]] = {}
|
|
174
|
+
widths: dict[str, float] = {}
|
|
175
|
+
sizes: dict[str, tuple[float, float]] = {}
|
|
176
|
+
counter = [0]
|
|
177
|
+
|
|
178
|
+
def visit(node: ProcessTree) -> str:
|
|
179
|
+
counter[0] += 1
|
|
180
|
+
node_id = f"n{counter[0]}"
|
|
181
|
+
if node.is_leaf:
|
|
182
|
+
if node.label is None:
|
|
183
|
+
spec = NodeSpec(node_id, "transition", text="τ", tooltip="τ (silent step)")
|
|
184
|
+
else:
|
|
185
|
+
spec = NodeSpec(node_id, "transition", text=node.label,
|
|
186
|
+
tooltip=f"activity {node.label}")
|
|
187
|
+
else:
|
|
188
|
+
symbol = node.operator.value
|
|
189
|
+
spec = NodeSpec(node_id, "operator", text=symbol, fill=t.accent_soft,
|
|
190
|
+
stroke=t.accent, tooltip=f"{symbol} {OPERATOR_NAMES[symbol]}")
|
|
191
|
+
nodes.append(spec)
|
|
192
|
+
sizes[node_id] = node_size(spec)
|
|
193
|
+
children[node_id] = []
|
|
194
|
+
for child in node.children:
|
|
195
|
+
child_id = visit(child)
|
|
196
|
+
children[node_id].append(child_id)
|
|
197
|
+
# A plain line: a tree's edge means "contains", not "goes to".
|
|
198
|
+
edges.append(EdgeSpec(node_id, child_id, width=1.2, colour=t.text_muted,
|
|
199
|
+
arrow=False))
|
|
200
|
+
below = [widths[c] for c in children[node_id]]
|
|
201
|
+
widths[node_id] = max(sizes[node_id][0],
|
|
202
|
+
sum(below) + TREE_SIBLING_GAP * max(0, len(below) - 1))
|
|
203
|
+
return node_id
|
|
204
|
+
|
|
205
|
+
root = visit(tree)
|
|
206
|
+
# Each level is as tall as its tallest node, so rows line up.
|
|
207
|
+
depth_of: dict[str, int] = {}
|
|
208
|
+
heights: dict[int, float] = {}
|
|
209
|
+
|
|
210
|
+
def depths(node_id: str, depth: int) -> None:
|
|
211
|
+
depth_of[node_id] = depth
|
|
212
|
+
heights[depth] = max(heights.get(depth, 0.0), sizes[node_id][1])
|
|
213
|
+
for child in children[node_id]:
|
|
214
|
+
depths(child, depth + 1)
|
|
215
|
+
|
|
216
|
+
depths(root, 0)
|
|
217
|
+
tops, y = {}, 0.0
|
|
218
|
+
for depth in sorted(heights):
|
|
219
|
+
tops[depth] = y
|
|
220
|
+
y += heights[depth] + TREE_LEVEL_GAP
|
|
221
|
+
positions: dict[str, tuple[float, float]] = {}
|
|
222
|
+
|
|
223
|
+
def place(node_id: str, left: float) -> None:
|
|
224
|
+
depth = depth_of[node_id]
|
|
225
|
+
positions[node_id] = (left + widths[node_id] / 2, tops[depth] + heights[depth] / 2)
|
|
226
|
+
below = children[node_id]
|
|
227
|
+
span = sum(widths[c] for c in below) + TREE_SIBLING_GAP * max(0, len(below) - 1)
|
|
228
|
+
x = left + (widths[node_id] - span) / 2
|
|
229
|
+
for child in below:
|
|
230
|
+
place(child, x)
|
|
231
|
+
x += widths[child] + TREE_SIBLING_GAP
|
|
232
|
+
|
|
233
|
+
place(root, 0.0)
|
|
234
|
+
return nodes, edges, positions
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TreeView(GraphView):
|
|
238
|
+
"""The drawn process tree: as tall as the tree is at the width it gets.
|
|
239
|
+
|
|
240
|
+
Stretched to fill the column instead, the canvas ran past the bottom of
|
|
241
|
+
the card (taking its zoom bar with it), with the tree a sliver in the
|
|
242
|
+
middle.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def fit_height(self) -> None:
|
|
246
|
+
rect = self.graph.sceneRect()
|
|
247
|
+
if rect.isEmpty():
|
|
248
|
+
return
|
|
249
|
+
scale = min(self.viewport().width() / rect.width(), 1.4) if self.width() > 1 else 1.0
|
|
250
|
+
height = rect.height() * scale + 2 * self.frameWidth()
|
|
251
|
+
height = int(min(max(height, TREE_VIEW_HEIGHT[0]), TREE_VIEW_HEIGHT[1]))
|
|
252
|
+
if height != self.height():
|
|
253
|
+
self.setFixedHeight(height)
|
|
254
|
+
# The zoom bar is placed for the old height until the layout has
|
|
255
|
+
# applied the new one: place it again once it has.
|
|
256
|
+
QTimer.singleShot(0, self._place_controls)
|
|
257
|
+
self.fit()
|
|
258
|
+
|
|
259
|
+
def resizeEvent(self, event) -> None: # noqa: N802
|
|
260
|
+
super().resizeEvent(event)
|
|
261
|
+
if event.size().width() != event.oldSize().width():
|
|
262
|
+
self.fit_height()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Rich-text rows in a tree widget
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
class HtmlDelegate(QStyledItemDelegate):
|
|
269
|
+
"""Paints an item's text as rich text (HTML) instead of plain text."""
|
|
270
|
+
|
|
271
|
+
def _document(self, option, index) -> QTextDocument:
|
|
272
|
+
document = QTextDocument()
|
|
273
|
+
document.setDefaultFont(option.font)
|
|
274
|
+
document.setHtml(index.data(Qt.DisplayRole) or "")
|
|
275
|
+
document.setTextWidth(max(option.rect.width(), 200))
|
|
276
|
+
return document
|
|
277
|
+
|
|
278
|
+
def paint(self, painter, option, index) -> None:
|
|
279
|
+
options = QStyleOptionViewItem(option)
|
|
280
|
+
self.initStyleOption(options, index)
|
|
281
|
+
html = options.text
|
|
282
|
+
options.text = ""
|
|
283
|
+
style_ = options.widget.style() if options.widget else None
|
|
284
|
+
if style_ is not None:
|
|
285
|
+
style_.drawControl(QStyle.CE_ItemViewItem, options, painter, options.widget)
|
|
286
|
+
document = QTextDocument()
|
|
287
|
+
document.setDefaultFont(options.font)
|
|
288
|
+
document.setHtml(html)
|
|
289
|
+
document.setTextWidth(options.rect.width())
|
|
290
|
+
painter.save()
|
|
291
|
+
painter.translate(options.rect.topLeft())
|
|
292
|
+
context = QAbstractTextDocumentLayout.PaintContext()
|
|
293
|
+
context.palette.setColor(QPalette.Text, QColor(style.tokens().text))
|
|
294
|
+
document.documentLayout().draw(painter, context)
|
|
295
|
+
painter.restore()
|
|
296
|
+
|
|
297
|
+
def sizeHint(self, option, index) -> QSize: # noqa: N802
|
|
298
|
+
document = QTextDocument()
|
|
299
|
+
document.setDefaultFont(option.font)
|
|
300
|
+
document.setHtml(index.data(Qt.DisplayRole) or "")
|
|
301
|
+
# Wrap at the width the row will really get, or rows overlap.
|
|
302
|
+
width = option.rect.width()
|
|
303
|
+
widget = option.widget
|
|
304
|
+
if widget is not None and hasattr(widget, "viewport"):
|
|
305
|
+
indent = 0
|
|
306
|
+
if hasattr(widget, "indentation"):
|
|
307
|
+
depth, parent = 0, index.parent()
|
|
308
|
+
while parent.isValid():
|
|
309
|
+
depth, parent = depth + 1, parent.parent()
|
|
310
|
+
indent = widget.indentation() * (depth + 1)
|
|
311
|
+
width = widget.viewport().width() - indent - 6
|
|
312
|
+
document.setTextWidth(max(width, 200))
|
|
313
|
+
return QSize(int(document.idealWidth()), int(document.size().height()) + 6)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _step_html(step) -> str:
|
|
317
|
+
t = style.tokens()
|
|
318
|
+
accent, muted = t.accent, t.text_muted
|
|
319
|
+
if step.kind == "cut":
|
|
320
|
+
groups = " | ".join(_set(g) for g in step.groups or [])
|
|
321
|
+
where = " <span style='color:%s'>(on the filtered DFG)</span>" % muted if step.filtered else ""
|
|
322
|
+
return (f"<b style='color:{accent}; font-size: 15px'>{step.operator}</b> "
|
|
323
|
+
f"<b>{OPERATOR_NAMES.get(step.operator, '')} cut</b>{where}<br>"
|
|
324
|
+
f"<span style='font-family:{MATH_FONT}'>{groups}</span>")
|
|
325
|
+
if step.kind == "base":
|
|
326
|
+
leaf = "<i>τ</i>" if step.result == "τ" else _activity(step.result or "")
|
|
327
|
+
return f"<b>base case</b> → {leaf}"
|
|
328
|
+
if step.kind == "filter":
|
|
329
|
+
return f"<b>IMf filter</b> <span style='color:{muted}'>{escape(step.detail or step.text)}</span>"
|
|
330
|
+
operator = f"<b style='color:{accent}'>{step.operator}</b> " if step.operator else ""
|
|
331
|
+
return (f"{operator}<b>fall-through</b> <span style='color:{muted}'>"
|
|
332
|
+
f"{escape(step.detail or step.text)}</span>")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def inductive_view(result: InductiveResult, draw_tree: bool = True) -> QWidget:
|
|
336
|
+
host = QWidget()
|
|
337
|
+
layout = QVBoxLayout(host)
|
|
338
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
339
|
+
layout.setSpacing(8)
|
|
340
|
+
|
|
341
|
+
layout.addWidget(label("PROCESS TREE", "sectionLabel"))
|
|
342
|
+
layout.addWidget(_rich(tree_formula(result.tree), 15))
|
|
343
|
+
if draw_tree:
|
|
344
|
+
view = TreeView()
|
|
345
|
+
nodes, edges, positions = tree_specs(result.tree)
|
|
346
|
+
view.graph.populate(nodes, edges, positions=positions)
|
|
347
|
+
view.fit_height()
|
|
348
|
+
layout.addWidget(view)
|
|
349
|
+
|
|
350
|
+
layout.addWidget(label("RECURSION", "sectionLabel"))
|
|
351
|
+
tree = QTreeWidget()
|
|
352
|
+
tree.setHeaderHidden(True)
|
|
353
|
+
tree.setItemDelegate(HtmlDelegate(tree))
|
|
354
|
+
tree.setUniformRowHeights(False)
|
|
355
|
+
tree.setFrameShape(QFrame.NoFrame)
|
|
356
|
+
tree.setMinimumHeight(200)
|
|
357
|
+
stack: list[tuple[int, QTreeWidgetItem | QTreeWidget]] = [(-1, tree)]
|
|
358
|
+
for step in result.steps:
|
|
359
|
+
while stack and stack[-1][0] >= step.depth:
|
|
360
|
+
stack.pop()
|
|
361
|
+
parent = stack[-1][1]
|
|
362
|
+
item = QTreeWidgetItem(parent, [_step_html(step)]) if isinstance(parent, QTreeWidgetItem) \
|
|
363
|
+
else QTreeWidgetItem(tree, [_step_html(step)])
|
|
364
|
+
item.setToolTip(0, step.text)
|
|
365
|
+
stack.append((step.depth, item))
|
|
366
|
+
tree.expandAll()
|
|
367
|
+
layout.addWidget(tree, 1)
|
|
368
|
+
return host
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def heuristics_view(result) -> QWidget:
|
|
372
|
+
"""The causal net behind a Heuristics Miner Petri net: each activity's
|
|
373
|
+
input and output bindings, with how often the log used them."""
|
|
374
|
+
from ...mining.discovery.heuristics import END, START
|
|
375
|
+
host = QWidget()
|
|
376
|
+
layout = QVBoxLayout(host)
|
|
377
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
378
|
+
layout.setSpacing(8)
|
|
379
|
+
graph = result.graph
|
|
380
|
+
layout.addWidget(label(
|
|
381
|
+
f"1. Dependency graph: {len(graph.edges)} arcs a → b with a ⇒ b ≥ "
|
|
382
|
+
f"{result.dependency_threshold:g}. 2. Bindings, from replaying the log: which "
|
|
383
|
+
"predecessors each activity waited for (in) and which successors it enabled (out). "
|
|
384
|
+
"A binding of several activities is an AND; different bindings are an XOR. "
|
|
385
|
+
"3. One place per arc, and a silent transition per binding where there is a choice.",
|
|
386
|
+
"muted", wrap=True))
|
|
387
|
+
layout.addWidget(label("BINDINGS", "sectionLabel"))
|
|
388
|
+
|
|
389
|
+
def shown(binding) -> str:
|
|
390
|
+
names = sorted({"start" if a == START else "end" if a == END else a for a in binding})
|
|
391
|
+
return "{" + ", ".join(escape(n) for n in names) + "}"
|
|
392
|
+
|
|
393
|
+
causal = result.causal
|
|
394
|
+
activities = sorted((set(causal.inputs) | set(causal.outputs)) - {START, END})
|
|
395
|
+
rows = []
|
|
396
|
+
for activity in activities:
|
|
397
|
+
ins = " or ".join(f"{shown(b)} ×{n}" for b, n in causal.inputs.get(activity, {}).most_common())
|
|
398
|
+
outs = " or ".join(f"{shown(b)} ×{n}" for b, n in causal.outputs.get(activity, {}).most_common())
|
|
399
|
+
rows.append(f"<b>{escape(activity)}</b><br> in: {ins or '–'}"
|
|
400
|
+
f"<br> out: {outs or '–'}")
|
|
401
|
+
layout.addWidget(_rich("<br>".join(rows), 13))
|
|
402
|
+
layout.addStretch(1)
|
|
403
|
+
return host
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def derivation_view(payload, draw_tree: bool = True) -> QWidget:
|
|
407
|
+
from ...mining.discovery.heuristics import HeuristicsResult
|
|
408
|
+
if isinstance(payload, AlphaResult):
|
|
409
|
+
return alpha_view(payload)
|
|
410
|
+
if isinstance(payload, InductiveResult):
|
|
411
|
+
return inductive_view(payload, draw_tree)
|
|
412
|
+
if isinstance(payload, HeuristicsResult):
|
|
413
|
+
return heuristics_view(payload)
|
|
414
|
+
from ...mining.discovery.state_regions import RegionResult
|
|
415
|
+
if isinstance(payload, RegionResult):
|
|
416
|
+
from .regions_view import RegionsPanel
|
|
417
|
+
# The net is already drawn beside it (Discover tab, model page).
|
|
418
|
+
return RegionsPanel(payload, show_net=False, flat=True)
|
|
419
|
+
info = getattr(payload, "info", {}) or {}
|
|
420
|
+
return label("\n".join(f"{k}: {v}" for k, v in info.items()) or "No details.", "muted",
|
|
421
|
+
wrap=True, selectable=True)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Open documents: the objects the sidebar lists.
|
|
2
|
+
|
|
3
|
+
A *document* wraps one thing the user opened or produced -- an event log or
|
|
4
|
+
a Petri net -- together with the derived data the views need (statistics,
|
|
5
|
+
activity colours) so that switching between pages never recomputes them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from ...mining.log import Classifier, EventLog
|
|
14
|
+
from ...mining.petrinet import PetriNet
|
|
15
|
+
from ...mining.stats import LogSummary, summarise
|
|
16
|
+
from .style import ActivityColours
|
|
17
|
+
|
|
18
|
+
_ids = itertools.count(1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(eq=False)
|
|
22
|
+
class LogDocument:
|
|
23
|
+
log: EventLog
|
|
24
|
+
path: str | None = None
|
|
25
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
26
|
+
#: Its file was deleted or moved away while it was open.
|
|
27
|
+
missing: bool = False
|
|
28
|
+
#: The text the log was written in, for a log typed in the course's
|
|
29
|
+
#: notation or read from a ``….log.txt`` file (None for XES and CSV logs).
|
|
30
|
+
notation: str | None = None
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
self.classifier: Classifier = self.log.default_classifier()
|
|
34
|
+
self._summary: LogSummary | None = None
|
|
35
|
+
self._colours: ActivityColours | None = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def name(self) -> str:
|
|
39
|
+
return self.log.name
|
|
40
|
+
|
|
41
|
+
def replace_log(self, log: EventLog) -> None:
|
|
42
|
+
"""The file was read again: show the new log (same classifier if it still applies)."""
|
|
43
|
+
classifier = self.classifier
|
|
44
|
+
self.log = log
|
|
45
|
+
self.classifier = (classifier if classifier in log.available_classifiers()
|
|
46
|
+
else log.default_classifier())
|
|
47
|
+
self._summary = None
|
|
48
|
+
self._colours = None
|
|
49
|
+
|
|
50
|
+
def set_classifier(self, classifier: Classifier) -> None:
|
|
51
|
+
self.classifier = classifier
|
|
52
|
+
self._summary = None
|
|
53
|
+
self._colours = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def summary(self) -> LogSummary:
|
|
57
|
+
if self._summary is None:
|
|
58
|
+
self._summary = summarise(self.log, self.classifier)
|
|
59
|
+
return self._summary
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def colours(self) -> ActivityColours:
|
|
63
|
+
if self._colours is None:
|
|
64
|
+
self._colours = ActivityColours([a.name for a in self.summary.activities])
|
|
65
|
+
return self._colours
|
|
66
|
+
|
|
67
|
+
def simple_log(self):
|
|
68
|
+
return self.log.simple_log(self.classifier)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(eq=False)
|
|
72
|
+
class ModelDocument:
|
|
73
|
+
net: PetriNet
|
|
74
|
+
path: str | None = None
|
|
75
|
+
#: Where it came from: "α-algorithm on <log>", "PNML file", ...
|
|
76
|
+
origin: str = ""
|
|
77
|
+
#: Algorithm-specific extras (AlphaResult, InductiveResult, ...)
|
|
78
|
+
derivation: object | None = None
|
|
79
|
+
source_log: LogDocument | None = None
|
|
80
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
81
|
+
#: Its file was deleted or moved away while it was open.
|
|
82
|
+
missing: bool = False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
return self.net.name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(eq=False)
|
|
90
|
+
class CpnDocument:
|
|
91
|
+
"""A coloured Petri net (CPN Tools model) being edited and simulated."""
|
|
92
|
+
|
|
93
|
+
net: object # openprocess.model.net.CPNet
|
|
94
|
+
path: str | None = None
|
|
95
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
96
|
+
#: Edited since it was opened or last saved.
|
|
97
|
+
dirty: bool = False
|
|
98
|
+
#: Edits are saved by themselves (a file in the open folder): no "edited" mark.
|
|
99
|
+
autosave: bool = False
|
|
100
|
+
#: Its file was deleted or moved away while it was open.
|
|
101
|
+
missing: bool = False
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def name(self) -> str:
|
|
105
|
+
return self.net.name
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(eq=False)
|
|
109
|
+
class ComparisonDocument:
|
|
110
|
+
"""Two or more open logs shown side by side (nothing is stored on disk)."""
|
|
111
|
+
|
|
112
|
+
logs: list
|
|
113
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
114
|
+
path: str | None = None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
if len(self.logs) == 2:
|
|
119
|
+
return f"{self.logs[0].name} vs {self.logs[1].name}"
|
|
120
|
+
return f"{len(self.logs)} logs compared"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(eq=False)
|
|
124
|
+
class WorkflowDocument:
|
|
125
|
+
"""A workflow of boxes (a ``.cpnflow`` file), with the record of its last save."""
|
|
126
|
+
|
|
127
|
+
workflow: object # openprocess.flow.workflow.Workflow
|
|
128
|
+
path: str | None = None
|
|
129
|
+
record: object | None = None # openprocess.flow.record.Record
|
|
130
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
131
|
+
dirty: bool = False
|
|
132
|
+
autosave: bool = False
|
|
133
|
+
missing: bool = False
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def name(self) -> str:
|
|
137
|
+
return self.workflow.name
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(eq=False)
|
|
141
|
+
class TransitionSystemDocument:
|
|
142
|
+
"""A transition system, typed or from a ``ts.txt`` file, to study its regions."""
|
|
143
|
+
|
|
144
|
+
ts: object # openprocess.mining.transition_system.TransitionSystem
|
|
145
|
+
path: str | None = None
|
|
146
|
+
id: int = field(default_factory=lambda: next(_ids))
|
|
147
|
+
#: Its file was deleted or moved away while it was open.
|
|
148
|
+
missing: bool = False
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def name(self) -> str:
|
|
152
|
+
return self.ts.name
|