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,760 @@
1
+ """The model workspace: a Petri net with analysis and conformance checking.
2
+
3
+ Left: the canvas, with two views --
4
+
5
+ * **Model** -- the net itself. With *Token game* on, enabled transitions
6
+ are highlighted and clicking one fires it, so you can play out the firing
7
+ rule by hand exactly as in the lectures;
8
+ * **State space** -- the reachability graph (or the coverability graph with
9
+ ω when the net is unbounded).
10
+
11
+ Right: an inspector with three tabs --
12
+
13
+ * **Analysis** -- WF-net structure, soundness with counterexamples, and the
14
+ classic behavioural properties;
15
+ * **Conformance** -- replay an open log: fitness (token replay and
16
+ alignments), precision, generalisation, simplicity, per-variant alignments,
17
+ and deviations painted onto the model;
18
+ * **Details** -- where the model came from, including the α-algorithm's
19
+ derivation or the Inductive Miner's process tree.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from PySide6.QtCore import QRectF, Qt, QTimer, Signal
25
+ from PySide6.QtGui import QPainter, QStandardItemModel
26
+ from PySide6.QtWidgets import (
27
+ QCheckBox, QComboBox, QFileDialog, QSlider, QGridLayout, QHeaderView, QStackedWidget, QToolButton, QVBoxLayout,
28
+ QWidget, QSplitter,
29
+ )
30
+
31
+ from ...mining.analysis import (analyse, check_soundness, coverability_graph, reachability_graph,
32
+ short_circuit)
33
+ from ...mining.conformance.alignments import align_log
34
+ from ...mining.conformance.quality import generalisation, precision, simplicity
35
+ from ...mining.conformance.token_replay import token_replay
36
+ from ...mining.petrinet import Marking
37
+ from ...mining.pnml import write_pnml
38
+ from .. import theme
39
+ from . import instances, style
40
+ from ..learn.concealment import ConcealsResults
41
+ from .documents import ModelDocument
42
+ from .graph_builders import petri_net_specs, state_graph_specs
43
+ from .graph_view import GraphView
44
+ from .log_page import _item, _table, derivation_widget
45
+ from .widgets import (
46
+ suggested_path,
47
+ Card, ElidedLabel, PageHeader, SegmentedControl, StatTile, Verdict, button, fitness_status, flow, hbox, label,
48
+ paint_chips, scroll, status_for, vbox,
49
+ )
50
+ from .workers import run_in_background
51
+
52
+
53
+ def _tool(text: str, tooltip: str, checkable: bool = False) -> QToolButton:
54
+ tool = QToolButton()
55
+ tool.setObjectName("canvasTool")
56
+ tool.setText(text)
57
+ tool.setToolTip(tooltip)
58
+ tool.setCheckable(checkable)
59
+ tool.setCursor(Qt.PointingHandCursor)
60
+ return tool
61
+
62
+
63
+ class AlignmentStrip(QWidget):
64
+ """One alignment as a row of chips: sync, log move (a ≫), model move (≫ a)."""
65
+
66
+ def __init__(self) -> None:
67
+ super().__init__()
68
+ self.moves = []
69
+ self.setMinimumHeight(34)
70
+
71
+ def set_moves(self, moves) -> None:
72
+ self.moves = [m for m in moves if m.kind != "silent"]
73
+ self.update()
74
+
75
+ def paintEvent(self, _event) -> None: # noqa: N802
76
+ t = style.tokens()
77
+ painter = QPainter(self)
78
+ painter.setRenderHint(QPainter.Antialiasing)
79
+ chips = []
80
+ for move in self.moves:
81
+ if move.kind == "sync":
82
+ chips.append((move.log, t.surface_alt, t.border))
83
+ elif move.kind == "log":
84
+ chips.append((f"{move.log} ≫", style.categorical(1), None))
85
+ else:
86
+ chips.append((f"≫ {move.model}", style.categorical(6), None))
87
+ font = painter.font()
88
+ theme.set_px(font, 10.5)
89
+ paint_chips(painter, QRectF(self.rect()).adjusted(0, 4, 0, -4), chips, font, 140)
90
+
91
+
92
+ class ModelPage(ConcealsResults, QWidget):
93
+ status = Signal(str)
94
+ #: A play-out produced a new event log (the window adds it to the sidebar).
95
+ log_generated = Signal(object)
96
+ #: Emitted after the model was exported as PNML; it now lives in that file.
97
+ saved = Signal()
98
+ #: "Edit" was pressed: open this net (a copy) in the Petri net editor.
99
+ edit_requested = Signal(object)
100
+ #: "Keep" was pressed: save this discovered model into the open folder.
101
+ keep_requested = Signal()
102
+ #: "As a workflow" on the Conformance tab, with the chosen log document.
103
+ workflow_requested = Signal(object)
104
+
105
+ def __init__(self, document: ModelDocument, open_logs, parent=None) -> None:
106
+ """``open_logs`` is a callable returning the currently open LogDocuments."""
107
+ super().__init__(parent)
108
+ self.document = document
109
+ self.net = document.net
110
+ self.open_logs = open_logs
111
+ self.marking: Marking = self.net.initial_marking
112
+ self.overlay_badges: dict = {}
113
+ self.conformance = None
114
+
115
+ root = QVBoxLayout(self)
116
+ root.setContentsMargins(0, 0, 0, 0)
117
+ root.setSpacing(0)
118
+ self.header = PageHeader(self.net.name, self._subtitle())
119
+ # Shown by the window while a discovered model is not in the open folder yet.
120
+ self.keep_button = button("Keep", self.keep_requested.emit, kind="primary",
121
+ tooltip="Save this model into the folder as a PNML file")
122
+ self.keep_button.setVisible(False)
123
+ self.header.actions.addWidget(self.keep_button)
124
+ self.header.actions.addWidget(button("✎ Edit a copy", lambda: self.edit_requested.emit(
125
+ self.net), tooltip="Open this net in the Petri net editor to change it"))
126
+ self.header.actions.addWidget(button("Export PNML…", self.export))
127
+ self.header.actions.addWidget(button("Export image…", self._export_image))
128
+ root.addWidget(self.header)
129
+
130
+ splitter = QSplitter()
131
+ splitter.setHandleWidth(14)
132
+ splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
133
+ splitter.addWidget(self._build_canvas())
134
+ splitter.addWidget(self._build_inspector())
135
+ splitter.setStretchFactor(0, 1)
136
+ splitter.setSizes([880, 430])
137
+ wrapper = QWidget()
138
+ wrapper.setLayout(vbox(splitter, margins=(20, 0, 20, 16)))
139
+ root.addWidget(wrapper, 1)
140
+ self.redraw(fit=True)
141
+
142
+ def _subtitle(self) -> str:
143
+ return f"{self.document.origin or 'Petri net'} · {self.net.summary()}"
144
+
145
+ # ================================================================ canvas
146
+ MODES = ["View", "Step through", "Simulate"]
147
+
148
+ def _build_canvas(self) -> QWidget:
149
+ card = Card()
150
+ self.view_switch = SegmentedControl(["Model", "State space"])
151
+ self.mode_switch = SegmentedControl(self.MODES)
152
+ self.mode_switch.setToolTip("View: the model with analysis overlays\n"
153
+ "Step through: fire transitions one at a time\n"
154
+ "Simulate: let the model run by itself")
155
+ self.place_names = _tool("Labels", "Show place names", checkable=True)
156
+ self.place_names.setChecked(len(self.net.places) <= 40 and self.net.info.get(
157
+ "algorithm", "").startswith("α"))
158
+ self.sim_status = ElidedLabel("", "muted")
159
+ top = hbox(self.view_switch, 12, self.mode_switch, 12, self.sim_status, self.place_names)
160
+ top.setStretch(4, 1)
161
+ card.add(top)
162
+
163
+ # -- simulation bar (hidden in View mode) ------------------------------------
164
+ self.back_button = _tool("◀ Back", "Undo the last firing")
165
+ self.step_button = _tool("Step ▶", "Fire one random enabled transition")
166
+ self.play_button = _tool("▶ Play", "Fire random enabled transitions automatically",
167
+ checkable=True)
168
+ self.reset_button = _tool("⟲ Reset", "Back to the initial marking")
169
+ self.repeat_box = QCheckBox("Repeat")
170
+ self.repeat_box.setToolTip("When a run ends, start a new one automatically")
171
+ self.repeat_box.setChecked(True)
172
+ self.speed = QSlider(Qt.Horizontal)
173
+ self.speed.setRange(1, 30) # firings per second
174
+ self.speed.setValue(4)
175
+ self.speed.setFixedWidth(110)
176
+ self.speed.setToolTip("Speed: firings per second")
177
+ self.generate_button = _tool("Generate event log…", "Play the model out many times and "
178
+ "add the resulting event log to the sidebar")
179
+ self.sim_hint = label("", "muted", wrap=True)
180
+ self.simulate_widgets = [self.play_button, self.speed, self.speed_label_widget(),
181
+ self.repeat_box, self.generate_button]
182
+ # A flow layout: on a narrow window the bar wraps instead of widening the page.
183
+ bar = flow(self.reset_button, self.back_button, self.step_button, 12,
184
+ *self.simulate_widgets)
185
+ self.sim_bar = bar
186
+ card.add(bar)
187
+ card.add(self.sim_hint)
188
+
189
+ self.canvas_stack = QStackedWidget()
190
+ self.view = GraphView()
191
+ self.state_view = GraphView()
192
+ self.state_info = label("", "muted", wrap=True)
193
+ state_host = QWidget()
194
+ state_host.setLayout(vbox(self.state_info, self.state_view))
195
+ self.canvas_stack.addWidget(self.view)
196
+ self.canvas_stack.addWidget(state_host)
197
+ card.add(self.canvas_stack, 1)
198
+
199
+ self.sequence_label = label("", "muted", wrap=True, selectable=True)
200
+ card.add(self.sequence_label)
201
+
202
+ # -- state of the simulation ------------------------------------------------------
203
+ self.history: list[tuple[Marking, str]] = [] # (marking before, transition fired)
204
+ self.runs_completed = 0
205
+ self.last_fired: str | None = None
206
+ self.timer = QTimer(self)
207
+ self.timer.timeout.connect(self._tick)
208
+
209
+ self.view.graph.node_clicked.connect(self._node_clicked)
210
+ self.mode_switch.changed.connect(self._mode_changed)
211
+ self.back_button.clicked.connect(self._back)
212
+ self.step_button.clicked.connect(self._step_random)
213
+ self.play_button.toggled.connect(self._play_toggled)
214
+ self.reset_button.clicked.connect(self._reset_game)
215
+ self.speed.valueChanged.connect(self._speed_changed)
216
+ self.generate_button.clicked.connect(self._generate_log)
217
+ self.place_names.toggled.connect(lambda _: self.redraw(fit=False))
218
+ self.view_switch.changed.connect(self._switch_view)
219
+ self._mode_changed(0)
220
+ return card
221
+
222
+ def speed_label_widget(self) -> QWidget:
223
+ self.speed_label = label("4 / s", "muted")
224
+ return self.speed_label
225
+
226
+ @property
227
+ def simulating(self) -> bool:
228
+ return self.mode_switch.index() in (1, 2)
229
+
230
+ # -- drawing ---------------------------------------------------------------------------
231
+ def redraw(self, fit: bool = False) -> None:
232
+ simulating = self.simulating
233
+ enabled = set(self.net.enabled(self.marking)) if simulating else set()
234
+ fills: dict[str, str] = {}
235
+ if simulating and self.last_fired:
236
+ fills[self.last_fired] = style.tokens().accent_soft
237
+ nodes, edges = petri_net_specs(
238
+ self.net, marking=self.marking, enabled=enabled,
239
+ badges=self.overlay_badges if not simulating else {},
240
+ fills=fills, show_place_names=self.place_names.isChecked())
241
+ positions = None
242
+ if all(p.position for p in self.net.places.values()) and \
243
+ all(t.position for t in self.net.transitions.values()):
244
+ positions = {p.id: p.position for p in self.net.places.values()}
245
+ positions |= {t.id: t.position for t in self.net.transitions.values()}
246
+ if fit or not self.view.graph.nodes:
247
+ self.view.graph.populate(nodes, edges, positions, layer_gap=48)
248
+ if fit:
249
+ self.view.fit()
250
+ else:
251
+ for spec in nodes:
252
+ self.view.graph.update_node(spec)
253
+ for edge in self.view.graph.edges:
254
+ edge.rebuild()
255
+ self._update_sim_text(enabled)
256
+
257
+ def _update_sim_text(self, enabled: set[str]) -> None:
258
+ if not self.simulating:
259
+ self.sequence_label.setText("")
260
+ self.sim_status.setText("")
261
+ return
262
+ names = [self.net.transitions[t].name for _, t in self.history]
263
+ shown = names[-30:]
264
+ prefix = "… → " if len(names) > 30 else ""
265
+ self.sequence_label.setText(
266
+ f"Marking {self.marking.describe(self.net)} · fired: "
267
+ + (prefix + " → ".join(shown) if names else "nothing yet"))
268
+ at_final = bool(self.net.final_marking) and self.marking == self.net.final_marking
269
+ if at_final:
270
+ state = "✓ final marking reached"
271
+ elif not enabled:
272
+ state = "✕ dead marking: nothing is enabled"
273
+ else:
274
+ state = f"{len(enabled)} enabled"
275
+ runs = f" · {self.runs_completed} run(s) completed" if self.mode_switch.index() == 2 else ""
276
+ self.sim_status.setText(f"Step {len(self.history)} · {state}{runs}")
277
+ self.back_button.setEnabled(bool(self.history))
278
+ self.step_button.setEnabled(bool(enabled))
279
+
280
+ # -- modes ---------------------------------------------------------------------------------
281
+ def _mode_changed(self, index: int) -> None:
282
+ self.timer.stop()
283
+ self.play_button.setChecked(False)
284
+ self.sim_bar.setVisible(index != 0)
285
+ for widget in self.simulate_widgets:
286
+ widget.setVisible(index == 2)
287
+ self.sim_hint.setVisible(index != 0)
288
+ self.sim_hint.setText(
289
+ "Green transitions are enabled. Click one to fire it, or press Step to fire a "
290
+ "random one. Back undoes a firing." if index == 1 else
291
+ "Press Play to let the model fire random enabled transitions by itself. The last "
292
+ "transition that fired is shaded blue; with Repeat on, a new run starts at the "
293
+ "end. Generate event log turns many runs into a log you can mine.")
294
+ if index == 0:
295
+ self.marking = self.net.initial_marking
296
+ self.history.clear()
297
+ self.last_fired = None
298
+ if index != 0 and self.view_switch.index() != 0:
299
+ self.view_switch.set_index(0)
300
+ self.redraw()
301
+
302
+ # -- firing ------------------------------------------------------------------------------------
303
+ def _fire(self, transition_id: str) -> None:
304
+ self.history.append((self.marking, transition_id))
305
+ self.marking = self.net.fire(self.marking, transition_id)
306
+ self.last_fired = transition_id
307
+ self.redraw()
308
+
309
+ def _step_random(self) -> bool:
310
+ enabled = self.net.enabled(self.marking)
311
+ if not enabled:
312
+ return False
313
+ import random as _random
314
+ self._fire(_random.choice(enabled))
315
+ return True
316
+
317
+ def _back(self) -> None:
318
+ if self.history:
319
+ self.marking, _ = self.history.pop()
320
+ self.last_fired = self.history[-1][1] if self.history else None
321
+ self.redraw()
322
+
323
+ def _reset_game(self) -> None:
324
+ self.marking = self.net.initial_marking
325
+ self.history.clear()
326
+ self.last_fired = None
327
+ self.redraw()
328
+
329
+ def _node_clicked(self, node_id: str) -> None:
330
+ if node_id not in self.net.transitions:
331
+ return
332
+ if not self.simulating:
333
+ self.status.emit("Switch to Step through to fire transitions by clicking them.")
334
+ return
335
+ if self.net.is_enabled(self.marking, node_id):
336
+ self._fire(node_id)
337
+ else:
338
+ self.status.emit(f"“{self.net.transitions[node_id].name}” is not enabled in "
339
+ f"{self.marking.describe(self.net)}")
340
+
341
+ # -- automatic simulation ---------------------------------------------------------------
342
+ def _speed_changed(self, value: int) -> None:
343
+ self.speed_label.setText(f"{value} / s")
344
+ self.timer.setInterval(int(1000 / value))
345
+
346
+ def _play_toggled(self, on: bool) -> None:
347
+ self.play_button.setText("⏸ Pause" if on else "▶ Play")
348
+ if on:
349
+ self.timer.start(int(1000 / self.speed.value()))
350
+ else:
351
+ self.timer.stop()
352
+
353
+ def _tick(self) -> None:
354
+ at_final = bool(self.net.final_marking) and self.marking == self.net.final_marking
355
+ if not at_final and self._step_random():
356
+ return
357
+ # The run is over: at the final marking, or stuck.
358
+ if at_final:
359
+ self.runs_completed += 1
360
+ if self.repeat_box.isChecked():
361
+ self._reset_game()
362
+ else:
363
+ self.play_button.setChecked(False)
364
+ self.redraw()
365
+
366
+ def _generate_log(self) -> None:
367
+ from PySide6.QtWidgets import QInputDialog
368
+ from ...mining.playout import play_out
369
+ count, ok = QInputDialog.getInt(self, "Generate event log",
370
+ "Number of cases to simulate:", 100, 1, 100_000, 50)
371
+ if not ok:
372
+ return
373
+ net = self.net
374
+
375
+ def done(result) -> None:
376
+ self.status.emit(f"Generated {len(result.log):,} cases: {result.completed:,} "
377
+ f"completed, {result.deadlocked:,} deadlocked, "
378
+ f"{result.cut_off:,} cut off")
379
+ self.log_generated.emit(result.log)
380
+
381
+ run_in_background(lambda: play_out(net, traces=count, name=f"Play-out · {net.name}"),
382
+ done, lambda m: self.status.emit(f"Play-out failed: {m}"))
383
+
384
+ def _switch_view(self, index: int) -> None:
385
+ self.canvas_stack.setCurrentIndex(index)
386
+ if index == 1 and not self.state_view.graph.nodes:
387
+ self.state_info.setText("Computing state space…")
388
+ net = self.net
389
+
390
+ def compute():
391
+ graph = reachability_graph(net, max_states=20_000)
392
+ if graph.has_omega:
393
+ graph = coverability_graph(net, max_states=20_000)
394
+ return graph
395
+
396
+ def done(graph) -> None:
397
+ nodes, edges = state_graph_specs(graph)
398
+ self.state_view.graph.populate(nodes, edges, layer_gap=120)
399
+ self.state_view.fit()
400
+ kind = "Coverability graph (unbounded net: ω = any number)" \
401
+ if graph.kind == "coverability" else "Reachability graph"
402
+ extra = ""
403
+ if len(graph.states) > len(nodes):
404
+ extra = f" — showing the first {len(nodes)} of {len(graph.states)} states"
405
+ if graph.truncated and not graph.has_omega:
406
+ extra += " (exploration stopped at the state limit)"
407
+ dead = len(graph.dead_states())
408
+ self.state_info.setText(
409
+ f"{kind}: {len(graph.states):,} states, {len(graph.edges):,} edges, "
410
+ f"{dead} dead state(s){extra}. Initial state is shaded; dead states "
411
+ "other than the final marking are outlined in red.")
412
+ run_in_background(compute, done, lambda m: self.state_info.setText(m))
413
+
414
+ # ============================================================= inspector
415
+ def _build_inspector(self) -> QWidget:
416
+ host = QWidget()
417
+ host.setMinimumWidth(300)
418
+ host.setMaximumWidth(560)
419
+ layout = QVBoxLayout(host)
420
+ layout.setContentsMargins(0, 0, 0, 0)
421
+ layout.setSpacing(10)
422
+ tabs = SegmentedControl(["Analysis", "Conformance", "Details"])
423
+ self.inspector_tabs = tabs
424
+ layout.addWidget(tabs)
425
+ stack = QStackedWidget()
426
+ stack.addWidget(scroll(self._build_analysis()))
427
+ stack.addWidget(scroll(self._build_conformance()))
428
+ stack.addWidget(scroll(self._build_details()))
429
+ tabs.changed.connect(stack.setCurrentIndex)
430
+ layout.addWidget(stack, 1)
431
+ return host
432
+
433
+ # --- analysis -----------------------------------------------------------
434
+ def _build_analysis(self) -> QWidget:
435
+ page = QWidget()
436
+ layout = QVBoxLayout(page)
437
+ layout.setContentsMargins(0, 0, 4, 0)
438
+ layout.setSpacing(12)
439
+ self.soundness_card = Card("Soundness", "Classical soundness of the WF-net: option "
440
+ "to complete, proper completion, no dead transitions. "
441
+ "Hover over a property (ⓘ) for its definition.")
442
+ self.properties_card = Card("Behavioural properties")
443
+ check = button("Run analysis", self._run_analysis, kind="primary")
444
+ layout.addWidget(check)
445
+ layout.addWidget(self.soundness_card)
446
+ layout.addWidget(self.properties_card)
447
+ layout.addStretch(1)
448
+ self.conceal_card(self.soundness_card, "soundness")
449
+ self.conceal_card(self.properties_card, "properties")
450
+ self._run_analysis()
451
+ return page
452
+
453
+ @staticmethod
454
+ def _clear(card: Card) -> None:
455
+ card.clear()
456
+
457
+ def _run_analysis(self) -> None:
458
+ net = self.net
459
+ self._clear(self.soundness_card)
460
+ self._clear(self.properties_card)
461
+ self.soundness_card.add(label("Checking…", "muted"))
462
+
463
+ def compute():
464
+ soundness = check_soundness(net)
465
+ workflow = soundness.workflow
466
+ if workflow.is_workflow_net:
467
+ # Soundness theorem: analyse the short-circuited net N̄ instead, where
468
+ # liveness and boundedness together are equivalent to soundness.
469
+ closed = short_circuit(net, workflow.source, workflow.sink)
470
+ return soundness, analyse(closed, max_states=50_000), True
471
+ return soundness, analyse(net, max_states=50_000), False
472
+
473
+ def done(result) -> None:
474
+ soundness, properties, closed = result
475
+ self._clear(self.soundness_card)
476
+ self._clear(self.properties_card)
477
+ if closed:
478
+ self.properties_card.add(label(
479
+ "Computed on the short-circuited net N̄ (this net plus a silent t* from "
480
+ "the sink back to the source, starting from [source]). Soundness "
481
+ "theorem: the WF-net is sound ⇔ N̄ is live and bounded.", "muted",
482
+ wrap=True))
483
+ workflow = soundness.workflow
484
+ self.soundness_card.add(Verdict(
485
+ "WF-net structure", status_for(workflow.is_workflow_net),
486
+ "one source, one sink, every node on a path between them"
487
+ if workflow.is_workflow_net else "; ".join(workflow.problems),
488
+ instance=instances.soundness(soundness, "wf_net")))
489
+ if workflow.is_workflow_net:
490
+ verdict = soundness.sound
491
+ self.soundness_card.add(Verdict(
492
+ "Sound" if verdict else ("Not sound" if verdict is False else "Undecided"),
493
+ status_for(verdict),
494
+ "every case can complete properly and every transition can fire"
495
+ if verdict else "", instance=instances.soundness(soundness, "sound")))
496
+ for name, key, value in (
497
+ ("(i) Option to complete", "option_to_complete",
498
+ soundness.option_to_complete),
499
+ ("(ii) Proper completion", "proper_completion",
500
+ soundness.proper_completion),
501
+ ("(iii) No dead transitions", "no_dead_transitions",
502
+ soundness.no_dead_transitions)):
503
+ self.soundness_card.add(Verdict(
504
+ name, status_for(value), "", definition=key,
505
+ instance=instances.soundness(soundness, key)))
506
+ for finding in soundness.findings:
507
+ self.soundness_card.add(label("• " + finding, "muted", wrap=True, selectable=True))
508
+ from ...mining.definitions import FOR_TITLE
509
+ for name, value, detail in properties.lines():
510
+ key = FOR_TITLE.get(name)
511
+ if closed and key in ("bounded", "safe", "live", "deadlock_free"):
512
+ filled = instances.short_circuit(soundness, key)
513
+ else:
514
+ filled = instances.properties(properties, key) if key else None
515
+ self.properties_card.add(Verdict(name, status_for(value), detail,
516
+ instance=filled))
517
+ self.properties_card.add(label(
518
+ f"{len(properties.graph.states):,} reachable markings explored "
519
+ f"({properties.graph.kind} graph).", "muted", wrap=True))
520
+
521
+ run_in_background(compute, done, lambda m: self.soundness_card.add(
522
+ Verdict("Analysis failed", "critical", m)))
523
+
524
+ # --- conformance ----------------------------------------------------------
525
+ def _build_conformance(self) -> QWidget:
526
+ page = QWidget()
527
+ layout = QVBoxLayout(page)
528
+ layout.setContentsMargins(0, 0, 4, 0)
529
+ layout.setSpacing(12)
530
+
531
+ chooser = Card("Replay a log", "Measures how well this model and a log agree.")
532
+ self.log_box = QComboBox()
533
+ self.refresh_logs()
534
+ self.run_conformance = button("Check conformance", self._check, kind="primary")
535
+ chooser.add(self.log_box)
536
+ chooser.add(hbox(self.run_conformance, button(
537
+ "As a workflow ⧉", lambda: self.workflow_requested.emit(self.log_box.currentData()),
538
+ tooltip="This model and the chosen log as boxes on a canvas, with a Check fit box"), None))
539
+ layout.addWidget(chooser)
540
+
541
+ self.metric_tiles = {
542
+ "align": StatTile("Fitness · alignments"),
543
+ "tbr": StatTile("Fitness · token replay"),
544
+ "precision": StatTile("Precision"),
545
+ "generalisation": StatTile("Generalisation"),
546
+ "simplicity": StatTile("Simplicity"),
547
+ "fitting": StatTile("Fitting cases"),
548
+ }
549
+ grid = QGridLayout()
550
+ grid.setSpacing(10)
551
+ for index, tile in enumerate(self.metric_tiles.values()):
552
+ grid.addWidget(tile, index // 2, index % 2)
553
+ metrics = Card(padding=0)
554
+ metrics.setObjectName("plain")
555
+ metrics.add(grid)
556
+ layout.addWidget(metrics)
557
+ self.conceal_card(metrics, "conformance")
558
+
559
+ overlay = Card("Show on model")
560
+ self.overlay_switch = SegmentedControl(["Nothing", "Token replay", "Alignments"])
561
+ self.overlay_switch.changed.connect(self._apply_overlay)
562
+ overlay.add(self.overlay_switch)
563
+ self.overlay_legend = label("", "muted", wrap=True)
564
+ overlay.add(self.overlay_legend)
565
+ layout.addWidget(overlay)
566
+
567
+ variants = Card("Variants", "Select a variant to see its optimal alignment.")
568
+ self.variant_model = QStandardItemModel(0, 4)
569
+ self.variant_model.setHorizontalHeaderLabels(["Cases", "Fitness", "Deviations", "Trace"])
570
+ self.variant_table = _table(self.variant_model)
571
+ self.variant_table.setMinimumHeight(220)
572
+ header = self.variant_table.horizontalHeader()
573
+ for c in range(3):
574
+ header.setSectionResizeMode(c, QHeaderView.ResizeToContents)
575
+ header.setSectionResizeMode(3, QHeaderView.Stretch)
576
+ variants.add(self.variant_table)
577
+ self.alignment_strip = AlignmentStrip()
578
+ variants.add(self.alignment_strip)
579
+ legend = label("Chips: plain = synchronous move · “a ≫” = log move (in the log, "
580
+ "not allowed by the model) · “≫ a” = model move (required by the "
581
+ "model, missing in the log). Silent τ moves are hidden.", "muted", wrap=True)
582
+ variants.add(legend)
583
+ self.variant_table.selectionModel().currentRowChanged.connect(self._show_alignment)
584
+ layout.addWidget(variants)
585
+ self.conceal_card(overlay, "conformance")
586
+ self.conceal_card(variants, "conformance")
587
+ layout.addStretch(1)
588
+ return page
589
+
590
+ def refresh_logs(self) -> None:
591
+ current = self.log_box.currentData() if self.log_box.count() else None
592
+ self.log_box.clear()
593
+ logs = self.open_logs()
594
+ for document in logs:
595
+ self.log_box.addItem(document.name, document)
596
+ preferred = current or self.document.source_log
597
+ if preferred is not None:
598
+ index = next((i for i in range(self.log_box.count())
599
+ if self.log_box.itemData(i) is preferred), 0)
600
+ self.log_box.setCurrentIndex(index)
601
+ if hasattr(self, "run_conformance"):
602
+ self.run_conformance.setEnabled(bool(logs))
603
+
604
+ def _check(self) -> None:
605
+ document = self.log_box.currentData()
606
+ if document is None:
607
+ return
608
+ net = self.net
609
+ simple = document.simple_log()
610
+ self.run_conformance.setEnabled(False)
611
+ self.run_conformance.setText("Checking…")
612
+ for tile in self.metric_tiles.values():
613
+ tile.set("…")
614
+
615
+ def compute():
616
+ replay = token_replay(net, simple)
617
+ alignments = align_log(net, simple)
618
+ return (replay, alignments, precision(net, simple), generalisation(replay),
619
+ simplicity(net))
620
+
621
+ def done(result) -> None:
622
+ self.run_conformance.setEnabled(True)
623
+ self.run_conformance.setText("Check conformance")
624
+ replay, alignments, prec, gen, simp = result
625
+ self.conformance = (replay, alignments)
626
+ tiles = self.metric_tiles
627
+ tiles["align"].set(f"{alignments.average_fitness:.3f}",
628
+ f"log-level {alignments.log_fitness:.3f}")
629
+ tiles["tbr"].set(f"{replay.fitness:.3f}",
630
+ f"m {replay.missing:,} · r {replay.remaining:,} · "
631
+ f"c {replay.consumed:,} · p {replay.produced:,}")
632
+ tiles["precision"].set(f"{prec:.3f}", "escaping edges (ETC)")
633
+ tiles["generalisation"].set(f"{gen:.3f}", "token-based")
634
+ tiles["simplicity"].set(f"{simp:.3f}", "arc degree")
635
+ total = alignments.trace_count
636
+ tiles["fitting"].set(f"{alignments.fitting_traces:,} / {total:,}",
637
+ f"{alignments.fitting_traces / max(total, 1):.0%} replay perfectly")
638
+ self.variant_model.removeRows(0, self.variant_model.rowCount())
639
+ for alignment in sorted(alignments.alignments, key=lambda a: (a.fitness, -a.count)):
640
+ trace_item = _item(" → ".join(alignment.trace) or "⟨⟩", data=alignment)
641
+ self.variant_model.appendRow([
642
+ _item(f"{alignment.count:,}", True), _item(f"{alignment.fitness:.3f}", True),
643
+ _item(alignment.cost if alignment.complete else "?", True), trace_item])
644
+ if self.variant_model.rowCount():
645
+ self.variant_table.selectRow(0)
646
+ self.overlay_switch.set_index(2 if alignments.fitting_traces < total else 0)
647
+ self.status.emit(f"Conformance of “{document.name}”: fitness "
648
+ f"{alignments.average_fitness:.3f}, precision {prec:.3f}")
649
+
650
+ def failed(message: str) -> None:
651
+ self.run_conformance.setEnabled(True)
652
+ self.run_conformance.setText("Check conformance")
653
+ self.status.emit(f"Conformance failed: {message}")
654
+
655
+ run_in_background(compute, done, failed)
656
+
657
+ def _show_alignment(self, current, _previous=None) -> None:
658
+ if not current.isValid():
659
+ return
660
+ alignment = self.variant_model.item(current.row(), 3).data(Qt.UserRole)
661
+ self.alignment_strip.set_moves(alignment.moves)
662
+
663
+ def _apply_overlay(self, index: int) -> None:
664
+ self.overlay_badges = {}
665
+ self.overlay_legend.setText("")
666
+ if self.conformance is not None and index == 1:
667
+ replay, _ = self.conformance
668
+ missing, remaining = replay.place_totals()
669
+ for place in self.net.places:
670
+ badges = []
671
+ if missing[place]:
672
+ badges.append((f"−{missing[place]:,}", style.STATUS["critical"]))
673
+ if remaining[place]:
674
+ badges.append((f"+{remaining[place]:,}", style.STATUS["serious"]))
675
+ if badges:
676
+ self.overlay_badges[place] = badges
677
+ self.overlay_legend.setText("“−n” on a place: n tokens were missing (the log did "
678
+ "something the model did not allow). “+n”: n tokens "
679
+ "were left behind (the model expected more).")
680
+ elif self.conformance is not None and index == 2:
681
+ _, alignments = self.conformance
682
+ sync, log_moves, model_moves = alignments.move_statistics()
683
+ for transition in self.net.transitions.values():
684
+ if transition.label is None:
685
+ continue
686
+ badges = []
687
+ if model_moves.get(transition.label):
688
+ badges.append((f"≫ {model_moves[transition.label]:,}", style.categorical(6)))
689
+ if log_moves.get(transition.label):
690
+ badges.append((f"{log_moves[transition.label]:,} ≫", style.categorical(1)))
691
+ if badges:
692
+ self.overlay_badges[transition.id] = badges
693
+ unknown = {a: n for a, n in log_moves.items() if a not in self.net.labels()}
694
+ text = ("“≫ n”: the model had to do this n times without the log (model moves). "
695
+ "“n ≫”: the log did this n times where the model could not (log moves).")
696
+ if unknown:
697
+ text += " Activities missing from the model entirely: " + ", ".join(
698
+ f"{a} ({n:,})" for a, n in sorted(unknown.items()))
699
+ self.overlay_legend.setText(text)
700
+ self.redraw(fit=False)
701
+
702
+ # --- details -----------------------------------------------------------
703
+ def _build_details(self) -> QWidget:
704
+ page = QWidget()
705
+ layout = QVBoxLayout(page)
706
+ layout.setContentsMargins(0, 0, 4, 0)
707
+ layout.setSpacing(12)
708
+ info = Card("Model")
709
+ rows = [("Origin", self.document.origin or "–"), ("Size", self.net.summary()),
710
+ ("Initial marking", self.net.initial_marking.describe(self.net)),
711
+ ("Final marking", self.net.final_marking.describe(self.net))]
712
+ rows += [(k.capitalize(), v) for k, v in self.net.info.items() if k != "algorithm"]
713
+ for key, value in rows:
714
+ info.add(vbox(label(key, "sectionLabel"), label(value, wrap=True, selectable=True),
715
+ spacing=2))
716
+ layout.addWidget(info)
717
+ if self.document.derivation is not None:
718
+ derivation = Card("Derivation")
719
+ widget = derivation_widget(self.document.derivation)
720
+ widget.setMinimumHeight(420)
721
+ derivation.add(widget)
722
+ layout.addWidget(derivation)
723
+ layout.addStretch(1)
724
+ return page
725
+
726
+ # ================================================================ export
727
+ def refresh_title(self) -> None:
728
+ self.header.set_text(self.net.name, self._subtitle())
729
+
730
+ def export(self) -> None:
731
+ """Save as PNML (with the current layout). The document then refers to
732
+ that file, so it survives removal and is reopened at the next launch."""
733
+ path, _ = QFileDialog.getSaveFileName(self, "Export Petri net", suggested_path(f"{self.net.name}.pnml"),
734
+ "PNML (*.pnml)")
735
+ if path:
736
+ self.write_to(path)
737
+
738
+ def write_to(self, path: str, quiet: bool = False) -> None:
739
+ """Save as PNML at ``path``, with the current layout; it is then that file."""
740
+ from .workspace import atomic_write
741
+ positions = {node_id: (item.pos().x(), item.pos().y())
742
+ for node_id, item in self.view.graph.nodes.items()}
743
+ atomic_write(path, lambda temporary: write_pnml(self.net, temporary, positions))
744
+ self.document.path = path
745
+ self.document.missing = False
746
+ self.keep_button.setVisible(False)
747
+ if not quiet:
748
+ self.status.emit(f"Saved {path}")
749
+ self.saved.emit()
750
+
751
+ def _export_image(self) -> None:
752
+ path, chosen = QFileDialog.getSaveFileName(self, "Export image", suggested_path(f"{self.net.name}.png"),
753
+ "PNG image (*.png);;SVG drawing (*.svg)")
754
+ if not path:
755
+ return
756
+ if path.lower().endswith(".svg"):
757
+ self.view.export_svg(path)
758
+ else:
759
+ self.view.export_png(path)
760
+ self.status.emit(f"Saved {path}")