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,844 @@
1
+ """The Petri net editor: draw a plain Petri net or WF-net and analyse it.
2
+
3
+ This is the editor for the nets of the course -- black tokens, arc weights,
4
+ silent τ transitions -- as opposed to coloured nets. It is the coloured-net
5
+ page (:class:`~openprocess.gui.studio.cpn_page.CpnPage`) with a simpler element
6
+ form and, instead of the CPN state space tool, an **Analysis** tab:
7
+
8
+ * **Soundness**: is it a WF-net? Option to complete, proper completion, no
9
+ dead transitions -- and for every violation a counterexample, which
10
+ *Show* replays on the net in the token game.
11
+ * **Short-circuited net N̄** (plus t* from o to i): the soundness theorem
12
+ says the net is sound iff (N̄, [i]) is live and bounded.
13
+ * **Structure**: free-choice, well-structured, S-coverable, the start and
14
+ end rule.
15
+ * **Invariants**: the incidence matrix, and the P- and T-invariants (of N̄
16
+ for a WF-net), which prove boundedness or unsoundness without a state space.
17
+ * **Behavioural properties** of the net as drawn: bounded / safe, dead
18
+ transitions, deadlocks, liveness, reversibility.
19
+ * **Footprint** of the net's behaviour (the → ← ‖ # matrix of the α
20
+ chapter), to compare with a log's footprint.
21
+ * The **reachability graph**, and **conformance checking** against an open
22
+ event log (the net opens as a model next to your logs).
23
+
24
+ Every property is defined in :mod:`openprocess.mining.definitions` (and
25
+ ``docs/definitions.md``); hovering over one shows its definition filled in
26
+ for this net (:mod:`.instances`), clicking opens it.
27
+
28
+ The net is saved as PNML, the standard format ProM, WoPeD and PM4Py read.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from html import escape
34
+ from pathlib import Path
35
+
36
+ from PySide6.QtCore import Qt, QTimer, Signal
37
+ from PySide6.QtWidgets import (
38
+ QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout, QInputDialog, QLineEdit,
39
+ QMessageBox, QSizePolicy, QSpinBox, QVBoxLayout, QWidget,
40
+ )
41
+
42
+ from ...mining.analysis import analyse, check_soundness, check_workflow_net, short_circuit
43
+ from ...mining.footprint import footprint_of_net
44
+ from ...mining.invariants import invariants
45
+ from ...mining.petrinet import Marking
46
+ from ...mining.pnml import write_pnml
47
+ from ...model.net import Arc, Place, Transition
48
+ from ...model.plain import to_petri_net, token_count, tokens_text, weight_of
49
+ from ..learn.concealment import ConcealsResults
50
+ from .cpn_page import CpnPage
51
+ from .documents import ModelDocument
52
+ from .workspace import atomic_write
53
+ from .widgets import (
54
+ suggested_path,
55
+ Card, Verdict, button, flow, footprint_table, hbox, label, status_for,
56
+ )
57
+ from . import instances
58
+ from .definition_view import attach_definition, show_reference
59
+ from .workers import run_in_background
60
+
61
+ # The notation of docs/definitions.md, as Qt rich text (labels detect the tags by themselves).
62
+ # N̄ with a combining macron renders badly in many fonts, so the bar is a real
63
+ # overline over an italic N.
64
+ NBAR = '<span style="text-decoration: overline"><i>N</i></span>'
65
+ TSTAR = "<i>t</i>*"
66
+ I, O = "<i>i</i>", "<i>o</i>"
67
+
68
+
69
+ def _math(name: str, status: str, detail: str, definition: str | None = None,
70
+ instance: list[str] | None = None) -> "Verdict":
71
+ """A Verdict whose detail is rich text (names in it must be escaped)."""
72
+ return Verdict(name, status, f"<span>{detail}</span>", definition=definition,
73
+ instance=instance)
74
+
75
+
76
+ class PetriNetPage(ConcealsResults, CpnPage):
77
+ """Draw, play and analyse a plain Petri net (see the module docstring)."""
78
+
79
+ #: The net as a model for conformance checking / the model views.
80
+ open_model = Signal(object)
81
+
82
+ INSPECTOR = ["Simulation", "Element", "Analysis"]
83
+ KIND = "Petri net"
84
+
85
+ def __init__(self, document, parent=None) -> None:
86
+ self._analysis_stale = True
87
+ super().__init__(document, parent)
88
+ # No colour sets, declarations or pages to manage.
89
+ self.structure_toggle.hide()
90
+ self._toggle_structure(False)
91
+ self.values_button.hide()
92
+ self.export_log_button.setText("Generate event log…")
93
+ self.export_log_button.setToolTip("Play the net out many times (random choices) and "
94
+ "open the traces as an event log")
95
+ self.inspector_tabs.changed.connect(self._inspector_tab_changed)
96
+ # Plain nets have no variables and no clock.
97
+ self.bindings_card.title_label.setText("Enabled transitions")
98
+ self.bindings_card.caption_label.setText("Double-click one to fire it (or click it on "
99
+ "the net).")
100
+ self.binding_filter.hide()
101
+ self.time_tile.hide()
102
+
103
+ def _mode_changed(self, index: int) -> None:
104
+ super()._mode_changed(index)
105
+ if index == 1:
106
+ self.sim_hint.setText("Green = enabled. Click a green transition to fire it; Back "
107
+ "undoes, Reset returns to the initial marking. Trace lights up "
108
+ "the path so far.")
109
+ elif index == 2:
110
+ self.sim_hint.setText("Play fires enabled transitions at random (blue halo = fired "
111
+ "last). Generate event log (Simulation tab) plays many runs "
112
+ "and opens them as a log.")
113
+
114
+ def refresh_simulation(self, light: bool = False) -> None:
115
+ super().refresh_simulation(light)
116
+ count = len(getattr(self, "enabled_elements", []))
117
+ self.enabled_tile.set(f"{count}", f"transition{'s' * (count != 1)}")
118
+
119
+ def _fill_marking(self) -> None:
120
+ """The marking as counts and dots, not CPN multisets."""
121
+ from PySide6.QtWidgets import QTableWidgetItem
122
+ rows = []
123
+ for place in self.net.all_places():
124
+ count = self._marking_for(place.id)[0]
125
+ if count or not self.only_marked.isChecked():
126
+ rows.append((place, count))
127
+ self.marking_table.setRowCount(len(rows))
128
+ for row, (place, count) in enumerate(rows):
129
+ name = QTableWidgetItem(place.name)
130
+ name.setData(Qt.UserRole, place.id)
131
+ number = QTableWidgetItem(f"{count:,}")
132
+ number.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
133
+ dots = QTableWidgetItem("●" * count if count <= 12 else f"{count} tokens")
134
+ self.marking_table.setItem(row, 0, name)
135
+ self.marking_table.setItem(row, 1, number)
136
+ self.marking_table.setItem(row, 2, dots)
137
+
138
+ def showEvent(self, event) -> None: # noqa: N802
139
+ super().showEvent(event)
140
+ self._toggle_structure(False)
141
+
142
+ # =====================================================================
143
+ # Inspector
144
+ # =====================================================================
145
+ def _build_inspector(self) -> QWidget:
146
+ from PySide6.QtWidgets import QStackedWidget
147
+ from .widgets import SegmentedControl, scroll
148
+ host = QWidget()
149
+ self.inspector_panel = host
150
+ layout = QVBoxLayout(host)
151
+ layout.setContentsMargins(0, 0, 0, 0)
152
+ layout.setSpacing(10)
153
+ self.inspector_tabs = SegmentedControl(self.INSPECTOR, compact=True)
154
+ host.setMinimumWidth(max(300, self.inspector_tabs.sizeHint().width() + 24))
155
+ layout.addLayout(hbox(self.inspector_tabs, None))
156
+ self.inspector_stack = QStackedWidget()
157
+ for build in (self._build_simulation_tab, self._build_element_tab,
158
+ self._build_analysis_tab):
159
+ self.inspector_stack.addWidget(scroll(build()))
160
+ self.inspector_tabs.changed.connect(self.inspector_stack.setCurrentIndex)
161
+ layout.addWidget(self.inspector_stack, 1)
162
+ # The coloured-net page's problems list is not needed here.
163
+ self.problems_card = Card()
164
+ return host
165
+
166
+ # -- element form -------------------------------------------------------------------
167
+ def _build_element_tab(self) -> QWidget:
168
+ page = QWidget()
169
+ layout = QVBoxLayout(page)
170
+ layout.setContentsMargins(0, 0, 4, 0)
171
+ self.element_card = Card("Nothing selected")
172
+ self.element_hint = label(
173
+ "Click a place, transition or arc to edit it. Double-click a place or "
174
+ "transition on the canvas to rename it.", "muted", wrap=True)
175
+ self.element_card.add(self.element_hint)
176
+ self.form = QFormLayout()
177
+ self.form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter)
178
+ self.form.setHorizontalSpacing(12)
179
+ self.form.setVerticalSpacing(10)
180
+ self.name_edit = QLineEdit()
181
+ self.name_edit.editingFinished.connect(self._commit_element)
182
+ self.tokens_spin = QSpinBox()
183
+ self.tokens_spin.setRange(0, 999)
184
+ self.tokens_spin.setToolTip("Tokens in this place in the initial marking")
185
+ self.tokens_spin.valueChanged.connect(lambda _v: self._commit_element())
186
+ self.silent_box = QCheckBox("Silent (τ) — invisible in traces")
187
+ self.silent_box.toggled.connect(lambda _v: self._commit_element())
188
+ self.weight_spin = QSpinBox()
189
+ self.weight_spin.setRange(1, 99)
190
+ self.weight_spin.setToolTip("How many tokens the arc moves at once")
191
+ self.weight_spin.valueChanged.connect(lambda _v: self._commit_element())
192
+ self.orientation_box = QComboBox()
193
+ self.orientation_box.addItems(["Place → Transition", "Transition → Place",
194
+ "Both directions"])
195
+ self.orientation_box.activated.connect(lambda _i: self._commit_element())
196
+ self.rows = {}
197
+ for key, caption, widget in (("name", "Name", self.name_edit),
198
+ ("tokens", "Tokens", self.tokens_spin),
199
+ ("silent", "", self.silent_box),
200
+ ("weight", "Weight", self.weight_spin),
201
+ ("orientation", "Direction", self.orientation_box)):
202
+ caption_label = label(caption, "muted")
203
+ self.form.addRow(caption_label, widget)
204
+ self.rows[key] = (caption_label, widget)
205
+ self.element_card.add(self.form)
206
+ self.element_info = label("", "muted", wrap=True, selectable=True)
207
+ self.element_card.add(self.element_info)
208
+ from PySide6.QtWidgets import QVBoxLayout as _V
209
+ self.element_problems = _V()
210
+ layout.addWidget(self.element_card)
211
+ layout.addStretch(1)
212
+ self._show_element(None)
213
+ return page
214
+
215
+ def _show_element(self, element) -> None:
216
+ self.element = element
217
+ self.element_info.setText("")
218
+ self.element_hint.setVisible(element is None)
219
+ widgets = (self.name_edit, self.tokens_spin, self.silent_box, self.weight_spin,
220
+ self.orientation_box)
221
+ for widget in widgets:
222
+ widget.blockSignals(True)
223
+ try:
224
+ if element is None:
225
+ self.element_card.title_label.setText("Nothing selected")
226
+ self._set_rows(set())
227
+ elif isinstance(element, Place):
228
+ self.element_card.title_label.setText(f"Place · {element.name}")
229
+ self.name_edit.setText(element.name)
230
+ self.tokens_spin.setValue(token_count(element.initial_marking_text))
231
+ self._set_rows({"name", "tokens"})
232
+ now = self._marking_for(element.id)[0]
233
+ self.element_info.setText(f"Now: {now} token(s).")
234
+ elif isinstance(element, Transition):
235
+ kind = "Silent transition" if element.silent else "Transition"
236
+ self.element_card.title_label.setText(f"{kind} · {element.name or 'τ'}")
237
+ self.name_edit.setText(element.name)
238
+ self.silent_box.setChecked(element.silent)
239
+ self._set_rows({"name", "silent"})
240
+ enabled = bool(self.simulator.enabled_bindings(element))
241
+ self.element_info.setText("Enabled now." if enabled else "Not enabled now.")
242
+ elif isinstance(element, Arc):
243
+ place = self.net.find_place(element.place_id)
244
+ transition = self.net.find_transition(element.transition_id)
245
+ self.element_card.title_label.setText("Arc")
246
+ self.weight_spin.setValue(weight_of(element))
247
+ self.orientation_box.setCurrentIndex(
248
+ {"PtoT": 0, "TtoP": 1, "BOTHDIR": 2}.get(element.orientation, 0))
249
+ self._set_rows({"weight", "orientation"})
250
+ self.element_info.setText(
251
+ f"Between place “{place.name if place else '?'}” and transition "
252
+ f"“{transition.name if transition else '?'}”.")
253
+ finally:
254
+ for widget in widgets:
255
+ widget.blockSignals(False)
256
+
257
+ def _commit_element(self) -> None:
258
+ element = self.element
259
+ if element is None:
260
+ return
261
+ before = self._element_state(element)
262
+ snapshot = self._snapshot()
263
+ if isinstance(element, Place):
264
+ element.name = self.name_edit.text().strip() or element.name
265
+ element.initial_marking_text = tokens_text(self.tokens_spin.value())
266
+ elif isinstance(element, Transition):
267
+ element.name = self.name_edit.text().strip()
268
+ element.silent = self.silent_box.isChecked()
269
+ elif isinstance(element, Arc):
270
+ weight = self.weight_spin.value()
271
+ element.expression_text = "" if weight == 1 else tokens_text(weight)
272
+ element.orientation = ["PtoT", "TtoP", "BOTHDIR"][self.orientation_box.currentIndex()]
273
+ if self._element_state(element) != before:
274
+ self._push_undo(snapshot)
275
+ self._edited()
276
+ self._show_element(element)
277
+
278
+ @staticmethod
279
+ def _element_state(element) -> tuple:
280
+ return tuple(getattr(element, name, None) for name in (
281
+ "name", "initial_marking_text", "silent", "expression_text", "orientation"))
282
+
283
+ # -- analysis --------------------------------------------------------------------------
284
+ def _build_analysis_tab(self) -> QWidget:
285
+ page = QWidget()
286
+ layout = QVBoxLayout(page)
287
+ layout.setContentsMargins(0, 0, 4, 0)
288
+ layout.setSpacing(12)
289
+ self.analysis_button = button("Check again", self.run_analysis, kind="primary",
290
+ tooltip="The analysis also runs by itself whenever "
291
+ "you open this tab after an edit")
292
+ definitions = button("Definitions…", lambda: show_reference(None, self),
293
+ kind="ghost", tooltip="Every property, defined mathematically")
294
+ # The hint wraps, so the row never makes the inspector wider than it is.
295
+ layout.addLayout(hbox(label("Hover over a property (ⓘ) for its definition.", "muted",
296
+ wrap=True), definitions, self.analysis_button))
297
+ self.soundness_card = Card("Soundness", f"From one token in {I}: (i) every run can "
298
+ f"still reach [{O}], (ii) [{O}] is the only marking with a "
299
+ f"token in {O}, (iii) every transition can fire.")
300
+ self.theorem_card = Card(f"Short-circuited net {NBAR}",
301
+ f"{NBAR} is the net plus a transition {TSTAR} from {O} back "
302
+ f"to {I}. Soundness theorem: the WF-net is sound iff "
303
+ f"({NBAR}, [{I}]) is live and bounded.")
304
+ self.structure_card = Card("Structure", "Properties of the drawing alone. They "
305
+ "point at the construct behind a problem.")
306
+ self.invariants_card = Card("Invariants", "Linear algebra on the incidence matrix: "
307
+ "weighted token counts that never change (P-invariants), "
308
+ "and firing counts that lead back to where they started "
309
+ "(T-invariants).")
310
+ self.properties_card = Card("Behavioural properties", "Of the net as drawn, from "
311
+ "its initial marking.")
312
+ self.footprint_card = Card("Footprint", "The ordering relations of the net's "
313
+ "behaviour: which transition can directly follow which.")
314
+ # The card captions explain the idea; hovering shows the maths.
315
+ for card, key in ((self.soundness_card, "sound"), (self.theorem_card, "short_circuit"),
316
+ (self.invariants_card, "incidence_matrix"),
317
+ (self.footprint_card, "footprint")):
318
+ attach_definition(card.caption_label, key)
319
+ self.more_card = Card("More")
320
+ self.more_card.add(label("Look at every reachable marking, or replay an event log "
321
+ "on this net (token replay, alignments).", "muted",
322
+ wrap=True))
323
+ self.more_card.add(flow(button("Reachability graph…", self.show_reachability_graph),
324
+ button("Conformance with a log…", self.open_as_model)))
325
+ for card in (self.soundness_card, self.theorem_card, self.structure_card,
326
+ self.invariants_card, self.properties_card, self.footprint_card,
327
+ self.more_card):
328
+ layout.addWidget(card)
329
+ # Hidden in an exercise until revealed (see concealment.py).
330
+ for card, key in ((self.soundness_card, "soundness"), (self.theorem_card, "theorem"),
331
+ (self.structure_card, "structure"),
332
+ (self.invariants_card, "invariants"),
333
+ (self.properties_card, "properties"),
334
+ (self.footprint_card, "footprint")):
335
+ self.conceal_card(card, key)
336
+ layout.addStretch(1)
337
+ return page
338
+
339
+ def set_concealment(self, concealment) -> None:
340
+ super().set_concealment(concealment)
341
+ if concealment is not None:
342
+ concealment.changed.connect(self._update_sim_status)
343
+ self._update_sim_status()
344
+
345
+ def _inspector_tab_changed(self, index: int) -> None:
346
+ if index == 2 and self._analysis_stale:
347
+ self.run_analysis()
348
+
349
+ def _edited(self) -> None:
350
+ super()._edited()
351
+ self._analysis_stale = True
352
+ if getattr(self, "inspector_tabs", None) is not None and self.inspector_tabs.index() == 2:
353
+ QTimer.singleShot(0, self.run_analysis)
354
+
355
+ def petri_net(self):
356
+ """The drawing as a :class:`~openprocess.mining.petrinet.PetriNet`."""
357
+ return to_petri_net(self.net)
358
+
359
+ def run_analysis(self) -> None:
360
+ self._analysis_stale = False
361
+ petri = self.petri_net()
362
+ for card in self._result_cards():
363
+ card.clear()
364
+ self.soundness_card.add(label("Checking…", "muted"))
365
+
366
+ def compute():
367
+ soundness = check_soundness(petri, max_states=100_000)
368
+ drawn = petri
369
+ if not petri.initial_marking and soundness.workflow.is_workflow_net:
370
+ # Nothing drawn in the net yet: analyse from [source].
371
+ drawn = petri.copy()
372
+ drawn.initial_marking = Marking({soundness.workflow.source: 1})
373
+ properties = analyse(drawn, max_states=50_000)
374
+ try:
375
+ footprint = footprint_of_net(drawn)
376
+ footprint_error = None
377
+ except ValueError as error:
378
+ footprint, footprint_error = None, str(error)
379
+ workflow = soundness.workflow
380
+ linear = (invariants(drawn), invariants(short_circuit(
381
+ drawn, workflow.source, workflow.sink)) if workflow.is_workflow_net else None)
382
+ return (soundness, properties, footprint, footprint_error, drawn is not petri,
383
+ linear)
384
+
385
+ run_in_background(compute, self._show_analysis,
386
+ lambda message: self.soundness_card.add(
387
+ Verdict("Analysis failed", "critical", message)))
388
+
389
+ def _result_cards(self) -> tuple:
390
+ return (self.soundness_card, self.theorem_card, self.structure_card,
391
+ self.invariants_card, self.properties_card, self.footprint_card)
392
+
393
+ def _finding(self, card: Card, text: str, path: list[str] | None = None,
394
+ source: str | None = None) -> None:
395
+ """A bullet with, when there is a firing sequence, a Show ▶ button."""
396
+ row = QWidget()
397
+ row_layout = hbox(spacing=6)
398
+ row_layout.addWidget(label("• " + text, "muted", wrap=True, selectable=True), 1)
399
+ if path:
400
+ show = button("Show ▶", lambda _=False, p=path, s=source: self.replay(p, s),
401
+ kind="ghost", tooltip="Fire this sequence on the net in the "
402
+ "token game")
403
+ show.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
404
+ row_layout.addWidget(show, 0, Qt.AlignTop)
405
+ row.setLayout(row_layout)
406
+ card.add(row)
407
+
408
+ def _show_analysis(self, result) -> None:
409
+ soundness, properties, footprint, footprint_error, from_source, linear = result
410
+ for card in self._result_cards():
411
+ card.clear()
412
+ workflow = soundness.workflow
413
+ card = self.soundness_card
414
+ card.add(Verdict("WF-net", status_for(workflow.is_workflow_net),
415
+ f"source i = {self._name(workflow.source)}, sink o = "
416
+ f"{self._name(workflow.sink)}" if workflow.is_workflow_net else
417
+ "needs one source place i, one sink place o, and every node on a "
418
+ "path from i to o",
419
+ instance=instances.soundness(soundness, "wf_net")))
420
+ if workflow.is_workflow_net:
421
+ verdict = soundness.sound
422
+ card.add(Verdict("Sound" if verdict else ("Not sound" if verdict is False
423
+ else "Undecided"),
424
+ status_for(verdict),
425
+ "every case can complete properly and every transition can fire"
426
+ if verdict else "", instance=instances.soundness(soundness, "sound")))
427
+ for name, key, value in (
428
+ ("(i) Option to complete", "option_to_complete",
429
+ soundness.option_to_complete),
430
+ ("(ii) Proper completion", "proper_completion", soundness.proper_completion),
431
+ ("(iii) No dead transitions", "no_dead_transitions",
432
+ soundness.no_dead_transitions)):
433
+ card.add(Verdict(name, status_for(value), "", definition=key,
434
+ instance=instances.soundness(soundness, key)))
435
+ for finding, path in zip(soundness.findings, soundness.paths):
436
+ self._finding(card, finding, path, workflow.source)
437
+ self._show_theorem(soundness)
438
+ self._show_structure(soundness)
439
+ self._show_invariants(*linear)
440
+
441
+ if from_source:
442
+ self.properties_card.add(label("The net has no tokens yet, so this is from one "
443
+ "token in the source place.", "muted", wrap=True))
444
+ final = Marking({workflow.sink: 1}) if workflow.is_workflow_net else None
445
+ only_final = final is not None and properties.dead_markings and all(
446
+ properties.graph.states[s] == final for s in properties.dead_markings)
447
+ from ...mining.definitions import FOR_TITLE
448
+ for name, value, detail in properties.lines():
449
+ key = FOR_TITLE.get(name)
450
+ filled = instances.properties(properties, key) if key else None
451
+ if name == "Deadlock-free" and only_final:
452
+ # A WF-net always stops in [o]: that is the case finishing.
453
+ self.properties_card.add(Verdict(
454
+ name, "info", "the only dead marking is "
455
+ f"[{escape(self._plain(workflow.sink))}]: the case has finished. Every "
456
+ f"WF-net stops there, so this is expected; {NBAR} above shows whether it "
457
+ "can get stuck anywhere else.", instance=filled))
458
+ continue
459
+ self.properties_card.add(Verdict(name, status_for(value), detail,
460
+ instance=filled))
461
+ self.properties_card.add(label(
462
+ f"{len(properties.graph.states):,} reachable markings "
463
+ f"({properties.graph.kind} graph).", "muted", wrap=True))
464
+
465
+ if footprint is None:
466
+ self.footprint_card.add(label(footprint_error or "", "muted", wrap=True))
467
+ elif not footprint.activities:
468
+ self.footprint_card.add(label("No visible transitions yet.", "muted"))
469
+ else:
470
+ table = footprint_table(footprint, compact=True)
471
+ rows = len(footprint.activities)
472
+ table.setMinimumHeight(min(440, 36 + 30 * rows))
473
+ self.footprint_card.add(table)
474
+ abbreviated = any(len(a) > 3 for a in footprint.activities)
475
+ self.footprint_card.add(label(
476
+ ("Columns use initials (hover for the name). " if abbreviated else "")
477
+ + "→ causality ← inverse ‖ parallel # choice. Start: "
478
+ f"{', '.join(sorted(footprint.start)) or '–'} · end: "
479
+ f"{', '.join(sorted(footprint.end)) or '–'}", "muted", wrap=True))
480
+
481
+ def _show_invariants(self, drawn, closed) -> None:
482
+ """P-invariants of the net; T-invariants of N̄ for a WF-net (a WF-net
483
+ itself has none: nothing leads back from [o])."""
484
+ card = self.invariants_card
485
+ self._invariants = drawn
486
+
487
+ def names(ids) -> str:
488
+ return ", ".join(escape(drawn.net.node_name(node)) for node in ids)
489
+
490
+ covered = drawn.covered_by_p_invariants
491
+ uncovered = drawn.uncovered_places()
492
+ card.add(Verdict(
493
+ "Covered by P-invariants", status_for(covered),
494
+ "too many invariants to list" if covered is None else
495
+ "every place is in one, so the net is bounded from any initial marking"
496
+ if covered else f"in no P-invariant: {names(uncovered)}",
497
+ definition="p_invariant"))
498
+ for invariant in drawn.p_invariants[:12]:
499
+ card.add(label("• " + escape(drawn.describe(invariant, with_value=True)), "muted",
500
+ wrap=True, selectable=True))
501
+ if len(drawn.p_invariants) > 12:
502
+ card.add(label(f"… and {len(drawn.p_invariants) - 12} more", "muted"))
503
+
504
+ target, where = (closed, NBAR) if closed is not None else (drawn, "the net")
505
+ covered = target.covered_by_t_invariants
506
+ missing = [t for t in target.uncovered_transitions()]
507
+ if covered is None:
508
+ detail = "too many invariants to list"
509
+ elif covered:
510
+ detail = (f"every transition of {where} is in one" + (
511
+ f": a sound WF-net needs this ({NBAR} is live and bounded), but it does not "
512
+ "prove soundness" if closed is not None else ""))
513
+ else:
514
+ detail = f"in no T-invariant of {where}: {names(missing)}" + (
515
+ f", so {NBAR} is not live and bounded and the WF-net is not sound"
516
+ if closed is not None else ", so the net is not both live and bounded")
517
+ card.add(Verdict("Covered by T-invariants", status_for(covered),
518
+ f"<span>{detail}</span>", definition="t_invariant"))
519
+ for invariant in target.t_invariants[:12]:
520
+ card.add(label("• " + escape(target.describe(invariant)), "muted", wrap=True,
521
+ selectable=True))
522
+ if len(target.t_invariants) > 12:
523
+ card.add(label(f"… and {len(target.t_invariants) - 12} more", "muted"))
524
+ card.add(flow(button("Incidence matrix…", self.show_incidence_matrix,
525
+ tooltip="What firing each transition does to each place")))
526
+
527
+ def show_incidence_matrix(self) -> None:
528
+ """The incidence matrix C (places × transitions) in a dialog."""
529
+ from PySide6.QtWidgets import QAbstractItemView, QDialogButtonBox, QTableWidget, \
530
+ QTableWidgetItem
531
+ found = getattr(self, "_invariants", None) or invariants(self.petri_net())
532
+ net = found.net
533
+ dialog = QDialog(self)
534
+ dialog.setWindowTitle(f"Incidence matrix of {self.net.name}")
535
+ table = QTableWidget(len(found.places), len(found.transitions))
536
+ table.setHorizontalHeaderLabels([net.node_name(t) for t in found.transitions])
537
+ table.setVerticalHeaderLabels([net.node_name(p) for p in found.places])
538
+ table.setEditTriggers(QAbstractItemView.NoEditTriggers)
539
+ for i, row in enumerate(found.incidence):
540
+ for j, value in enumerate(row):
541
+ cell = QTableWidgetItem("0" if not value else f"{value:+d}")
542
+ cell.setTextAlignment(Qt.AlignCenter)
543
+ table.setItem(i, j, cell)
544
+ table.resizeColumnsToContents()
545
+ buttons = QDialogButtonBox(QDialogButtonBox.Close)
546
+ buttons.rejected.connect(dialog.reject)
547
+ layout = QVBoxLayout(dialog)
548
+ layout.addWidget(label("C(p, t) = tokens t puts into p − tokens t takes from p. "
549
+ "Firing t adds column t to the marking.", "muted", wrap=True))
550
+ layout.addWidget(table)
551
+ layout.addWidget(buttons)
552
+ dialog.resize(min(900, 140 + 80 * len(found.transitions)),
553
+ min(640, 140 + 32 * len(found.places)))
554
+ dialog.show()
555
+ self._matrix_dialog = dialog
556
+
557
+ def _name(self, node_id: str | None) -> str:
558
+ if node_id is None:
559
+ return "?"
560
+ node = self.net.find_place(node_id) or self.net.find_transition(node_id)
561
+ return f"“{node.name}”" if node is not None else node_id
562
+
563
+ def _plain(self, node_id: str | None) -> str:
564
+ node = node_id and (self.net.find_place(node_id) or self.net.find_transition(node_id))
565
+ return node.name if node else (node_id or "?")
566
+
567
+ def _show_theorem(self, soundness) -> None:
568
+ """The soundness theorem: (N̄, [i]) live and bounded iff sound."""
569
+ card = self.theorem_card
570
+ closed = soundness.short_circuit
571
+ if closed is None:
572
+ card.add(label("Only for WF-nets.", "muted"))
573
+ return
574
+ graph = closed.properties.graph
575
+ net = closed.net
576
+ live = closed.live
577
+ not_live = [TSTAR if t == "t_star" else escape(net.node_name(t))
578
+ for t in closed.not_live]
579
+ filled = lambda key: instances.short_circuit(soundness, key) # noqa: E731
580
+ card.add(_math("Live", status_for(live),
581
+ f"from every reachable marking, every transition ({TSTAR} too) can "
582
+ "fire again" if live else
583
+ (f"not decided: {NBAR} is unbounded or too big" if live is None else
584
+ "can never fire again at some point: " + ", ".join(not_live)),
585
+ instance=filled("live")))
586
+ card.add(_math("Bounded", status_for(closed.bounded),
587
+ f"at most {int(closed.properties.bound)} token(s) per place"
588
+ if closed.bounded else
589
+ "tokens can pile up: a case can finish with tokens left behind, "
590
+ f"and {TSTAR} then starts the next case on top of them",
591
+ instance=filled("bounded")))
592
+ theorem = closed.sound
593
+ card.add(_math("Live and bounded ⇒ sound" if theorem else
594
+ ("Unbounded ⇒ not sound" if not closed.bounded else
595
+ "Not live ⇒ not sound" if theorem is False else "Undecided"),
596
+ status_for(theorem),
597
+ "the same verdict as the soundness check above" if theorem ==
598
+ soundness.sound else "differs from the soundness check above (a "
599
+ "state-space limit was hit)", definition="soundness_theorem",
600
+ instance=filled("soundness_theorem")))
601
+ card.add(_math("Safe", "good" if closed.safe else "info",
602
+ "at most one token per place (a sound free-choice or "
603
+ "well-structured net is always safe)" if closed.safe else
604
+ "some place can hold two or more tokens", instance=filled("safe")))
605
+ dead = closed.properties.dead_markings
606
+ card.add(_math("Deadlock-free", "good" if not dead else "info",
607
+ "no marking where nothing can fire. Weaker than live: a net can "
608
+ "keep going while some transition is dead, so the soundness "
609
+ "theorem asks for live" if not dead else
610
+ f"stuck in {escape(graph.states[dead[0]].describe(net))}; a deadlock is "
611
+ "never live, so this alone already means not sound",
612
+ instance=filled("deadlock_free")))
613
+ for transition, state in list(closed.not_live.items())[:4]:
614
+ path = graph.path_to(state)
615
+ usable = "t_star" not in path
616
+ who = TSTAR if transition == "t_star" else escape(net.node_name(transition))
617
+ self._finding(card, f"<span>{who} can never fire again after "
618
+ f"{escape(graph.describe_path(state))} (marking "
619
+ f"{escape(graph.states[state].describe(net))}).</span>",
620
+ path if usable else None, soundness.workflow.source)
621
+ card.add(flow(button("Open the short-circuited net", self._open_short_circuited,
622
+ tooltip="Draw the net with t* added, in a new tab")))
623
+
624
+ def _open_short_circuited(self) -> None:
625
+ from ...mining.analysis import short_circuit
626
+ from ...model.plain import from_petri_net
627
+ from .documents import CpnDocument
628
+ petri = self.petri_net()
629
+ workflow = check_workflow_net(petri)
630
+ if not workflow.is_workflow_net:
631
+ return
632
+ closed = short_circuit(petri, workflow.source, workflow.sink)
633
+ closed.name = f"{self.net.name} — short-circuited"
634
+ self.open_model.emit(CpnDocument(from_petri_net(closed)))
635
+
636
+ def _show_structure(self, soundness) -> None:
637
+ """Free-choice, well-structured, S-coverable, and the start and end rule."""
638
+ card = self.structure_card
639
+ report = soundness.structure
640
+ if report is None:
641
+ card.add(label("Only for WF-nets.", "muted"))
642
+ return
643
+ name = self._plain
644
+
645
+ def node(prefixed: str) -> str:
646
+ return TSTAR if prefixed == "t:t_star" else escape(name(prefixed[2:]))
647
+
648
+ if report.is_free_choice:
649
+ detail = ("transitions that share an input place have the same input places, "
650
+ "so every choice is free")
651
+ else:
652
+ t1, t2, p = report.free_choice[0]
653
+ detail = (f"{escape(name(t1))} and {escape(name(t2))} share {escape(name(p))} but "
654
+ "need different input "
655
+ "places, so the choice between them depends on what happened "
656
+ "elsewhere")
657
+ net = self.petri_net()
658
+ card.add(_math("Free-choice", "good" if report.is_free_choice else "info", detail,
659
+ instance=instances.structure(soundness, net, "free_choice")))
660
+
661
+ well = report.well_structured
662
+ if well is None:
663
+ detail = "not checked (the net is very large)"
664
+ elif well:
665
+ detail = (f"{NBAR} has no handles: every AND-split is closed by an AND-join and every "
666
+ "OR-split by an OR-join")
667
+ else:
668
+ handle = report.handles[0]
669
+ kind = ("an OR-split (place) later synchronised by an AND-join (transition)"
670
+ if handle.kind == "PT" else
671
+ "an AND-split (transition) later merged by an OR-join (place)")
672
+ detail = (f"{handle.kind}-handle from {node(handle.start)} to "
673
+ f"{node(handle.end)}: {kind}. Paths "
674
+ + " and ".join("⟨" + ", ".join(node(n) for n in path) + "⟩"
675
+ for path in handle.paths))
676
+ card.add(_math("Well-structured", "unknown" if well is None else
677
+ ("good" if well else "info"), detail,
678
+ instance=instances.structure(soundness, net, "well_structured")))
679
+
680
+ cover = report.s_coverable
681
+ if cover is None:
682
+ detail = "not decided (search limit reached)"
683
+ elif cover:
684
+ detail = (f"{len(report.coverage.components)} S-component(s) cover {NBAR}: each is "
685
+ "a 'thread' that is always in exactly one place")
686
+ else:
687
+ missing = ", ".join(TSTAR if n == "t_star" else escape(name(n))
688
+ for n in report.coverage.uncovered[:6])
689
+ detail = (f"no S-component contains {missing}. Unsound nets are often not "
690
+ "S-coverable, so this is worth a close look")
691
+ card.add(_math("S-coverable", "unknown" if cover is None else
692
+ ("good" if cover else "warning"), detail,
693
+ instance=instances.structure(soundness, net, "s_coverable")))
694
+ for transition in report.lemma4:
695
+ self._finding(card, f"<span>Start and end rule: {escape(name(transition))} needs {I} or "
696
+ f"{O} together with another place, which never happens in a sound "
697
+ "net — it is dead.</span>")
698
+ if soundness.sound and (report.is_free_choice or well):
699
+ self._finding(card, "Sound and " + ("free-choice" if report.is_free_choice
700
+ else "well-structured")
701
+ + ", so the net is safe.")
702
+
703
+ # -- counterexamples in the token game --------------------------------------------------
704
+ def replay(self, path: list[str], source: str | None = None) -> None:
705
+ """Fire ``path`` (transition ids) from the start, in Step through mode."""
706
+ if source is not None:
707
+ place = self.net.find_place(source)
708
+ wanted = {p.id: (1 if p.id == source else 0) for p in self.net.all_places()}
709
+ drawn = {p.id: token_count(p.initial_marking_text) for p in self.net.all_places()}
710
+ if place is not None and drawn != wanted:
711
+ # Soundness is about one token in the source: start there.
712
+ self._push_undo(self._snapshot())
713
+ for other in self.net.all_places():
714
+ other.initial_marking_text = tokens_text(wanted[other.id])
715
+ self._edited()
716
+ self.status.emit(f"Initial marking set to one token in {place.name} to "
717
+ f"replay the counterexample (undo brings yours back)")
718
+ self.mode_switch.set_index(1)
719
+ self.simulator.reset()
720
+ names = []
721
+ for transition_id in path:
722
+ transition = self.net.find_transition(transition_id)
723
+ elements = self.simulator.enabled_bindings(transition) if transition else []
724
+ if not elements:
725
+ break
726
+ self.simulator.step(elements[0])
727
+ names.append(transition.name or "τ")
728
+ self.refresh_simulation()
729
+ self.inspector_tabs.set_index(0)
730
+ self.status.emit("Counterexample: " + (" → ".join(names) or "(initial marking)")
731
+ + " — this is the problem marking")
732
+
733
+ # -- more ------------------------------------------------------------------------------
734
+ def show_reachability_graph(self) -> None:
735
+ from ...mining.analysis import coverability_graph, reachability_graph
736
+ from .graph_builders import state_graph_specs
737
+ from .graph_view import GraphView
738
+ petri = self.petri_net()
739
+ if not petri.initial_marking:
740
+ workflow = check_workflow_net(petri)
741
+ if workflow.is_workflow_net:
742
+ petri.initial_marking = Marking({workflow.source: 1})
743
+ graph = reachability_graph(petri, max_states=5_000)
744
+ if graph.has_omega:
745
+ graph = coverability_graph(petri, max_states=5_000)
746
+ dialog = QDialog(self)
747
+ dialog.setWindowTitle(f"Reachability graph — {self.net.name}")
748
+ dialog.resize(900, 640)
749
+ layout = QVBoxLayout(dialog)
750
+ kind = "Coverability graph (ω = unbounded)" if graph.kind == "coverability" \
751
+ else "Reachability graph"
752
+ layout.addWidget(label(f"{kind}: {len(graph.states):,} markings, "
753
+ f"{len(graph.edges):,} steps"
754
+ + (" (stopped at 5,000)" if graph.truncated else "")
755
+ + ". The initial marking is shaded; red = dead.", "muted",
756
+ wrap=True))
757
+ view = GraphView()
758
+ nodes, edges = state_graph_specs(graph)
759
+ view.graph.populate(nodes, edges, layer_gap=120)
760
+ layout.addWidget(view, 1)
761
+ QTimer.singleShot(0, view.fit)
762
+ dialog.exec()
763
+
764
+ def open_as_model(self) -> None:
765
+ """Open the net next to the logs, where conformance checking lives."""
766
+ petri = self.petri_net()
767
+ self.open_model.emit(ModelDocument(petri, origin=f"Drawn in {self.net.name}"))
768
+
769
+ def _export_log(self) -> None:
770
+ """Play the net out: each run from the initial marking is one case."""
771
+ from ...mining.playout import play_out
772
+ count, ok = QInputDialog.getInt(self, "Generate event log",
773
+ "Number of cases to simulate:", 100, 1, 100_000, 50)
774
+ if not ok:
775
+ return
776
+ petri = self.petri_net()
777
+ if not petri.initial_marking:
778
+ workflow = check_workflow_net(petri)
779
+ if workflow.is_workflow_net:
780
+ petri.initial_marking = Marking({workflow.source: 1})
781
+
782
+ def done(result) -> None:
783
+ self.status.emit(f"Generated {len(result.log):,} cases: {result.completed:,} "
784
+ f"completed, {result.deadlocked:,} deadlocked")
785
+ self.log_generated.emit(result.log)
786
+
787
+ run_in_background(lambda: play_out(petri, traces=count,
788
+ name=f"Play-out · {self.net.name}"),
789
+ done, lambda m: self.status.emit(f"Play-out failed: {m}"))
790
+
791
+ # =====================================================================
792
+ # Status, titles, files
793
+ # =====================================================================
794
+ def _fill_problems(self) -> None:
795
+ pass
796
+
797
+ def _update_sim_status(self) -> None:
798
+ if not self.simulating:
799
+ if self.results_hidden("soundness"):
800
+ self.sim_status.setText("") # an exercise may ask exactly this
801
+ return
802
+ workflow = check_workflow_net(self.petri_net())
803
+ self.sim_status.setText("WF-net ✓" if workflow.is_workflow_net else
804
+ "not (yet) a WF-net")
805
+ return
806
+ enabled = len(getattr(self, "enabled_elements", []))
807
+ state = f"{enabled} enabled" if enabled else "✕ dead: nothing is enabled"
808
+ self.sim_status.setText(f"Step {self.simulator.step_count:,} · {state}")
809
+ self.back_button.setEnabled(bool(self.simulator.log))
810
+ self.step_button.setEnabled(bool(enabled))
811
+
812
+ def _subtitle(self) -> str:
813
+ places = sum(1 for _ in self.net.all_places())
814
+ transitions = sum(1 for _ in self.net.all_transitions())
815
+ where = Path(self.document.path).name if self.document.path else "not saved yet"
816
+ state = " · edited" if self.document.dirty and not self.document.autosave else ""
817
+ return (f"Petri net · {places} places · {transitions} transitions · "
818
+ f"{where}{state}")
819
+
820
+ def export(self) -> bool:
821
+ suggested = self.document.path or suggested_path(f"{self.net.name}.pnml")
822
+ if suggested.lower().endswith(".cpn"):
823
+ suggested = suggested[:-4] + ".pnml"
824
+ path, chosen = QFileDialog.getSaveFileName(
825
+ self, "Save Petri net", suggested,
826
+ "PNML Petri net (*.pnml);;CPN Tools model (*.cpn)")
827
+ if not path:
828
+ return False
829
+ if not path.lower().endswith((".pnml", ".cpn")):
830
+ path += ".cpn" if "cpn" in chosen.lower() and "pnml" not in chosen.lower() \
831
+ else ".pnml"
832
+ self._take_file_name(Path(path))
833
+ return self._write(Path(path))
834
+
835
+ def _write(self, path: Path, quiet: bool = False) -> bool:
836
+ if path.suffix.lower() == ".cpn":
837
+ return super()._write(path, quiet)
838
+ try:
839
+ net = self.petri_net()
840
+ atomic_write(path, lambda temporary: write_pnml(net, temporary))
841
+ except Exception as error: # noqa: BLE001
842
+ return self._save_failed(error, quiet)
843
+ self._written(path, quiet)
844
+ return True