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,800 @@
|
|
|
1
|
+
"""The event log workspace: overview, variants, cases, dotted chart,
|
|
2
|
+
process map, footprint, and discovery.
|
|
3
|
+
|
|
4
|
+
Everything shown is derived from one :class:`LogDocument` under its current
|
|
5
|
+
*classifier* (the rule that turns events into activity labels). Changing the
|
|
6
|
+
classifier in the header recomputes every tab.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Qt, Signal
|
|
12
|
+
from PySide6.QtGui import QColor, QStandardItem, QStandardItemModel
|
|
13
|
+
from PySide6.QtWidgets import (
|
|
14
|
+
QAbstractItemView, QComboBox, QFileDialog, QGridLayout, QHeaderView, QLabel,
|
|
15
|
+
QGraphicsOpacityEffect, QLineEdit, QPlainTextEdit, QSizePolicy, QSlider, QSpinBox, QSplitter, QStackedWidget, QTableView,
|
|
16
|
+
QVBoxLayout, QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ...mining import pm4py_bridge
|
|
20
|
+
from ...mining.dfg import discover_dfg
|
|
21
|
+
from ...mining.discovery.alpha import AlphaResult, alpha_miner
|
|
22
|
+
from ...mining.discovery.heuristics import heuristics_miner, heuristics_net
|
|
23
|
+
from ...mining.discovery.inductive import InductiveResult, inductive_miner
|
|
24
|
+
from ...mining.footprint import footprint_of_log
|
|
25
|
+
from ...mining.stats import format_duration
|
|
26
|
+
from ...mining.xes import write_xes
|
|
27
|
+
from .. import theme
|
|
28
|
+
from . import style
|
|
29
|
+
from .charts import ColumnChart
|
|
30
|
+
from ..learn.concealment import ConcealsResults
|
|
31
|
+
from .documents import LogDocument, ModelDocument
|
|
32
|
+
from .dotted_chart import DottedChartPanel
|
|
33
|
+
from .graph_builders import dependency_specs, dfg_specs, petri_net_specs
|
|
34
|
+
from .graph_view import GraphView
|
|
35
|
+
from .widgets import (
|
|
36
|
+
suggested_path,
|
|
37
|
+
footprint_table,
|
|
38
|
+
BarDelegate, Card, ElidedLabel, Legend, LegendSwatch, PageHeader, SegmentedControl,
|
|
39
|
+
SequenceDelegate, StatTile, Verdict,
|
|
40
|
+
button, hbox, label, scroll, vbox,
|
|
41
|
+
)
|
|
42
|
+
from .workers import run_in_background
|
|
43
|
+
|
|
44
|
+
def plural(count: int, singular: str, many: str | None = None) -> str:
|
|
45
|
+
return f"{count:,} {singular if count == 1 else many or singular + 's'}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
TABS = ["Overview", "Variants", "Cases", "Dotted chart", "Process map", "Footprint", "Discover"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _item(text, align_right: bool = False, data=None, colour: str | None = None) -> QStandardItem:
|
|
52
|
+
item = QStandardItem("" if text is None else str(text))
|
|
53
|
+
item.setEditable(False)
|
|
54
|
+
if align_right:
|
|
55
|
+
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
56
|
+
if data is not None:
|
|
57
|
+
item.setData(data, Qt.UserRole)
|
|
58
|
+
if colour is not None:
|
|
59
|
+
item.setData(colour, Qt.UserRole + 1)
|
|
60
|
+
return item
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _runs(sequence) -> list[tuple[str, int]]:
|
|
64
|
+
"""Collapse repeats: (a, b, b, b, c) -> [(a, 1), (b, 3), (c, 1)]."""
|
|
65
|
+
runs: list[tuple[str, int]] = []
|
|
66
|
+
for activity in sequence:
|
|
67
|
+
if runs and runs[-1][0] == activity:
|
|
68
|
+
runs[-1] = (activity, runs[-1][1] + 1)
|
|
69
|
+
else:
|
|
70
|
+
runs.append((activity, 1))
|
|
71
|
+
return runs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _table(model: QStandardItemModel) -> QTableView:
|
|
75
|
+
view = QTableView()
|
|
76
|
+
view.setModel(model)
|
|
77
|
+
view.setAlternatingRowColors(True)
|
|
78
|
+
view.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
79
|
+
view.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
80
|
+
view.setShowGrid(False)
|
|
81
|
+
view.verticalHeader().setVisible(False)
|
|
82
|
+
view.verticalHeader().setDefaultSectionSize(30)
|
|
83
|
+
view.horizontalHeader().setHighlightSections(False)
|
|
84
|
+
view.horizontalHeader().setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
|
85
|
+
view.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
86
|
+
view.setWordWrap(False)
|
|
87
|
+
return view
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LogPage(ConcealsResults, QWidget):
|
|
91
|
+
open_model = Signal(object) # ModelDocument
|
|
92
|
+
#: "As a workflow" on the Discover tab: the window makes a workflow of this discovery.
|
|
93
|
+
workflow_requested = Signal(dict)
|
|
94
|
+
open_log = Signal(object) # LogDocument (a filtered copy)
|
|
95
|
+
status = Signal(str)
|
|
96
|
+
#: Emitted after the log was exported; the document now lives in that file.
|
|
97
|
+
saved = Signal()
|
|
98
|
+
#: The log was edited (Edit…): the document holds the new log.
|
|
99
|
+
edited = Signal()
|
|
100
|
+
|
|
101
|
+
def __init__(self, document: LogDocument, parent=None) -> None:
|
|
102
|
+
super().__init__(parent)
|
|
103
|
+
self.document = document
|
|
104
|
+
self._built: set[int] = set()
|
|
105
|
+
|
|
106
|
+
root = QVBoxLayout(self)
|
|
107
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
108
|
+
root.setSpacing(0)
|
|
109
|
+
|
|
110
|
+
self.header = PageHeader()
|
|
111
|
+
self.classifier_box = QComboBox()
|
|
112
|
+
self.classifier_box.setToolTip("How events become activity labels")
|
|
113
|
+
# Let the combo shrink below its longest entry instead of widening the window.
|
|
114
|
+
self.classifier_box.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
|
115
|
+
self.classifier_box.setMinimumContentsLength(14)
|
|
116
|
+
for classifier in document.log.available_classifiers():
|
|
117
|
+
self.classifier_box.addItem(classifier.name, classifier)
|
|
118
|
+
index = self.classifier_box.findText(document.classifier.name)
|
|
119
|
+
self.classifier_box.setCurrentIndex(max(index, 0))
|
|
120
|
+
self.classifier_box.currentIndexChanged.connect(self._classifier_changed)
|
|
121
|
+
self.header.actions.addWidget(label("Classifier", "muted"))
|
|
122
|
+
self.header.actions.addWidget(self.classifier_box)
|
|
123
|
+
self.edit_button = button("Edit…", self.edit_log,
|
|
124
|
+
tooltip="Change the log: its notation, or case by case")
|
|
125
|
+
self.header.actions.addWidget(self.edit_button)
|
|
126
|
+
self.header.actions.addWidget(button("Filter…", self.filter_log,
|
|
127
|
+
tooltip="Keep part of the log (variants, "
|
|
128
|
+
"activities, start/end, length, time) "
|
|
129
|
+
"as a new log"))
|
|
130
|
+
self.header.actions.addWidget(button("Export…", self.export,
|
|
131
|
+
tooltip="Save the log as XES or CSV"))
|
|
132
|
+
root.addWidget(self.header)
|
|
133
|
+
|
|
134
|
+
self.tabs = SegmentedControl(TABS)
|
|
135
|
+
root.addLayout(hbox(self.tabs, None, margins=(20, 0, 20, 12)))
|
|
136
|
+
|
|
137
|
+
self.stack = QStackedWidget()
|
|
138
|
+
root.addWidget(self.stack, 1)
|
|
139
|
+
self.pages = [QWidget() for _ in TABS]
|
|
140
|
+
for page in self.pages:
|
|
141
|
+
QVBoxLayout(page).setContentsMargins(20, 0, 20, 16)
|
|
142
|
+
self.stack.addWidget(page)
|
|
143
|
+
self.tabs.changed.connect(self._show_tab)
|
|
144
|
+
self._refresh_header()
|
|
145
|
+
self._show_tab(0)
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------ util
|
|
148
|
+
def _refresh_header(self) -> None:
|
|
149
|
+
s = self.document.summary
|
|
150
|
+
from pathlib import Path
|
|
151
|
+
where = Path(self.document.path).name if self.document.path else "created in the app"
|
|
152
|
+
self.header.set_text(self.document.name,
|
|
153
|
+
f"{s.case_count:,} cases · {s.event_count:,} events · "
|
|
154
|
+
f"{s.activity_count} activities · {where}")
|
|
155
|
+
|
|
156
|
+
def _classifier_changed(self) -> None:
|
|
157
|
+
self.document.set_classifier(self.classifier_box.currentData())
|
|
158
|
+
self._refresh_header()
|
|
159
|
+
# Rebuild lazily: forget every built tab, then rebuild the visible one.
|
|
160
|
+
self._built.clear()
|
|
161
|
+
for page in self.pages:
|
|
162
|
+
layout = page.layout()
|
|
163
|
+
while layout.count():
|
|
164
|
+
item = layout.takeAt(0)
|
|
165
|
+
if item.widget():
|
|
166
|
+
item.widget().hide()
|
|
167
|
+
item.widget().deleteLater()
|
|
168
|
+
self._show_tab(self.tabs.index())
|
|
169
|
+
|
|
170
|
+
def refresh_title(self) -> None:
|
|
171
|
+
self._refresh_header()
|
|
172
|
+
|
|
173
|
+
def export(self) -> None:
|
|
174
|
+
"""Save the log as XES (or CSV). The document then refers to that file,
|
|
175
|
+
so it can be reopened at the next launch and removed without losing it."""
|
|
176
|
+
notation = "Textbook notation, activities only (*.log.txt)"
|
|
177
|
+
filters = ["XES (*.xes *.xes.gz)", "CSV, one row per event (*.csv)", notation]
|
|
178
|
+
if self.document.notation: # written in notation: offer that first
|
|
179
|
+
filters.insert(0, filters.pop())
|
|
180
|
+
path, chosen = QFileDialog.getSaveFileName(
|
|
181
|
+
self, "Export event log", suggested_path(
|
|
182
|
+
f"{self.document.name}." + ("log.txt" if self.document.notation else "xes")),
|
|
183
|
+
";;".join(filters))
|
|
184
|
+
if not path:
|
|
185
|
+
return
|
|
186
|
+
if chosen.startswith("CSV") and not path.lower().endswith(".csv"):
|
|
187
|
+
path += ".csv"
|
|
188
|
+
elif chosen.startswith("Textbook") and not path.lower().endswith(".log.txt"):
|
|
189
|
+
path = (path[:-4] if path.lower().endswith(".txt") else path) + ".log.txt"
|
|
190
|
+
self.write_to(path)
|
|
191
|
+
|
|
192
|
+
def edit_log(self) -> bool:
|
|
193
|
+
"""Edit the log (see :mod:`.log_editor`); True when it changed."""
|
|
194
|
+
from .log_editor import LogEditorDialog
|
|
195
|
+
dialog = LogEditorDialog(self.document, self)
|
|
196
|
+
if dialog.exec() != LogEditorDialog.Accepted or dialog.new_log is None:
|
|
197
|
+
return False
|
|
198
|
+
self.apply_edit(dialog.new_log, dialog.new_notation)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def apply_edit(self, log, notation: str | None) -> None:
|
|
202
|
+
"""Make ``log`` (written as ``notation``, if it is) the document's log."""
|
|
203
|
+
self.document.replace_log(log)
|
|
204
|
+
self.document.notation = notation
|
|
205
|
+
self.edited.emit()
|
|
206
|
+
|
|
207
|
+
def write_to(self, path: str, quiet: bool = False) -> None:
|
|
208
|
+
"""Save the log to ``path`` (XES, CSV, or notation for ``….log.txt``, by its
|
|
209
|
+
extension); it is then that file."""
|
|
210
|
+
from .workspace import atomic_write
|
|
211
|
+
if path.lower().endswith(".txt"):
|
|
212
|
+
from ...mining.log import format_simple_log
|
|
213
|
+
text = self.document.notation or format_simple_log(self.document.simple_log())
|
|
214
|
+
atomic_write(path, lambda temporary: temporary.write_text(text.strip() + "\n",
|
|
215
|
+
encoding="utf-8"))
|
|
216
|
+
elif path.lower().endswith(".csv"):
|
|
217
|
+
from ...mining.csv_import import write_csv
|
|
218
|
+
atomic_write(path, lambda temporary: write_csv(self.document.log, temporary))
|
|
219
|
+
else:
|
|
220
|
+
atomic_write(path, lambda temporary: write_xes(self.document.log, temporary))
|
|
221
|
+
self.document.path = path
|
|
222
|
+
self.document.missing = False
|
|
223
|
+
self._refresh_header()
|
|
224
|
+
if not quiet:
|
|
225
|
+
self.status.emit(f"Saved {path}")
|
|
226
|
+
self.saved.emit()
|
|
227
|
+
|
|
228
|
+
def filter_log(self) -> None:
|
|
229
|
+
"""Filter this log into a new one (the Filter… dialog)."""
|
|
230
|
+
from .filter_dialog import FilterDialog
|
|
231
|
+
dialog = FilterDialog(self.document, self)
|
|
232
|
+
if dialog.exec() != FilterDialog.Accepted:
|
|
233
|
+
return
|
|
234
|
+
filtered = LogDocument(dialog.result_log())
|
|
235
|
+
if self.document.classifier in filtered.log.available_classifiers():
|
|
236
|
+
filtered.set_classifier(self.document.classifier)
|
|
237
|
+
self.open_log.emit(filtered)
|
|
238
|
+
|
|
239
|
+
def _show_tab(self, index: int) -> None:
|
|
240
|
+
if index not in self._built:
|
|
241
|
+
builder = [self._build_overview, self._build_variants, self._build_cases,
|
|
242
|
+
self._build_dotted, self._build_map, self._build_footprint,
|
|
243
|
+
self._build_discover][index]
|
|
244
|
+
builder(self.pages[index].layout())
|
|
245
|
+
self._built.add(index)
|
|
246
|
+
self.stack.setCurrentIndex(index)
|
|
247
|
+
|
|
248
|
+
# -------------------------------------------------------------- overview
|
|
249
|
+
def _build_overview(self, layout) -> None:
|
|
250
|
+
s = self.document.summary
|
|
251
|
+
content = QWidget()
|
|
252
|
+
column = QVBoxLayout(content)
|
|
253
|
+
column.setContentsMargins(0, 0, 0, 0)
|
|
254
|
+
column.setSpacing(14)
|
|
255
|
+
|
|
256
|
+
span = "–"
|
|
257
|
+
if s.start and s.end:
|
|
258
|
+
span = format_duration(s.end - s.start)
|
|
259
|
+
tiles = [
|
|
260
|
+
StatTile("Cases", f"{s.case_count:,}"),
|
|
261
|
+
StatTile("Events", f"{s.event_count:,}",
|
|
262
|
+
f"{self.document.log.event_count:,} before classifier"
|
|
263
|
+
if s.event_count != self.document.log.event_count else ""),
|
|
264
|
+
StatTile("Activities", f"{s.activity_count}"),
|
|
265
|
+
StatTile("Variants", f"{s.variant_count:,}",
|
|
266
|
+
f"{s.variant_count / max(s.case_count, 1):.2f} variants per case"),
|
|
267
|
+
StatTile("Median case duration", format_duration(s.median_case_duration),
|
|
268
|
+
f"mean {format_duration(s.mean_case_duration)}"),
|
|
269
|
+
StatTile("Time span", span, s.start.strftime("%d %b %Y %H:%M") if s.start else ""),
|
|
270
|
+
]
|
|
271
|
+
grid = QGridLayout()
|
|
272
|
+
grid.setSpacing(12)
|
|
273
|
+
for i, tile in enumerate(tiles):
|
|
274
|
+
grid.addWidget(tile, 0, i)
|
|
275
|
+
column.addLayout(grid)
|
|
276
|
+
|
|
277
|
+
# Activity table with inline bars
|
|
278
|
+
activities = Card("Activities", "Frequency of each activity; colour identifies it "
|
|
279
|
+
"in the dotted chart and variant views.")
|
|
280
|
+
model = QStandardItemModel(0, 5)
|
|
281
|
+
model.setHorizontalHeaderLabels(["Activity", "Events", "Cases", "Starts", "Ends"])
|
|
282
|
+
top = max((a.occurrences for a in s.activities), default=1)
|
|
283
|
+
colours = self.document.colours
|
|
284
|
+
for a in s.activities:
|
|
285
|
+
colour = colours.colour(a.name)
|
|
286
|
+
name = _item(a.name)
|
|
287
|
+
name.setData(QColor(colour), Qt.DecorationRole)
|
|
288
|
+
model.appendRow([name, _item(f"{a.occurrences:,}", data=a.occurrences / top,
|
|
289
|
+
colour=colour),
|
|
290
|
+
_item(f"{a.cases:,}", True), _item(f"{a.as_start:,}", True),
|
|
291
|
+
_item(f"{a.as_end:,}", True)])
|
|
292
|
+
table = _table(model)
|
|
293
|
+
table.setItemDelegateForColumn(1, BarDelegate(Qt.UserRole + 1, table))
|
|
294
|
+
header = table.horizontalHeader()
|
|
295
|
+
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
|
296
|
+
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
|
297
|
+
for c in (2, 3, 4):
|
|
298
|
+
header.setSectionResizeMode(c, QHeaderView.ResizeToContents)
|
|
299
|
+
table.setMinimumHeight(min(60 + 30 * len(s.activities), 420))
|
|
300
|
+
activities.add(table)
|
|
301
|
+
|
|
302
|
+
lengths = Card("Case length", "Number of events per case")
|
|
303
|
+
chart = ColumnChart("length", "cases")
|
|
304
|
+
if s.events_per_case:
|
|
305
|
+
longest = max(s.events_per_case)
|
|
306
|
+
chart.set_values([(str(n), s.events_per_case.get(n, 0))
|
|
307
|
+
for n in range(min(s.events_per_case), longest + 1)])
|
|
308
|
+
lengths.add(chart, 1)
|
|
309
|
+
|
|
310
|
+
row = QGridLayout()
|
|
311
|
+
row.setSpacing(12)
|
|
312
|
+
row.addWidget(activities, 0, 0)
|
|
313
|
+
row.addWidget(lengths, 0, 1)
|
|
314
|
+
row.setColumnStretch(0, 3)
|
|
315
|
+
row.setColumnStretch(1, 2)
|
|
316
|
+
column.addLayout(row)
|
|
317
|
+
|
|
318
|
+
attributes = Card("Log attributes")
|
|
319
|
+
rows = [(k, str(v)) for k, v in self.document.log.attributes.items()]
|
|
320
|
+
rows += [("classifier (file)", c.name) for c in self.document.log.declared_classifiers]
|
|
321
|
+
text = "\n".join(f"{k}: {v}" for k, v in rows) or "No log-level attributes."
|
|
322
|
+
attributes.add(label(text, "muted", wrap=True, selectable=True))
|
|
323
|
+
column.addWidget(attributes)
|
|
324
|
+
column.addStretch(1)
|
|
325
|
+
layout.addWidget(scroll(content))
|
|
326
|
+
|
|
327
|
+
# -------------------------------------------------------------- variants
|
|
328
|
+
def _build_variants(self, layout) -> None:
|
|
329
|
+
s = self.document.summary
|
|
330
|
+
colours = self.document.colours
|
|
331
|
+
card = Card("Variants", "Distinct activity sequences, most frequent first.")
|
|
332
|
+
model = QStandardItemModel(0, 5)
|
|
333
|
+
model.setHorizontalHeaderLabels(["#", "Cases", "Share", "Length", "Sequence"])
|
|
334
|
+
for number, variant in enumerate(s.variants, 1):
|
|
335
|
+
chips = [(f"{a} ×{n}" if n > 1 else a, colours.colour(a), None)
|
|
336
|
+
for a, n in _runs(variant.sequence)]
|
|
337
|
+
sequence_item = _item(" → ".join(variant.sequence), data=chips)
|
|
338
|
+
sequence_item.setToolTip(" → ".join(variant.sequence))
|
|
339
|
+
model.appendRow([
|
|
340
|
+
_item(number, True), _item(f"{variant.count:,}", True),
|
|
341
|
+
_item(f"{variant.count / max(s.case_count, 1):.1%}", True),
|
|
342
|
+
_item(len(variant.sequence), True), sequence_item])
|
|
343
|
+
table = _table(model)
|
|
344
|
+
table.setItemDelegateForColumn(4, SequenceDelegate(table))
|
|
345
|
+
header = table.horizontalHeader()
|
|
346
|
+
for c in range(4):
|
|
347
|
+
header.setSectionResizeMode(c, QHeaderView.ResizeToContents)
|
|
348
|
+
header.setSectionResizeMode(4, QHeaderView.Stretch)
|
|
349
|
+
card.add(table, 1)
|
|
350
|
+
layout.addWidget(card, 1)
|
|
351
|
+
|
|
352
|
+
# ----------------------------------------------------------------- cases
|
|
353
|
+
def _build_cases(self, layout) -> None:
|
|
354
|
+
log = self.document.log
|
|
355
|
+
s = self.document.summary
|
|
356
|
+
variant_of = {}
|
|
357
|
+
for number, variant in enumerate(s.variants, 1):
|
|
358
|
+
for index in variant.trace_indices:
|
|
359
|
+
variant_of[index] = number
|
|
360
|
+
|
|
361
|
+
cases = QStandardItemModel(0, 5)
|
|
362
|
+
cases.setHorizontalHeaderLabels(["Case", "Events", "Start", "Duration", "Variant"])
|
|
363
|
+
for index, trace in enumerate(log.traces):
|
|
364
|
+
stamps = [e.timestamp for e in trace.events if e.timestamp]
|
|
365
|
+
start = min(stamps).strftime("%Y-%m-%d %H:%M") if stamps else "–"
|
|
366
|
+
duration = format_duration(max(stamps) - min(stamps)) if stamps else "–"
|
|
367
|
+
first = _item(trace.case_id, data=index)
|
|
368
|
+
cases.appendRow([first, _item(len(trace), True), _item(start),
|
|
369
|
+
_item(duration, True), _item(variant_of.get(index, ""), True)])
|
|
370
|
+
case_table = _table(cases)
|
|
371
|
+
case_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
|
372
|
+
case_table.horizontalHeader().setStretchLastSection(True)
|
|
373
|
+
|
|
374
|
+
search = QLineEdit()
|
|
375
|
+
search.setPlaceholderText("Filter cases…")
|
|
376
|
+
search.setClearButtonEnabled(True)
|
|
377
|
+
|
|
378
|
+
def filter_rows(text: str) -> None:
|
|
379
|
+
for row in range(cases.rowCount()):
|
|
380
|
+
case_table.setRowHidden(row, text.lower() not in cases.item(row, 0).text().lower())
|
|
381
|
+
search.textChanged.connect(filter_rows)
|
|
382
|
+
|
|
383
|
+
events = QStandardItemModel(0, 5)
|
|
384
|
+
event_table = _table(events)
|
|
385
|
+
colours = self.document.colours
|
|
386
|
+
classifier = self.document.classifier
|
|
387
|
+
|
|
388
|
+
def show_case(current, _previous=None) -> None:
|
|
389
|
+
events.clear()
|
|
390
|
+
events.setHorizontalHeaderLabels(["#", "Activity", "Timestamp", "Lifecycle",
|
|
391
|
+
"Resource", "Other attributes"])
|
|
392
|
+
if not current.isValid():
|
|
393
|
+
return
|
|
394
|
+
trace = log.traces[cases.item(current.row(), 0).data(Qt.UserRole)]
|
|
395
|
+
standard = {"concept:name", "time:timestamp", "lifecycle:transition", "org:resource"}
|
|
396
|
+
for number, event in enumerate(trace.events, 1):
|
|
397
|
+
included = classifier.accepts(event)
|
|
398
|
+
name = _item(classifier.label(event) if included else event.activity)
|
|
399
|
+
if included:
|
|
400
|
+
name.setData(QColor(colours.colour(classifier.label(event))), Qt.DecorationRole)
|
|
401
|
+
else:
|
|
402
|
+
name.setForeground(QColor(style.tokens().text_muted))
|
|
403
|
+
name.setToolTip("Not included by the current classifier")
|
|
404
|
+
others = ", ".join(f"{k}={v}" for k, v in event.attributes.items()
|
|
405
|
+
if k not in standard)
|
|
406
|
+
events.appendRow([
|
|
407
|
+
_item(number, True), name,
|
|
408
|
+
_item(event.timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
409
|
+
if event.timestamp else "–"),
|
|
410
|
+
_item(event.lifecycle or ""), _item(event.resource or ""), _item(others)])
|
|
411
|
+
header = event_table.horizontalHeader()
|
|
412
|
+
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
|
413
|
+
header.setStretchLastSection(True)
|
|
414
|
+
|
|
415
|
+
case_table.selectionModel().currentRowChanged.connect(show_case)
|
|
416
|
+
|
|
417
|
+
left = Card("Cases")
|
|
418
|
+
left.add(search)
|
|
419
|
+
left.add(case_table, 1)
|
|
420
|
+
right = Card("Events of the selected case")
|
|
421
|
+
right.add(event_table, 1)
|
|
422
|
+
splitter = QSplitter()
|
|
423
|
+
splitter.setHandleWidth(12)
|
|
424
|
+
splitter.addWidget(left)
|
|
425
|
+
splitter.addWidget(right)
|
|
426
|
+
splitter.setSizes([380, 700])
|
|
427
|
+
splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
|
|
428
|
+
layout.addWidget(splitter, 1)
|
|
429
|
+
if cases.rowCount():
|
|
430
|
+
case_table.selectRow(0)
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------- dotted chart
|
|
433
|
+
def _build_dotted(self, layout) -> None:
|
|
434
|
+
layout.addWidget(DottedChartPanel(self.document), 1)
|
|
435
|
+
|
|
436
|
+
# ----------------------------------------------------------- process map
|
|
437
|
+
def _build_map(self, layout) -> None:
|
|
438
|
+
dfg = discover_dfg(self.document.log, self.document.classifier)
|
|
439
|
+
view = GraphView()
|
|
440
|
+
# Fill the window rather than ask for the whole map's height (see Discover).
|
|
441
|
+
view.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
|
|
442
|
+
mode = SegmentedControl(["Frequency", "Performance"])
|
|
443
|
+
has_time = bool(dfg.durations)
|
|
444
|
+
mode.buttons[1].setEnabled(has_time)
|
|
445
|
+
if not has_time:
|
|
446
|
+
mode.buttons[1].setToolTip("The log has no timestamps")
|
|
447
|
+
activities = QSlider(Qt.Horizontal)
|
|
448
|
+
paths = QSlider(Qt.Horizontal)
|
|
449
|
+
for slider, value in ((activities, 100), (paths, 100)):
|
|
450
|
+
slider.setRange(0, 100)
|
|
451
|
+
slider.setValue(value)
|
|
452
|
+
slider.setMinimumWidth(70)
|
|
453
|
+
slider.setMaximumWidth(220)
|
|
454
|
+
activity_label, path_label = label("100%", "muted"), label("100%", "muted")
|
|
455
|
+
info = ElidedLabel("", "muted")
|
|
456
|
+
legend = Legend()
|
|
457
|
+
|
|
458
|
+
def update_legend(simplified) -> None:
|
|
459
|
+
if mode.index() == 1:
|
|
460
|
+
means = [m for m in (simplified.mean_duration(e) for e in simplified.edges)
|
|
461
|
+
if m is not None]
|
|
462
|
+
slowest = max(means, default=0)
|
|
463
|
+
legend.set_items([
|
|
464
|
+
(LegendSwatch("ramp"), "activity: darker = more events (count inside)"),
|
|
465
|
+
(LegendSwatch("line"), "label = mean time until the next activity"),
|
|
466
|
+
(LegendSwatch("widths"), "thicker = slower (relative to the slowest path)"),
|
|
467
|
+
(LegendSwatch("line", style.STATUS["critical"], 3.5),
|
|
468
|
+
f"red = slow: mean ≥ ⅔ of the slowest ({format_duration(slowest)})"),
|
|
469
|
+
(LegendSwatch("dashed"), "case start / end"),
|
|
470
|
+
])
|
|
471
|
+
else:
|
|
472
|
+
legend.set_items([
|
|
473
|
+
(LegendSwatch("ramp"), "activity: darker = more events (count inside)"),
|
|
474
|
+
(LegendSwatch("widths"), "thicker = path taken more often (label = times)"),
|
|
475
|
+
(LegendSwatch("line", style.tokens().text_muted), "grey = rare path (< 15 % of the busiest)"),
|
|
476
|
+
(LegendSwatch("dashed"), "case start / end"),
|
|
477
|
+
])
|
|
478
|
+
|
|
479
|
+
def redraw(fit: bool = True) -> None:
|
|
480
|
+
simplified = dfg.simplified(activities.value() / 100, paths.value() / 100)
|
|
481
|
+
activity_label.setText(f"{activities.value()}%")
|
|
482
|
+
path_label.setText(f"{paths.value()}%")
|
|
483
|
+
nodes, edges = dfg_specs(simplified, "performance" if mode.index() == 1 else "frequency")
|
|
484
|
+
view.graph.populate(nodes, edges, layer_gap=64)
|
|
485
|
+
update_legend(simplified)
|
|
486
|
+
info.setText(f"{len(simplified.activities)} of {len(dfg.activities)} activities · "
|
|
487
|
+
f"{len(simplified.all_edges())} of {len(dfg.all_edges())} paths")
|
|
488
|
+
if fit:
|
|
489
|
+
view.fit()
|
|
490
|
+
|
|
491
|
+
activities.valueChanged.connect(lambda _: redraw())
|
|
492
|
+
paths.valueChanged.connect(lambda _: redraw())
|
|
493
|
+
mode.changed.connect(lambda _: redraw(True))
|
|
494
|
+
|
|
495
|
+
card = Card("Process map", "Directly-follows graph: an arrow a → b means b directly "
|
|
496
|
+
"followed a in some case. Drag the sliders to simplify.")
|
|
497
|
+
top_row = hbox(mode, 12, info)
|
|
498
|
+
top_row.setStretch(2, 1) # the info text takes the slack
|
|
499
|
+
card.add(top_row)
|
|
500
|
+
card.add(hbox(label("Activities"), activities, activity_label, 16,
|
|
501
|
+
label("Paths"), paths, path_label, None))
|
|
502
|
+
card.add(view, 1)
|
|
503
|
+
card.add(legend)
|
|
504
|
+
layout.addWidget(card, 1)
|
|
505
|
+
redraw()
|
|
506
|
+
|
|
507
|
+
# ------------------------------------------------------------- footprint
|
|
508
|
+
def _build_footprint(self, layout) -> None:
|
|
509
|
+
fp = footprint_of_log(self.document.simple_log())
|
|
510
|
+
table = footprint_table(fp)
|
|
511
|
+
card = Card("Footprint matrix", "Log-based ordering relations derived from the "
|
|
512
|
+
"directly-follows relation >_L (row activity vs column activity).")
|
|
513
|
+
card.add(label("→ causality: a > b and not b > a ← inverse "
|
|
514
|
+
"‖ parallel: a > b and b > a # choice: neither", "muted", wrap=True))
|
|
515
|
+
card.add(table, 1)
|
|
516
|
+
start_end = (f"Start activities: {', '.join(sorted(fp.start))} "
|
|
517
|
+
f"End activities: {', '.join(sorted(fp.end))}")
|
|
518
|
+
card.add(label(start_end, "muted", wrap=True, selectable=True))
|
|
519
|
+
card.body.addStretch(0)
|
|
520
|
+
self.conceal_card(card, "footprint")
|
|
521
|
+
layout.addWidget(card, 1)
|
|
522
|
+
|
|
523
|
+
# -------------------------------------------------------------- discover
|
|
524
|
+
def _build_discover(self, layout) -> None:
|
|
525
|
+
algorithms = [
|
|
526
|
+
("im", "Inductive Miner", "Divide-and-conquer on the DFG. Always sound, always fits "
|
|
527
|
+
"the log perfectly."),
|
|
528
|
+
("imf", "Inductive Miner – infrequent", "IM with a noise filter: simpler models, "
|
|
529
|
+
"fitness may drop slightly."),
|
|
530
|
+
("alpha", "α-algorithm", "Classic footprint-based miner. Shows the eight steps; "
|
|
531
|
+
"cannot handle short loops, noise or silent steps."),
|
|
532
|
+
("heuristics", "Heuristics Miner",
|
|
533
|
+
"Frequency-based dependency measures, robust to noise. Produces a dependency graph."),
|
|
534
|
+
("heuristics_net", "Heuristics Miner → Petri net",
|
|
535
|
+
"The same, plus which forks are AND or XOR (learned from the log as a causal "
|
|
536
|
+
"net), as a Petri net. Fits well; not always sound."),
|
|
537
|
+
("regions", "State-based regions",
|
|
538
|
+
"Two-phase: the log becomes a transition system (choose the state function), "
|
|
539
|
+
"whose minimal regions become places. Shows the regions step by step."),
|
|
540
|
+
]
|
|
541
|
+
if pm4py_bridge.available():
|
|
542
|
+
algorithms += [
|
|
543
|
+
("pm_heuristics", "Heuristics → Petri net (PM4Py)",
|
|
544
|
+
"Full heuristics net with split/join semantics, converted by PM4Py."),
|
|
545
|
+
("pm_ilp", "ILP Miner (PM4Py)", "Region-based miner via integer programming."),
|
|
546
|
+
]
|
|
547
|
+
names = {key: name for key, name, _ in algorithms}
|
|
548
|
+
descriptions = {key: text for key, _, text in algorithms}
|
|
549
|
+
|
|
550
|
+
# --- toolbar: algorithm, its parameter, and the way out to a model.
|
|
551
|
+
# A compact bar rather than a sidebar, so the model and its derivation
|
|
552
|
+
# get the whole width.
|
|
553
|
+
chooser = QComboBox()
|
|
554
|
+
for key, name, _ in algorithms:
|
|
555
|
+
chooser.addItem(name, key)
|
|
556
|
+
chooser.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
557
|
+
|
|
558
|
+
def slider_row(text: str, value: int, tooltip: str):
|
|
559
|
+
slider = QSlider(Qt.Horizontal)
|
|
560
|
+
slider.setRange(0, 100)
|
|
561
|
+
slider.setValue(value)
|
|
562
|
+
slider.setFixedWidth(160)
|
|
563
|
+
# Rediscover when the slider is let go, not at every step of a drag.
|
|
564
|
+
slider.setTracking(False)
|
|
565
|
+
shown = label(f"{value / 100:.2f}", "muted")
|
|
566
|
+
slider.sliderMoved.connect(lambda v: shown.setText(f"{v / 100:.2f}"))
|
|
567
|
+
slider.valueChanged.connect(lambda v: shown.setText(f"{v / 100:.2f}"))
|
|
568
|
+
row = QWidget()
|
|
569
|
+
row.setLayout(hbox(label(text), slider, shown))
|
|
570
|
+
row.setToolTip(tooltip)
|
|
571
|
+
return slider, row
|
|
572
|
+
|
|
573
|
+
noise, noise_row = slider_row(
|
|
574
|
+
"Noise threshold f", 20,
|
|
575
|
+
"Edges weaker than f × the strongest outgoing edge are ignored when no cut is found.")
|
|
576
|
+
dependency, dependency_row = slider_row(
|
|
577
|
+
"Dependency threshold", 50,
|
|
578
|
+
"Keep a → b when (|a>b| − |b>a|) / (|a>b| + |b>a| + 1) reaches this value.")
|
|
579
|
+
|
|
580
|
+
# State-based regions: the state function (see transition_system.py).
|
|
581
|
+
direction_box = QComboBox()
|
|
582
|
+
for text, value in (("prefix", "prefix"), ("postfix", "postfix"),
|
|
583
|
+
("prefix + postfix", "both")):
|
|
584
|
+
direction_box.addItem(text, value)
|
|
585
|
+
direction_box.setToolTip("Which events decide the state: those before the event "
|
|
586
|
+
"(prefix), after it (postfix), or both")
|
|
587
|
+
representation_box = QComboBox()
|
|
588
|
+
for value in ("set", "multiset", "sequence"):
|
|
589
|
+
representation_box.addItem(value, value)
|
|
590
|
+
representation_box.setToolTip("How those events become a state: their set (order and "
|
|
591
|
+
"frequency forgotten), multiset (order forgotten) or "
|
|
592
|
+
"sequence")
|
|
593
|
+
horizon_spin = QSpinBox()
|
|
594
|
+
horizon_spin.setRange(0, 50)
|
|
595
|
+
horizon_spin.setSpecialValueText("all")
|
|
596
|
+
horizon_spin.setToolTip("Horizon k: keep only the last k events (all: no limit)")
|
|
597
|
+
region_row = QWidget()
|
|
598
|
+
region_row.setLayout(hbox(label("State function"), representation_box,
|
|
599
|
+
label("of the"), direction_box, label("over the last"),
|
|
600
|
+
horizon_spin, label("events"), None, spacing=6))
|
|
601
|
+
self.region_controls = (direction_box, representation_box, horizon_spin)
|
|
602
|
+
|
|
603
|
+
open_button = button("Open as model →", kind="primary")
|
|
604
|
+
open_button.setEnabled(False)
|
|
605
|
+
workflow_button = button("As a workflow ⧉", tooltip="The same discovery as boxes on a canvas: "
|
|
606
|
+
"this log, the algorithm with these settings, and a fitness check")
|
|
607
|
+
toolbar = Card()
|
|
608
|
+
toolbar.body.addLayout(hbox(label("Algorithm"), chooser, 16, noise_row, dependency_row,
|
|
609
|
+
None, workflow_button, open_button, spacing=10))
|
|
610
|
+
# On a line of its own: three choices do not fit beside the algorithm.
|
|
611
|
+
toolbar.add(region_row)
|
|
612
|
+
description = label("", "muted", wrap=True)
|
|
613
|
+
toolbar.add(description)
|
|
614
|
+
|
|
615
|
+
def current_key() -> str:
|
|
616
|
+
return chooser.currentData()
|
|
617
|
+
|
|
618
|
+
def update_params() -> None:
|
|
619
|
+
key = current_key()
|
|
620
|
+
noise_row.setVisible(key == "imf")
|
|
621
|
+
dependency_row.setVisible(key in ("heuristics", "heuristics_net", "pm_heuristics"))
|
|
622
|
+
region_row.setVisible(key == "regions")
|
|
623
|
+
description.setText(descriptions[key])
|
|
624
|
+
update_params()
|
|
625
|
+
|
|
626
|
+
# --- result: the model beside how it was derived
|
|
627
|
+
result_card = Card("Model", " ")
|
|
628
|
+
caption = result_card.caption_label
|
|
629
|
+
preview = GraphView()
|
|
630
|
+
preview.setMinimumHeight(140)
|
|
631
|
+
# Fill the space there is, as the editors do: asked for the whole net's
|
|
632
|
+
# height instead, the page grew past the window and had to be scrolled.
|
|
633
|
+
preview.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
|
|
634
|
+
result_card.add(preview, 1)
|
|
635
|
+
derivation_body = QWidget()
|
|
636
|
+
steps_host = QVBoxLayout(derivation_body)
|
|
637
|
+
steps_host.setContentsMargins(0, 0, 0, 0)
|
|
638
|
+
derivation = Card("How it was derived")
|
|
639
|
+
steps = scroll(derivation_body)
|
|
640
|
+
derivation.add(steps, 1)
|
|
641
|
+
derivation.setMinimumWidth(260)
|
|
642
|
+
results = QSplitter(Qt.Horizontal)
|
|
643
|
+
results.addWidget(result_card)
|
|
644
|
+
results.addWidget(derivation)
|
|
645
|
+
results.setStretchFactor(0, 3)
|
|
646
|
+
results.setStretchFactor(1, 2)
|
|
647
|
+
results.setSizes([600, 400])
|
|
648
|
+
results.setHandleWidth(14)
|
|
649
|
+
results.setStyleSheet("QSplitter::handle { background: transparent; }")
|
|
650
|
+
|
|
651
|
+
# Faded while a new model is on its way, so the old one reads as stale.
|
|
652
|
+
fades = []
|
|
653
|
+
for widget in (preview, steps):
|
|
654
|
+
fades.append(QGraphicsOpacityEffect(widget))
|
|
655
|
+
fades[-1].setEnabled(False)
|
|
656
|
+
widget.setGraphicsEffect(fades[-1])
|
|
657
|
+
|
|
658
|
+
def fade(opacity: float) -> None:
|
|
659
|
+
for effect in fades:
|
|
660
|
+
effect.setOpacity(opacity)
|
|
661
|
+
# Off when opaque: the views then paint exactly as elsewhere.
|
|
662
|
+
effect.setEnabled(opacity < 1.0)
|
|
663
|
+
|
|
664
|
+
# Only the newest request's result is shown, so quickly switching
|
|
665
|
+
# algorithms cannot leave an older model on screen.
|
|
666
|
+
state: dict = {"request": 0}
|
|
667
|
+
|
|
668
|
+
def clear_steps() -> None:
|
|
669
|
+
while steps_host.count():
|
|
670
|
+
item = steps_host.takeAt(0)
|
|
671
|
+
if item.widget():
|
|
672
|
+
item.widget().hide()
|
|
673
|
+
item.widget().deleteLater()
|
|
674
|
+
|
|
675
|
+
def show(result) -> None:
|
|
676
|
+
request, key, payload = result
|
|
677
|
+
if request != state["request"]:
|
|
678
|
+
return
|
|
679
|
+
fade(1.0)
|
|
680
|
+
clear_steps()
|
|
681
|
+
state["model"] = None
|
|
682
|
+
log_name = self.document.name
|
|
683
|
+
if key == "heuristics":
|
|
684
|
+
nodes, edges = dependency_specs(payload)
|
|
685
|
+
preview.graph.populate(nodes, edges)
|
|
686
|
+
steps_host.addWidget(label(
|
|
687
|
+
"Dependency graph: edge labels are a ⇒ b values. It has no split/join "
|
|
688
|
+
"semantics, so it is not a Petri net — “Heuristics Miner → Petri net” "
|
|
689
|
+
"learns those from the log.", "muted", wrap=True))
|
|
690
|
+
open_button.setEnabled(False)
|
|
691
|
+
size = (f"{plural(len(payload.activities), 'activity', 'activities')} · "
|
|
692
|
+
f"{plural(len(payload.edges), 'dependency', 'dependencies')}")
|
|
693
|
+
elif key == "regions" and payload.net is None:
|
|
694
|
+
# No net (several initial states, or too big): show the transition system.
|
|
695
|
+
from .regions_view import ts_specs
|
|
696
|
+
nodes, edges, _ = ts_specs(payload.ts)
|
|
697
|
+
preview.graph.populate(nodes, edges, layer_gap=90)
|
|
698
|
+
open_button.setEnabled(False)
|
|
699
|
+
steps_host.addWidget(derivation_widget(payload))
|
|
700
|
+
size = f"no net · transition system: {payload.ts.summary()}"
|
|
701
|
+
else:
|
|
702
|
+
net = payload.net if hasattr(payload, "net") else payload
|
|
703
|
+
net.name = f"{net.info.get('algorithm', 'Model')} · {log_name}"
|
|
704
|
+
nodes, edges = petri_net_specs(net, show_place_names=key == "alpha")
|
|
705
|
+
preview.graph.populate(nodes, edges, layer_gap=48)
|
|
706
|
+
state["model"] = ModelDocument(net, origin=f"{net.info.get('algorithm')} on "
|
|
707
|
+
f"“{log_name}”", derivation=payload,
|
|
708
|
+
source_log=self.document)
|
|
709
|
+
open_button.setEnabled(True)
|
|
710
|
+
steps_host.addWidget(derivation_widget(payload))
|
|
711
|
+
silent = sum(t.silent for t in net.transitions.values())
|
|
712
|
+
size = " · ".join(filter(None, [
|
|
713
|
+
plural(len(net.places), "place"),
|
|
714
|
+
plural(len(net.transitions) - silent, "transition"),
|
|
715
|
+
silent and f"{silent} silent",
|
|
716
|
+
plural(len(net.arcs), "arc"),
|
|
717
|
+
]))
|
|
718
|
+
steps_host.addStretch(1)
|
|
719
|
+
preview.fit()
|
|
720
|
+
caption.setText(size)
|
|
721
|
+
|
|
722
|
+
def failed(request: int, key: str, message: str) -> None:
|
|
723
|
+
if request != state["request"]:
|
|
724
|
+
return
|
|
725
|
+
fade(1.0)
|
|
726
|
+
clear_steps()
|
|
727
|
+
preview.graph.populate([], [])
|
|
728
|
+
state["model"] = None
|
|
729
|
+
open_button.setEnabled(False)
|
|
730
|
+
caption.setText(f"{names[key]} found no model")
|
|
731
|
+
steps_host.addWidget(Verdict("Discovery failed", "critical", message))
|
|
732
|
+
steps_host.addStretch(1)
|
|
733
|
+
|
|
734
|
+
def discover() -> None:
|
|
735
|
+
key = current_key()
|
|
736
|
+
simple = self.document.simple_log()
|
|
737
|
+
f, d = noise.value() / 100, dependency.value() / 100
|
|
738
|
+
functions = {
|
|
739
|
+
"alpha": lambda: alpha_miner(simple),
|
|
740
|
+
"im": lambda: inductive_miner(simple),
|
|
741
|
+
"imf": lambda: inductive_miner(simple, noise_threshold=f),
|
|
742
|
+
"heuristics": lambda: heuristics_miner(simple, dependency_threshold=d),
|
|
743
|
+
"heuristics_net": lambda: heuristics_net(simple, dependency_threshold=d),
|
|
744
|
+
"pm_heuristics": lambda: pm4py_bridge.heuristics_petri_net(simple, d),
|
|
745
|
+
"pm_ilp": lambda: pm4py_bridge.ilp_petri_net(simple),
|
|
746
|
+
"regions": lambda: self._regions(simple),
|
|
747
|
+
}
|
|
748
|
+
state["request"] += 1
|
|
749
|
+
request = state["request"]
|
|
750
|
+
# The previous model stays up until the new one arrives.
|
|
751
|
+
open_button.setEnabled(False)
|
|
752
|
+
fade(0.35)
|
|
753
|
+
caption.setText(f"Discovering with {names[key]}…")
|
|
754
|
+
run_in_background(lambda: (request, key, functions[key]()), show,
|
|
755
|
+
lambda message: failed(request, key, message))
|
|
756
|
+
|
|
757
|
+
# Rediscover whenever the choice changes.
|
|
758
|
+
chooser.currentIndexChanged.connect(lambda _i: (update_params(), discover()))
|
|
759
|
+
noise.valueChanged.connect(lambda _v: discover())
|
|
760
|
+
dependency.valueChanged.connect(lambda _v: discover())
|
|
761
|
+
direction_box.currentIndexChanged.connect(lambda _i: discover())
|
|
762
|
+
representation_box.currentIndexChanged.connect(lambda _i: discover())
|
|
763
|
+
horizon_spin.valueChanged.connect(lambda _v: discover())
|
|
764
|
+
open_button.clicked.connect(lambda: state.get("model") and self.open_model.emit(state["model"]))
|
|
765
|
+
|
|
766
|
+
def as_workflow() -> None:
|
|
767
|
+
direction, representation, horizon = self.region_controls
|
|
768
|
+
self.workflow_requested.emit({
|
|
769
|
+
"algorithm": current_key(), "noise": noise.value() / 100, "dependency": dependency.value() / 100,
|
|
770
|
+
"direction": direction.currentData(), "representation": representation.currentData(),
|
|
771
|
+
"horizon": horizon.value() or None})
|
|
772
|
+
workflow_button.clicked.connect(as_workflow)
|
|
773
|
+
chooser.currentIndexChanged.connect(lambda _i: workflow_button.setEnabled(
|
|
774
|
+
not current_key().startswith("pm_")))
|
|
775
|
+
|
|
776
|
+
# Hidden in an exercise until revealed (see concealment.py).
|
|
777
|
+
self.conceal_card(result_card, "discovery")
|
|
778
|
+
self.conceal_card(derivation, "discovery")
|
|
779
|
+
layout.addWidget(toolbar)
|
|
780
|
+
layout.addWidget(results, 1)
|
|
781
|
+
discover()
|
|
782
|
+
|
|
783
|
+
def _regions(self, simple):
|
|
784
|
+
"""State-based regions with the state function chosen in the toolbar."""
|
|
785
|
+
from ...mining.discovery.state_regions import region_result
|
|
786
|
+
from ...mining.transition_system import transition_system_from_log
|
|
787
|
+
direction, representation, horizon = self.region_controls
|
|
788
|
+
ts = transition_system_from_log(simple, direction.currentData(),
|
|
789
|
+
representation.currentData(),
|
|
790
|
+
horizon.value() or None,
|
|
791
|
+
name=f"TS · {self.document.name}")
|
|
792
|
+
return region_result(ts)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def derivation_widget(payload) -> QWidget:
|
|
796
|
+
"""Typeset account of a discovery result (see :mod:`derivation_view`)."""
|
|
797
|
+
from .derivation_view import derivation_view
|
|
798
|
+
# The Discover tab already shows the model, so skip the tree drawing there
|
|
799
|
+
# only when the model *is* the tree (it is not: the tree is a separate view).
|
|
800
|
+
return derivation_view(payload)
|