openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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