openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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
|