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,143 @@
|
|
|
1
|
+
"""Small dialogs about files and folders.
|
|
2
|
+
|
|
3
|
+
* :class:`ImportDialog` -- a file from outside the open folder is being
|
|
4
|
+
opened: copy it in, move it in, or open it where it is?
|
|
5
|
+
* :func:`ask_about_clash` -- a file with that name is already there: keep
|
|
6
|
+
both, or replace it?
|
|
7
|
+
* :class:`SettingsDialog` -- the app's settings (File ▸ Settings…, or the
|
|
8
|
+
application menu on macOS).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QButtonGroup, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel,
|
|
17
|
+
QMessageBox, QRadioButton, QVBoxLayout, QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .widgets import label, shortcut_text
|
|
21
|
+
|
|
22
|
+
#: What to do with a file opened from outside the folder.
|
|
23
|
+
IMPORT_CHOICES = {
|
|
24
|
+
"copy": "Copy into {folder}",
|
|
25
|
+
"move": "Move into {folder}",
|
|
26
|
+
"open": "Open from where it is",
|
|
27
|
+
}
|
|
28
|
+
IMPORT_DETAILS = {
|
|
29
|
+
"copy": "The original stays where it is.",
|
|
30
|
+
"move": "The file is no longer where it was.",
|
|
31
|
+
"open": "The folder will not have it next time.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ImportDialog(QDialog):
|
|
36
|
+
"""Add files from elsewhere to the open folder?"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, names: list[str], folder: str, parent=None,
|
|
39
|
+
allow_open: bool = True, default: str = "copy") -> None:
|
|
40
|
+
super().__init__(parent)
|
|
41
|
+
self.setWindowTitle("Add to folder")
|
|
42
|
+
self.setMinimumWidth(440)
|
|
43
|
+
layout = QVBoxLayout(self)
|
|
44
|
+
layout.setSpacing(10)
|
|
45
|
+
what = f"“{names[0]}”" if len(names) == 1 else f"{len(names)} files"
|
|
46
|
+
heading = QLabel(f"<b>Add {what} to {folder}?</b>")
|
|
47
|
+
heading.setWordWrap(True)
|
|
48
|
+
layout.addWidget(heading)
|
|
49
|
+
if len(names) > 1:
|
|
50
|
+
listing = ", ".join(names[:5]) + (f" and {len(names) - 5} more" if len(names) > 5
|
|
51
|
+
else "")
|
|
52
|
+
layout.addWidget(label(listing, "muted", wrap=True))
|
|
53
|
+
self.group = QButtonGroup(self)
|
|
54
|
+
self.buttons: dict[str, QRadioButton] = {}
|
|
55
|
+
for key, text in IMPORT_CHOICES.items():
|
|
56
|
+
if key == "open" and not allow_open:
|
|
57
|
+
continue
|
|
58
|
+
radio = QRadioButton(text.format(folder=folder))
|
|
59
|
+
radio.setToolTip(IMPORT_DETAILS[key])
|
|
60
|
+
self.group.addButton(radio)
|
|
61
|
+
self.buttons[key] = radio
|
|
62
|
+
layout.addWidget(radio)
|
|
63
|
+
self.buttons.get(default, self.buttons["copy"]).setChecked(True)
|
|
64
|
+
self.always = QCheckBox(f"Always do this in {folder}")
|
|
65
|
+
layout.addWidget(self.always)
|
|
66
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
67
|
+
buttons.button(QDialogButtonBox.Ok).setText("Add")
|
|
68
|
+
buttons.accepted.connect(self.accept)
|
|
69
|
+
buttons.rejected.connect(self.reject)
|
|
70
|
+
layout.addWidget(buttons)
|
|
71
|
+
|
|
72
|
+
def choice(self) -> str:
|
|
73
|
+
return next(key for key, radio in self.buttons.items() if radio.isChecked())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ask_about_clash(parent: QWidget, target: Path) -> str | None:
|
|
77
|
+
"""``"keep"`` (keep both), ``"replace"``, or None (skip this file)."""
|
|
78
|
+
box = QMessageBox(parent)
|
|
79
|
+
box.setIcon(QMessageBox.Question)
|
|
80
|
+
box.setWindowTitle("A file with that name exists")
|
|
81
|
+
box.setText(f"“{target.name}” is already in {target.parent.name}.")
|
|
82
|
+
box.setInformativeText("Keep both (the new one gets a number), or replace the one "
|
|
83
|
+
"that is there? A replaced file goes to the Bin.")
|
|
84
|
+
keep = box.addButton("Keep Both", QMessageBox.AcceptRole)
|
|
85
|
+
replace = box.addButton("Replace", QMessageBox.DestructiveRole)
|
|
86
|
+
box.addButton("Skip", QMessageBox.RejectRole)
|
|
87
|
+
box.setDefaultButton(keep)
|
|
88
|
+
box.exec()
|
|
89
|
+
clicked = box.clickedButton()
|
|
90
|
+
return "keep" if clicked is keep else "replace" if clicked is replace else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SettingsDialog(QDialog):
|
|
94
|
+
"""The few settings the app has."""
|
|
95
|
+
|
|
96
|
+
IMPORT_LABELS = {"ask": "Ask each time", "copy": "Copy it into the folder",
|
|
97
|
+
"move": "Move it into the folder", "open": "Open it from where it is"}
|
|
98
|
+
|
|
99
|
+
def __init__(self, values: dict, parent=None) -> None:
|
|
100
|
+
super().__init__(parent)
|
|
101
|
+
self.setWindowTitle("Settings")
|
|
102
|
+
self.setMinimumWidth(480)
|
|
103
|
+
layout = QVBoxLayout(self)
|
|
104
|
+
layout.setSpacing(12)
|
|
105
|
+
form = QFormLayout()
|
|
106
|
+
form.setVerticalSpacing(10)
|
|
107
|
+
|
|
108
|
+
self.autosave = QCheckBox("Save edits automatically")
|
|
109
|
+
self.autosave.setChecked(values["autosave"])
|
|
110
|
+
form.addRow("Nets in a folder", self.autosave)
|
|
111
|
+
form.addRow("", label("A second after you stop editing, and when you switch to "
|
|
112
|
+
"another file or quit. Only for files in the open folder; "
|
|
113
|
+
".cpn files from CPN Tools are saved when you press "
|
|
114
|
+
f"{shortcut_text('Ctrl+S')}.",
|
|
115
|
+
"muted", wrap=True))
|
|
116
|
+
|
|
117
|
+
self.import_box = QComboBox()
|
|
118
|
+
for key, text in self.IMPORT_LABELS.items():
|
|
119
|
+
self.import_box.addItem(text, key)
|
|
120
|
+
self.import_box.setCurrentIndex(max(0, self.import_box.findData(values["import"])))
|
|
121
|
+
form.addRow("Opening a file from\noutside the folder", self.import_box)
|
|
122
|
+
form.addRow("", label("A choice made for one folder (“Always do this”) takes "
|
|
123
|
+
"precedence.", "muted", wrap=True))
|
|
124
|
+
|
|
125
|
+
self.restore = QCheckBox("Reopen the folder and files at launch")
|
|
126
|
+
self.restore.setChecked(values["restore"])
|
|
127
|
+
form.addRow("Launch", self.restore)
|
|
128
|
+
|
|
129
|
+
self.updates = QCheckBox("Check for a new version when OpenProcess opens")
|
|
130
|
+
self.updates.setChecked(values["check_updates"])
|
|
131
|
+
form.addRow("Updates", self.updates)
|
|
132
|
+
layout.addLayout(form)
|
|
133
|
+
|
|
134
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
135
|
+
buttons.accepted.connect(self.accept)
|
|
136
|
+
buttons.rejected.connect(self.reject)
|
|
137
|
+
layout.addWidget(buttons)
|
|
138
|
+
|
|
139
|
+
def values(self) -> dict:
|
|
140
|
+
return {"autosave": self.autosave.isChecked(),
|
|
141
|
+
"import": self.import_box.currentData(),
|
|
142
|
+
"restore": self.restore.isChecked(),
|
|
143
|
+
"check_updates": self.updates.isChecked()}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The Filter… dialog of a log page: ProM's and Disco's log filters in one form.
|
|
2
|
+
|
|
3
|
+
Each filter is a group box that is switched off until you tick it; the line
|
|
4
|
+
at the bottom previews the result as you change anything. Accepting opens
|
|
5
|
+
the filtered log as a new document next to the original (see
|
|
6
|
+
:mod:`openprocess.mining.filtering` for what each filter does).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from datetime import timezone
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import QDateTime, Qt, QTimer, QTimeZone
|
|
15
|
+
from PySide6.QtWidgets import (
|
|
16
|
+
QButtonGroup, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QDoubleSpinBox,
|
|
17
|
+
QFormLayout, QGroupBox, QLineEdit, QListWidget, QListWidgetItem, QRadioButton, QSpinBox,
|
|
18
|
+
QVBoxLayout, QWidget,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ...mining.filtering import FilterSettings, apply_filters
|
|
22
|
+
from ...mining.stats import summarise
|
|
23
|
+
from .widgets import button, hbox, label, scroll
|
|
24
|
+
|
|
25
|
+
ACTIVITY_MODE_TEXT = [
|
|
26
|
+
("keep events", "Keep only the events of the ticked activities"),
|
|
27
|
+
("mandatory", "Keep cases that contain a ticked activity"),
|
|
28
|
+
("forbidden", "Remove cases that contain a ticked activity"),
|
|
29
|
+
]
|
|
30
|
+
TIME_MODE_TEXT = [("contained", "Cases that start and end in the period"),
|
|
31
|
+
("started", "Cases that start in the period"),
|
|
32
|
+
("intersecting", "Cases active at some point in the period")]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _checklist(counts: Counter, total: int) -> QListWidget:
|
|
36
|
+
"""Ticked items, most frequent first, each with its share of the cases."""
|
|
37
|
+
widget = QListWidget()
|
|
38
|
+
for name, count in counts.most_common():
|
|
39
|
+
item = QListWidgetItem(f"{name} ({count:,} · {count / max(total, 1):.0%})")
|
|
40
|
+
item.setData(Qt.UserRole, name)
|
|
41
|
+
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
|
42
|
+
item.setCheckState(Qt.Checked)
|
|
43
|
+
widget.addItem(item)
|
|
44
|
+
widget.setMinimumHeight(min(60 + 22 * len(counts), 180))
|
|
45
|
+
return widget
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ticked(widget: QListWidget) -> set[str]:
|
|
49
|
+
return {widget.item(i).data(Qt.UserRole) for i in range(widget.count())
|
|
50
|
+
if widget.item(i).checkState() == Qt.Checked}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _set_all(widget: QListWidget, checked: bool) -> None:
|
|
54
|
+
for i in range(widget.count()):
|
|
55
|
+
widget.item(i).setCheckState(Qt.Checked if checked else Qt.Unchecked)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FilterDialog(QDialog):
|
|
59
|
+
"""Choose filters for ``document``'s log; :meth:`result` is the new log."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, document, parent=None) -> None:
|
|
62
|
+
super().__init__(parent)
|
|
63
|
+
self.document = document
|
|
64
|
+
self.log = document.log
|
|
65
|
+
self.classifier = document.classifier
|
|
66
|
+
self.setWindowTitle(f"Filter {document.name}")
|
|
67
|
+
self.setMinimumWidth(560)
|
|
68
|
+
sequences = self.log.sequences(self.classifier)
|
|
69
|
+
cases = len(sequences)
|
|
70
|
+
self._timer = QTimer(self, singleShot=True, interval=150)
|
|
71
|
+
self._timer.timeout.connect(self._preview)
|
|
72
|
+
|
|
73
|
+
# -- variants
|
|
74
|
+
self.variants_box = self._group("Variants", "Keep the most frequent behaviour.")
|
|
75
|
+
self.by_coverage = QRadioButton("Most frequent variants covering")
|
|
76
|
+
self.by_count = QRadioButton("The most frequent")
|
|
77
|
+
self.by_coverage.setChecked(True)
|
|
78
|
+
choice = QButtonGroup(self)
|
|
79
|
+
choice.addButton(self.by_coverage)
|
|
80
|
+
choice.addButton(self.by_count)
|
|
81
|
+
self.coverage = QDoubleSpinBox()
|
|
82
|
+
self.coverage.setRange(1, 100)
|
|
83
|
+
self.coverage.setDecimals(0)
|
|
84
|
+
self.coverage.setValue(80)
|
|
85
|
+
self.coverage.setSuffix(" % of the cases")
|
|
86
|
+
self.count = QSpinBox()
|
|
87
|
+
self.count.setRange(1, max(len(set(sequences)), 1))
|
|
88
|
+
self.count.setValue(min(5, self.count.maximum()))
|
|
89
|
+
self.count.setSuffix(" variants")
|
|
90
|
+
self.variants_box.layout().addLayout(hbox(self.by_coverage, self.coverage, None))
|
|
91
|
+
self.variants_box.layout().addLayout(hbox(self.by_count, self.count, None))
|
|
92
|
+
|
|
93
|
+
# -- activities
|
|
94
|
+
activity_cases = Counter(a for s in sequences for a in set(s))
|
|
95
|
+
self.activities_box = self._group("Activities", "Share = the cases the activity "
|
|
96
|
+
"occurs in.")
|
|
97
|
+
self.activity_mode = QComboBox()
|
|
98
|
+
for key, text in ACTIVITY_MODE_TEXT:
|
|
99
|
+
self.activity_mode.addItem(text, key)
|
|
100
|
+
self.activity_list = _checklist(activity_cases, cases)
|
|
101
|
+
self.activities_box.layout().addWidget(self.activity_mode)
|
|
102
|
+
self.activities_box.layout().addWidget(self.activity_list)
|
|
103
|
+
self.activities_box.layout().addLayout(hbox(
|
|
104
|
+
button("All", lambda: _set_all(self.activity_list, True), kind="ghost"),
|
|
105
|
+
button("None", lambda: _set_all(self.activity_list, False), kind="ghost"), None))
|
|
106
|
+
|
|
107
|
+
# -- start and end activities
|
|
108
|
+
self.start_box = self._group("Start activities", "Keep cases that start with a "
|
|
109
|
+
"ticked activity.")
|
|
110
|
+
self.start_list = _checklist(Counter(s[0] for s in sequences if s), cases)
|
|
111
|
+
self.start_box.layout().addWidget(self.start_list)
|
|
112
|
+
self.end_box = self._group("End activities", "Keep cases that end with a ticked "
|
|
113
|
+
"activity, e.g. to drop cases that were still running.")
|
|
114
|
+
self.end_list = _checklist(Counter(s[-1] for s in sequences if s), cases)
|
|
115
|
+
self.end_box.layout().addWidget(self.end_list)
|
|
116
|
+
|
|
117
|
+
# -- case length
|
|
118
|
+
lengths = [len(s) for s in sequences] or [0]
|
|
119
|
+
self.length_box = self._group("Case length", "Number of events per case (after the "
|
|
120
|
+
"activity filter).")
|
|
121
|
+
self.min_length, self.max_length = QSpinBox(), QSpinBox()
|
|
122
|
+
for box, value in ((self.min_length, min(lengths)), (self.max_length, max(lengths))):
|
|
123
|
+
box.setRange(0, 1_000_000)
|
|
124
|
+
box.setValue(value)
|
|
125
|
+
self.length_box.layout().addLayout(hbox(label("From"), self.min_length, label("to"),
|
|
126
|
+
self.max_length, label("events"), None))
|
|
127
|
+
|
|
128
|
+
# -- time frame
|
|
129
|
+
stamps = [e.timestamp for t in self.log for e in t if e.timestamp is not None]
|
|
130
|
+
self.time_box = self._group("Time frame", "Times in UTC.")
|
|
131
|
+
self.time_mode = QComboBox()
|
|
132
|
+
for key, text in TIME_MODE_TEXT:
|
|
133
|
+
self.time_mode.addItem(text, key)
|
|
134
|
+
self.time_from, self.time_to = QDateTimeEdit(), QDateTimeEdit()
|
|
135
|
+
for edit, stamp in ((self.time_from, min(stamps) if stamps else None),
|
|
136
|
+
(self.time_to, max(stamps) if stamps else None)):
|
|
137
|
+
edit.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
|
138
|
+
edit.setCalendarPopup(True)
|
|
139
|
+
if hasattr(edit, "setTimeZone"): # Qt 6.7 and later
|
|
140
|
+
edit.setTimeZone(QTimeZone.utc())
|
|
141
|
+
else:
|
|
142
|
+
edit.setTimeSpec(Qt.UTC)
|
|
143
|
+
if stamp is not None:
|
|
144
|
+
edit.setDateTime(QDateTime.fromSecsSinceEpoch(int(stamp.timestamp()),
|
|
145
|
+
QTimeZone.utc()))
|
|
146
|
+
form = QFormLayout()
|
|
147
|
+
form.addRow("Keep", self.time_mode)
|
|
148
|
+
form.addRow("From", self.time_from)
|
|
149
|
+
form.addRow("To", self.time_to)
|
|
150
|
+
self.time_box.layout().addLayout(form)
|
|
151
|
+
if not stamps:
|
|
152
|
+
self.time_box.setEnabled(False)
|
|
153
|
+
self.time_box.setTitle("Time frame (this log has no timestamps)")
|
|
154
|
+
|
|
155
|
+
sections = QWidget()
|
|
156
|
+
column = QVBoxLayout(sections)
|
|
157
|
+
column.setContentsMargins(0, 0, 8, 0)
|
|
158
|
+
for box in (self.variants_box, self.activities_box, self.start_box, self.end_box,
|
|
159
|
+
self.length_box, self.time_box):
|
|
160
|
+
column.addWidget(box)
|
|
161
|
+
column.addStretch(1)
|
|
162
|
+
|
|
163
|
+
self.name = QLineEdit(f"{document.name} (filtered)")
|
|
164
|
+
self.preview = label("", "muted", wrap=True)
|
|
165
|
+
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
166
|
+
self.buttons.button(QDialogButtonBox.Ok).setText("Create filtered log")
|
|
167
|
+
self.buttons.accepted.connect(self.accept)
|
|
168
|
+
self.buttons.rejected.connect(self.reject)
|
|
169
|
+
|
|
170
|
+
layout = QVBoxLayout(self)
|
|
171
|
+
layout.addWidget(label("Tick the filters to apply. The result opens as a new log; "
|
|
172
|
+
"this one stays as it is.", "muted", wrap=True))
|
|
173
|
+
layout.addWidget(scroll(sections), 1)
|
|
174
|
+
name_row = QFormLayout()
|
|
175
|
+
name_row.addRow("Name", self.name)
|
|
176
|
+
layout.addLayout(name_row)
|
|
177
|
+
layout.addWidget(self.preview)
|
|
178
|
+
layout.addWidget(self.buttons)
|
|
179
|
+
self.resize(620, 720)
|
|
180
|
+
|
|
181
|
+
# Any change updates the preview (after a short pause).
|
|
182
|
+
for widget in (self.variants_box, self.activities_box, self.start_box, self.end_box,
|
|
183
|
+
self.length_box, self.time_box):
|
|
184
|
+
widget.toggled.connect(self._changed)
|
|
185
|
+
for widget in (self.by_coverage, self.by_count):
|
|
186
|
+
widget.toggled.connect(self._changed)
|
|
187
|
+
for widget in (self.coverage, self.count, self.min_length, self.max_length):
|
|
188
|
+
widget.valueChanged.connect(self._changed)
|
|
189
|
+
for widget in (self.activity_mode, self.time_mode):
|
|
190
|
+
widget.currentIndexChanged.connect(self._changed)
|
|
191
|
+
for widget in (self.activity_list, self.start_list, self.end_list):
|
|
192
|
+
widget.itemChanged.connect(self._changed)
|
|
193
|
+
for widget in (self.time_from, self.time_to):
|
|
194
|
+
widget.dateTimeChanged.connect(self._changed)
|
|
195
|
+
self._result = None
|
|
196
|
+
self._preview()
|
|
197
|
+
|
|
198
|
+
def _group(self, title: str, hint: str) -> QGroupBox:
|
|
199
|
+
box = QGroupBox(title)
|
|
200
|
+
box.setCheckable(True)
|
|
201
|
+
box.setChecked(False)
|
|
202
|
+
layout = QVBoxLayout(box)
|
|
203
|
+
layout.addWidget(label(hint, "muted", wrap=True))
|
|
204
|
+
return box
|
|
205
|
+
|
|
206
|
+
def settings(self) -> FilterSettings:
|
|
207
|
+
"""The filters as currently ticked."""
|
|
208
|
+
settings = FilterSettings()
|
|
209
|
+
if self.variants_box.isChecked():
|
|
210
|
+
if self.by_coverage.isChecked():
|
|
211
|
+
settings.variant_coverage = self.coverage.value()
|
|
212
|
+
else:
|
|
213
|
+
settings.top_variants = self.count.value()
|
|
214
|
+
if self.activities_box.isChecked():
|
|
215
|
+
settings.activities = (_ticked(self.activity_list),
|
|
216
|
+
self.activity_mode.currentData())
|
|
217
|
+
if self.start_box.isChecked():
|
|
218
|
+
settings.start_activities = _ticked(self.start_list)
|
|
219
|
+
if self.end_box.isChecked():
|
|
220
|
+
settings.end_activities = _ticked(self.end_list)
|
|
221
|
+
if self.length_box.isChecked():
|
|
222
|
+
settings.case_length = (self.min_length.value(), self.max_length.value())
|
|
223
|
+
if self.time_box.isChecked() and self.time_box.isEnabled():
|
|
224
|
+
start = self.time_from.dateTime().toPython().replace(tzinfo=timezone.utc)
|
|
225
|
+
end = self.time_to.dateTime().toPython().replace(tzinfo=timezone.utc)
|
|
226
|
+
settings.time_frame = (start, end, self.time_mode.currentData())
|
|
227
|
+
return settings
|
|
228
|
+
|
|
229
|
+
def _changed(self, *_args) -> None:
|
|
230
|
+
self._timer.start()
|
|
231
|
+
|
|
232
|
+
def _preview(self) -> None:
|
|
233
|
+
self._result = apply_filters(self.log, self.settings(), self.classifier,
|
|
234
|
+
name=self.name.text().strip() or None)
|
|
235
|
+
before = summarise(self.log, self.classifier)
|
|
236
|
+
after = summarise(self._result, self.classifier)
|
|
237
|
+
share = after.case_count / max(before.case_count, 1)
|
|
238
|
+
self.preview.setText(
|
|
239
|
+
f"Result: {after.case_count:,} of {before.case_count:,} cases ({share:.0%}) · "
|
|
240
|
+
f"{after.event_count:,} events · {after.variant_count:,} variants · "
|
|
241
|
+
f"{after.activity_count} activities")
|
|
242
|
+
self.buttons.button(QDialogButtonBox.Ok).setEnabled(after.case_count > 0)
|
|
243
|
+
|
|
244
|
+
def result_log(self):
|
|
245
|
+
"""The filtered log (named as typed)."""
|
|
246
|
+
self._preview()
|
|
247
|
+
return self._result
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Turn mining objects into canvas descriptions (:class:`NodeSpec` / :class:`EdgeSpec`).
|
|
2
|
+
|
|
3
|
+
Keeping this separate from the canvas means the drawing code knows nothing
|
|
4
|
+
about Petri nets or logs, and these functions know nothing about Qt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from ...mining.analysis import StateGraph
|
|
12
|
+
from ...mining.dfg import DFG, END, START
|
|
13
|
+
from ...mining.discovery.heuristics import DependencyGraph
|
|
14
|
+
from ...mining.petrinet import Marking, PetriNet
|
|
15
|
+
from ...mining.stats import format_duration
|
|
16
|
+
from . import style
|
|
17
|
+
from .graph_view import EdgeSpec, NodeSpec
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ENABLED_FILL = "#dff5e4"
|
|
21
|
+
ENABLED_FILL_DARK = "#1e4028"
|
|
22
|
+
ENABLED_STROKE = "#2e9e4f"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def compact(n: float) -> str:
|
|
26
|
+
"""1284 -> 1,284; 12900 -> 12.9K."""
|
|
27
|
+
if n >= 100_000:
|
|
28
|
+
return f"{n / 1000:.0f}K"
|
|
29
|
+
if n >= 10_000:
|
|
30
|
+
return f"{n / 1000:.1f}K"
|
|
31
|
+
return f"{int(n):,}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Petri nets
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
def petri_net_specs(net: PetriNet, marking: Marking | None = None,
|
|
38
|
+
enabled: set[str] | None = None,
|
|
39
|
+
badges: dict[str, list[tuple[str, str]]] | None = None,
|
|
40
|
+
fills: dict[str, str] | None = None,
|
|
41
|
+
show_place_names: bool = True) -> tuple[list[NodeSpec], list[EdgeSpec]]:
|
|
42
|
+
t = style.tokens()
|
|
43
|
+
marking = marking if marking is not None else net.initial_marking
|
|
44
|
+
enabled = enabled or set()
|
|
45
|
+
badges = badges or {}
|
|
46
|
+
fills = fills or {}
|
|
47
|
+
nodes: list[NodeSpec] = []
|
|
48
|
+
for place in net.places.values():
|
|
49
|
+
roles = []
|
|
50
|
+
if net.initial_marking[place.id]:
|
|
51
|
+
roles.append("initial")
|
|
52
|
+
if net.final_marking[place.id]:
|
|
53
|
+
roles.append("final")
|
|
54
|
+
name = place.name
|
|
55
|
+
tip = f"Place {name}" + (f" ({', '.join(roles)})" if roles else "")
|
|
56
|
+
tip += f"\nTokens: {marking[place.id]}"
|
|
57
|
+
nodes.append(NodeSpec(place.id, "place", below=name if show_place_names else "",
|
|
58
|
+
tokens=marking[place.id], badges=badges.get(place.id, []),
|
|
59
|
+
tooltip=tip, fill=fills.get(place.id)))
|
|
60
|
+
for transition in net.transitions.values():
|
|
61
|
+
is_enabled = transition.id in enabled
|
|
62
|
+
tip = ("Silent transition (τ)" if transition.silent else f"Transition {transition.label}")
|
|
63
|
+
if is_enabled:
|
|
64
|
+
tip += "\nEnabled — click to fire"
|
|
65
|
+
# Enabled transitions are green, as in CPN Tools and CPN IDE; a silent
|
|
66
|
+
# transition keeps its solid fill but gets the green outline.
|
|
67
|
+
enabled_fill = ENABLED_FILL_DARK if style.theme.is_dark() else ENABLED_FILL
|
|
68
|
+
fill = fills.get(transition.id)
|
|
69
|
+
if fill is None and is_enabled:
|
|
70
|
+
fill = ENABLED_STROKE if transition.silent else enabled_fill
|
|
71
|
+
nodes.append(NodeSpec(
|
|
72
|
+
transition.id, "silent" if transition.silent else "transition",
|
|
73
|
+
text="" if transition.silent else transition.name,
|
|
74
|
+
fill=fill, stroke=ENABLED_STROKE if is_enabled else None, emphasis=is_enabled,
|
|
75
|
+
badges=badges.get(transition.id, []), tooltip=tip))
|
|
76
|
+
edges = [EdgeSpec(a.source, a.target, text=str(a.weight) if a.weight != 1 else "",
|
|
77
|
+
width=1.4) for a in net.arcs]
|
|
78
|
+
return nodes, edges
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Process maps
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
def dfg_specs(dfg: DFG, mode: str = "frequency") -> tuple[list[NodeSpec], list[EdgeSpec]]:
|
|
85
|
+
"""Frequency mode: node fill = how often; edge width = how often.
|
|
86
|
+
Performance mode: edge labels are mean durations; the slowest edges are
|
|
87
|
+
drawn thickest, in the accent-free critical tone with a text label."""
|
|
88
|
+
t = style.tokens()
|
|
89
|
+
most = max(dfg.activities.values(), default=1)
|
|
90
|
+
nodes = [NodeSpec(START, "start", tooltip=f"Start — {sum(dfg.start.values()):,} cases"),
|
|
91
|
+
NodeSpec(END, "end", tooltip=f"End — {sum(dfg.end.values()):,} cases")]
|
|
92
|
+
for activity, count in dfg.activities.items():
|
|
93
|
+
fraction = math.log1p(count) / math.log1p(most) if most else 0
|
|
94
|
+
fill = style.sequential(0.1 + 0.8 * fraction)
|
|
95
|
+
nodes.append(NodeSpec(activity, "activity", text=activity, subtext=compact(count),
|
|
96
|
+
fill=fill, stroke=style.SEQUENTIAL_BLUE[-3],
|
|
97
|
+
tooltip=f"{activity}\n{count:,} events"))
|
|
98
|
+
|
|
99
|
+
all_edges = dfg.all_edges()
|
|
100
|
+
top = max((n for _, _, n in all_edges), default=1)
|
|
101
|
+
durations = {edge: dfg.mean_duration(edge) for edge in dfg.edges}
|
|
102
|
+
slowest = max((d for d in durations.values() if d is not None), default=0)
|
|
103
|
+
edges: list[EdgeSpec] = []
|
|
104
|
+
for a, b, count in all_edges:
|
|
105
|
+
weight = count / top if top else 0
|
|
106
|
+
if mode == "performance" and (a, b) in dfg.edges:
|
|
107
|
+
mean = durations.get((a, b))
|
|
108
|
+
relative = (mean / slowest) if (mean is not None and slowest) else 0
|
|
109
|
+
edges.append(EdgeSpec(
|
|
110
|
+
a, b, text=format_duration(mean) if mean is not None else "",
|
|
111
|
+
width=1.2 + 4.5 * relative,
|
|
112
|
+
colour=style.STATUS["critical"] if relative > 0.66 else t.text_secondary,
|
|
113
|
+
tooltip=f"{a} → {b}\nmean {format_duration(mean)} · median "
|
|
114
|
+
f"{format_duration(dfg.median_duration((a, b)))} · {count:,}×"))
|
|
115
|
+
else:
|
|
116
|
+
edges.append(EdgeSpec(a, b, text=compact(count) if mode == "frequency" else "",
|
|
117
|
+
width=1.0 + 4.5 * weight,
|
|
118
|
+
colour=t.text_secondary if weight > 0.15 else t.text_muted,
|
|
119
|
+
dashed=a == START or b == END,
|
|
120
|
+
tooltip=f"{a} → {b}: {count:,} times"))
|
|
121
|
+
return nodes, edges
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dependency_specs(graph: DependencyGraph) -> tuple[list[NodeSpec], list[EdgeSpec]]:
|
|
125
|
+
t = style.tokens()
|
|
126
|
+
most = max(graph.activities.values(), default=1)
|
|
127
|
+
nodes = [NodeSpec(START, "start"), NodeSpec(END, "end")]
|
|
128
|
+
for activity, count in graph.activities.items():
|
|
129
|
+
fraction = math.log1p(count) / math.log1p(most)
|
|
130
|
+
nodes.append(NodeSpec(activity, "activity", text=activity, subtext=compact(count),
|
|
131
|
+
fill=style.sequential(0.1 + 0.8 * fraction),
|
|
132
|
+
stroke=style.SEQUENTIAL_BLUE[-3]))
|
|
133
|
+
edges = [EdgeSpec(START, a, dashed=True, colour=t.text_muted) for a in graph.start]
|
|
134
|
+
edges += [EdgeSpec(a, END, dashed=True, colour=t.text_muted) for a in graph.end]
|
|
135
|
+
for (a, b), (value, count) in graph.edges.items():
|
|
136
|
+
edges.append(EdgeSpec(a, b, text=f"{value:.2f}", width=1.0 + 3 * max(value, 0),
|
|
137
|
+
colour=t.text_secondary,
|
|
138
|
+
tooltip=f"{a} ⇒ {b} = {value:.3f} (|{a} > {b}| = {count})"))
|
|
139
|
+
return nodes, edges
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# State spaces
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
def state_graph_specs(graph: StateGraph, limit: int = 250) -> tuple[list[NodeSpec], list[EdgeSpec]]:
|
|
146
|
+
t = style.tokens()
|
|
147
|
+
net = graph.net
|
|
148
|
+
shown = min(len(graph.states), limit)
|
|
149
|
+
dead = set(graph.dead_states())
|
|
150
|
+
nodes = []
|
|
151
|
+
for index in range(shown):
|
|
152
|
+
marking = graph.states[index]
|
|
153
|
+
is_final = marking == net.final_marking and bool(net.final_marking)
|
|
154
|
+
tip = f"State {index}: {marking.describe(net)}"
|
|
155
|
+
if index == 0:
|
|
156
|
+
tip += "\nInitial marking"
|
|
157
|
+
if index in dead:
|
|
158
|
+
tip += "\nDead: nothing is enabled" + (" (final marking)" if is_final else "")
|
|
159
|
+
stroke = None
|
|
160
|
+
if index in dead and not is_final:
|
|
161
|
+
stroke = style.STATUS["critical"]
|
|
162
|
+
nodes.append(NodeSpec(str(index), "state", text=marking.describe(net), tooltip=tip,
|
|
163
|
+
emphasis=index == 0 or is_final, stroke=stroke,
|
|
164
|
+
fill=t.accent_soft if index == 0 else None))
|
|
165
|
+
# Several transitions can lead from the same state to the same state
|
|
166
|
+
# (e.g. a choice between three seats that all end in [sink]); draw them
|
|
167
|
+
# as one edge carrying all labels instead of stacking identical curves.
|
|
168
|
+
labels: dict[tuple[int, int], list[str]] = {}
|
|
169
|
+
for s, tr, d in graph.edges:
|
|
170
|
+
if s < shown and d < shown:
|
|
171
|
+
labels.setdefault((s, d), []).append(net.transitions[tr].name)
|
|
172
|
+
edges = [EdgeSpec(str(s), str(d), width=1.2,
|
|
173
|
+
text=names[0] if len(names) == 1 else f"{names[0]} +{len(names) - 1}",
|
|
174
|
+
tooltip="\n".join(names))
|
|
175
|
+
for (s, d), names in labels.items()]
|
|
176
|
+
return nodes, edges
|