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,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)