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