openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,502 @@
1
+ """Transition systems and their regions on screen.
2
+
3
+ :class:`RegionsPanel` shows one transition system with everything region
4
+ theory says about it, in the order an exam question asks:
5
+
6
+ 1. the **transition system** itself, drawn with the state-graph canvas
7
+ (states labelled by their abstraction, e.g. ``{a,d}``), with a menu to
8
+ highlight the states one trace of the log passes through;
9
+ 2. **Is this a region?** -- type a set of states, or click states on the
10
+ drawing, and get yes or no; no names the event that breaks it and the two
11
+ transitions that cross the set differently;
12
+ 3. **Elementary?** -- state separation and forward closure, with the states
13
+ or the event that break them;
14
+ 4. **Events** -- for each event its GER, minimal pre-regions, the
15
+ intersection of its pre-regions and its minimal post-regions (the table
16
+ exam answers are written in);
17
+ 5. the **regions**, the minimal ones numbered r1, r2, … as the places of
18
+ 6. the **synthesised net**, and whether its reachability graph is
19
+ isomorphic to the transition system.
20
+
21
+ It is used in the Discover tab (*State-based regions*), in a discovered
22
+ model's Details, and on the page of a typed transition system
23
+ (:class:`TransitionSystemPage`). Every property has its definition on hover
24
+ (:mod:`.definition_view`), filled in for this transition system
25
+ (:func:`.instances.regions`).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ from PySide6.QtCore import QTimer, Qt, Signal
33
+ from PySide6.QtWidgets import (
34
+ QAbstractItemView, QComboBox, QFileDialog, QHeaderView, QLineEdit, QPlainTextEdit,
35
+ QSizePolicy, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
36
+ )
37
+
38
+ from ...mining.discovery.state_regions import RegionResult, region_result
39
+ from ...mining.regions import check_region, format_states
40
+ from ...mining.transition_system import (
41
+ TransitionSystem, parse_states, parse_transition_system,
42
+ )
43
+ from . import instances, style
44
+ from ..learn.concealment import ConcealsResults
45
+ from .definition_view import attach_definition
46
+ from .graph_view import EdgeSpec, GraphView, NodeSpec
47
+ from .widgets import Card, PageHeader, Verdict, button, hbox, label, scroll, status_for, \
48
+ suggested_path
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Drawing a transition system
53
+ # ---------------------------------------------------------------------------
54
+ def ts_specs(ts: TransitionSystem) -> tuple[list[NodeSpec], list[EdgeSpec], dict[str, str]]:
55
+ """Nodes and edges for the canvas, and the node id of each state."""
56
+ t = style.tokens()
57
+ ids = {state: f"s{number}" for number, state in enumerate(ts.states)}
58
+ nodes = []
59
+ for state in ts.states:
60
+ roles = []
61
+ if state in ts.initial:
62
+ roles.append("initial")
63
+ if state in ts.final:
64
+ roles.append("final")
65
+ nodes.append(NodeSpec(ids[state], "state", text=state,
66
+ tooltip=f"State {state}" + (f" ({', '.join(roles)})" if roles
67
+ else "") + "\nClick to add it to "
68
+ "the set to check", fill=t.accent_soft if state in ts.initial
69
+ else None, emphasis=state in ts.final))
70
+ labels: dict[tuple[str, str], list[str]] = {}
71
+ for source, event, target in ts.transitions:
72
+ labels.setdefault((ids[source], ids[target]), []).append(event)
73
+ edges = [EdgeSpec(s, d, text=", ".join(events), width=1.3, tooltip=", ".join(events))
74
+ for (s, d), events in labels.items()]
75
+ return nodes, edges, ids
76
+
77
+
78
+ class TransitionSystemView(QWidget):
79
+ """The drawing, a trace highlighter, and click-to-select states."""
80
+
81
+ #: The set of selected states changed (by clicking).
82
+ selection_changed = Signal(list)
83
+
84
+ def __init__(self, ts: TransitionSystem, parent=None) -> None:
85
+ super().__init__(parent)
86
+ self.ts = ts
87
+ layout = QVBoxLayout(self)
88
+ layout.setContentsMargins(0, 0, 0, 0)
89
+ layout.setSpacing(6)
90
+ self.view = GraphView()
91
+ self.view.setMinimumHeight(260)
92
+ self.nodes, edges, self.ids = ts_specs(ts)
93
+ self.state_of = {node_id: state for state, node_id in self.ids.items()}
94
+ self.view.graph.populate(self.nodes, edges, layer_gap=90)
95
+ self.view.graph.node_clicked.connect(self._clicked)
96
+ self.selected: list[str] = []
97
+ self.trace_box = QComboBox()
98
+ self.trace_box.addItem("Highlight a trace…", None)
99
+ for sequence, count, walk in ts.traces:
100
+ text = "⟨" + ", ".join(sequence) + "⟩" + (f" ×{count}" if count > 1 else "")
101
+ self.trace_box.addItem(text, walk)
102
+ self.trace_box.currentIndexChanged.connect(lambda _i: self.repaint_states())
103
+ self.trace_box.setVisible(bool(ts.traces))
104
+ self.trace_box.setToolTip("Light up the states and transitions one trace passes "
105
+ "through")
106
+ self.trace_info = label("", "muted", wrap=True)
107
+ layout.addWidget(self.view, 1)
108
+ layout.addLayout(hbox(self.trace_box, self.trace_info, stretch_last=True))
109
+ QTimer.singleShot(0, self.view.fit)
110
+
111
+ def _clicked(self, node_id: str) -> None:
112
+ state = self.state_of.get(node_id)
113
+ if state is None:
114
+ return
115
+ if state in self.selected:
116
+ self.selected.remove(state)
117
+ else:
118
+ self.selected.append(state)
119
+ self.repaint_states()
120
+ self.selection_changed.emit(list(self.selected))
121
+
122
+ def set_selected(self, states: list[str]) -> None:
123
+ self.selected = [s for s in states if s in self.ids]
124
+ self.repaint_states()
125
+
126
+ def repaint_states(self) -> None:
127
+ t = style.tokens()
128
+ walk = self.trace_box.currentData() or []
129
+ on_path = set(walk)
130
+ steps = {(self.ids[a], self.ids[b]) for a, b in zip(walk, walk[1:])}
131
+ trace_colour = style.categorical(1)
132
+ selected_fill = style.categorical(0)
133
+ for spec in self.nodes:
134
+ state = self.state_of[spec.id]
135
+ fill = selected_fill if state in self.selected else (
136
+ t.accent_soft if state in self.ts.initial else None)
137
+ stroke = trace_colour if state in on_path else None
138
+ spec_copy = NodeSpec(**{**spec.__dict__, "fill": fill, "stroke": stroke,
139
+ "emphasis": spec.emphasis or state in on_path})
140
+ self.view.graph.update_node(spec_copy)
141
+ for edge in self.view.graph.edges:
142
+ on = (edge.spec.source, edge.spec.target) in steps
143
+ edge.spec.colour = trace_colour if on else None
144
+ edge.spec.width = 2.6 if on else 1.3
145
+ edge.prepareGeometryChange()
146
+ edge.update()
147
+ if walk:
148
+ self.trace_info.setText(" → ".join(walk))
149
+ else:
150
+ self.trace_info.setText("")
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # The panel
155
+ # ---------------------------------------------------------------------------
156
+ def _table(rows: list[list[str]], headers: list[str]) -> QTableWidget:
157
+ table = QTableWidget(len(rows), len(headers))
158
+ table.setHorizontalHeaderLabels(headers)
159
+ table.verticalHeader().setVisible(False)
160
+ table.setEditTriggers(QAbstractItemView.NoEditTriggers)
161
+ table.setSelectionMode(QAbstractItemView.NoSelection)
162
+ table.setWordWrap(True)
163
+ for r, row in enumerate(rows):
164
+ for c, text in enumerate(row):
165
+ item = QTableWidgetItem(text)
166
+ item.setToolTip(text)
167
+ table.setItem(r, c, item)
168
+ table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
169
+ table.horizontalHeader().setStretchLastSection(True)
170
+ table.resizeRowsToContents()
171
+ height = table.horizontalHeader().sizeHint().height() + sum(
172
+ table.rowHeight(r) for r in range(len(rows))) + 2 * table.frameWidth() + 4
173
+ table.setMinimumHeight(min(height, 360))
174
+ table.setMaximumHeight(height + 20)
175
+ return table
176
+
177
+
178
+ class RegionsPanel(ConcealsResults, QWidget):
179
+ """Everything about one transition system's regions (see the module docstring)."""
180
+
181
+ #: "Open as model" on the synthesised net: the window opens it.
182
+ open_net = Signal(object)
183
+
184
+ def __init__(self, result: RegionResult, show_net: bool = True, flat: bool = False,
185
+ parent=None) -> None:
186
+ """``flat``: inside a card already (a derivation), so the sections get
187
+ no frame of their own."""
188
+ super().__init__(parent)
189
+ self.result = result
190
+ ts, analysis = result.ts, result.analysis
191
+ self.ts = ts
192
+ layout = QVBoxLayout(self)
193
+ layout.setContentsMargins(0, 0, 0, 0)
194
+ layout.setSpacing(12)
195
+
196
+ # 1. the transition system
197
+ caption = ts.summary() + (f" · {ts.abstraction}" if ts.abstraction else "") + \
198
+ ". The initial state is shaded, final states have a thick outline."
199
+ system = Card("Transition system", caption)
200
+ attach_definition(system.caption_label, "transition_system",
201
+ instances.regions(analysis, "transition_system"))
202
+ self.system_view = TransitionSystemView(ts)
203
+ system.add(self.system_view, 1)
204
+ layout.addWidget(system, 1)
205
+
206
+ # 2. is this a region?
207
+ checker = Card("Is this a region?", "Type states separated by commas, or click "
208
+ "them on the drawing.")
209
+ self.region_edit = QLineEdit()
210
+ self.region_edit.setPlaceholderText(", ".join(ts.states[:3]) or "s0, s1")
211
+ self.region_edit.returnPressed.connect(self.check_region)
212
+ self.check_button = button("Check", self.check_region, kind="primary")
213
+ checker.add(hbox(self.region_edit, self.check_button))
214
+ self.check_host = QVBoxLayout()
215
+ checker.add(self.check_host)
216
+ self.system_view.selection_changed.connect(self._selection_changed)
217
+ layout.addWidget(checker)
218
+
219
+ if analysis.limit_message:
220
+ layout.addWidget(Verdict("Limit reached", "warning", analysis.limit_message))
221
+
222
+ # 3. elementary?
223
+ elementary = Card("Elementary?", "A Petri net can mimic the transition system "
224
+ "exactly when any two states are separated by a region and every "
225
+ "event's pre-regions meet exactly where it is enabled.")
226
+ self._fill_elementary(elementary)
227
+ self.conceal_card(elementary, "regions")
228
+ layout.addWidget(elementary)
229
+
230
+ # 4. events
231
+ events = Card("Events", "For each event: where it is enabled (GER), its minimal "
232
+ "pre-regions, the intersection of all its pre-regions, and its minimal "
233
+ "post-regions.")
234
+ attach_definition(events.caption_label, "pre_region",
235
+ instances.regions(analysis, "pre_region"))
236
+ order = ts.states
237
+ rows = []
238
+ for item in analysis.by_event.values():
239
+ rows.append([item.event, format_states(item.ger, order),
240
+ ", ".join(format_states(r, order) for r in item.minimal_pre) or "–",
241
+ format_states(item.intersection, order)
242
+ + (" ✓" if item.forward_closed else " ✗ ≠ GER"),
243
+ ", ".join(format_states(r, order) for r in item.minimal_post) or "–"])
244
+ events.add(_table(rows, ["Event", "GER", "Minimal pre-regions", "⋂ pre-regions",
245
+ "Minimal post-regions"]))
246
+ self.conceal_card(events, "regions")
247
+ layout.addWidget(events)
248
+
249
+ # 5. regions
250
+ regions = Card("Regions", f"{len(analysis.regions)} non-trivial region(s), "
251
+ f"{len(analysis.minimal)} minimal. Each minimal region becomes a "
252
+ "place of the net.")
253
+ attach_definition(regions.caption_label, "minimal_region",
254
+ instances.regions(analysis, "minimal_region"))
255
+ lines = [f"r{number} = {format_states(region, order)}"
256
+ for number, region in enumerate(analysis.minimal, 1)]
257
+ regions.add(label("\n".join(lines) or "No non-trivial regions.", wrap=True,
258
+ selectable=True))
259
+ others = [r for r in analysis.regions if r not in set(analysis.minimal)]
260
+ if others:
261
+ shown = others[:60]
262
+ text = "\n".join(format_states(r, order) for r in shown)
263
+ if len(others) > len(shown):
264
+ text += f"\n… and {len(others) - len(shown)} more"
265
+ more = label("Other regions (unions of minimal ones and more):\n" + text, "muted",
266
+ wrap=True, selectable=True)
267
+ regions.add(more)
268
+ self.conceal_card(regions, "regions")
269
+ layout.addWidget(regions)
270
+
271
+ # 6. the net
272
+ synthesis = result.synthesis
273
+ net_card = Card("Synthesised net", "One place per minimal region, an arc from each "
274
+ "pre-region and to each post-region, a token in every minimal region "
275
+ "holding the initial state.")
276
+ attach_definition(net_card.caption_label, "region_synthesis",
277
+ instances.regions(analysis, "region_synthesis", synthesis))
278
+ for warning in result.warnings:
279
+ net_card.add(Verdict("Note", "warning", warning))
280
+ if synthesis.net is not None:
281
+ if synthesis.isomorphic is not None:
282
+ net_card.add(Verdict(
283
+ "Reachability graph ≅ transition system" if synthesis.isomorphic else
284
+ "Reachability graph ≇ transition system",
285
+ status_for(synthesis.isomorphic),
286
+ "the net behaves exactly like the transition system"
287
+ if synthesis.isomorphic else
288
+ "the net's reachability graph is not the transition system (it is not "
289
+ "elementary, or has self-loops)",
290
+ instance=instances.regions(analysis, "region_synthesis", synthesis)))
291
+ if show_net:
292
+ from .graph_builders import petri_net_specs
293
+ preview = GraphView()
294
+ preview.setMinimumHeight(220)
295
+ nodes, edges = petri_net_specs(synthesis.net)
296
+ preview.graph.populate(nodes, edges, layer_gap=48)
297
+ net_card.add(preview)
298
+ QTimer.singleShot(0, preview.fit)
299
+ places = "\n".join(f"{p} = {format_states(region, order)}"
300
+ for p, region in synthesis.places.items())
301
+ net_card.add(label(places, "muted", wrap=True, selectable=True))
302
+ net_card.add(hbox(button("Open as model →", lambda: self.open_net.emit(
303
+ synthesis.net), kind="primary",
304
+ tooltip="Open the net next to your logs (conformance, soundness, "
305
+ "token game)"), None))
306
+ self.conceal_card(net_card, "regions")
307
+ layout.addWidget(net_card)
308
+ layout.addStretch(0)
309
+ if flat:
310
+ layout.setContentsMargins(0, 0, 12, 0) # clear of the scroll bar
311
+ for card in self.findChildren(Card):
312
+ card.setObjectName("flatCard")
313
+ card.body.setContentsMargins(0, 4, 0, 4)
314
+
315
+ def _fill_elementary(self, card: Card) -> None:
316
+ analysis = self.result.analysis
317
+ card.add(Verdict("State separation", status_for(analysis.state_separation),
318
+ "every two states are told apart by some region"
319
+ if analysis.state_separation else
320
+ "undecided (search limit)" if analysis.state_separation is None else "",
321
+ instance=instances.regions(analysis, "state_separation")))
322
+ for pair in analysis.inseparable[:5]:
323
+ card.add(label("• " + pair.explanation(), "muted", wrap=True, selectable=True))
324
+ if len(analysis.inseparable) > 5:
325
+ card.add(label(f"… and {len(analysis.inseparable) - 5} more pairs", "muted"))
326
+ card.add(Verdict("Forward closure", status_for(analysis.forward_closure),
327
+ "for every event, its pre-regions meet exactly in GER(e)"
328
+ if analysis.forward_closure else
329
+ "undecided (search limit)" if analysis.forward_closure is None else "",
330
+ instance=instances.regions(analysis, "forward_closure")))
331
+ order = self.ts.states
332
+ for event, extra in analysis.closure_failures()[:5]:
333
+ item = analysis.by_event[event]
334
+ card.add(label(f"• {event}: ⋂ pre-regions = {format_states(item.intersection, order)}"
335
+ f" but GER({event}) = {format_states(item.ger, order)}; the net "
336
+ f"would also enable {event} in {format_states(extra, order)}.",
337
+ "muted", wrap=True, selectable=True))
338
+ verdict = analysis.elementary
339
+ card.add(Verdict("Elementary" if verdict or verdict is None else "Not elementary",
340
+ status_for(verdict),
341
+ "state separation and forward closure both hold" if verdict else "",
342
+ definition="elementary_ts",
343
+ instance=instances.regions(analysis, "elementary_ts")))
344
+
345
+ # -- is this a region? ----------------------------------------------------------------
346
+ def _selection_changed(self, states: list[str]) -> None:
347
+ self.region_edit.setText(", ".join(states))
348
+ if states:
349
+ self.check_region()
350
+ else:
351
+ self._clear_check()
352
+
353
+ def _clear_check(self) -> None:
354
+ while self.check_host.count():
355
+ item = self.check_host.takeAt(0)
356
+ if item.widget():
357
+ item.widget().hide()
358
+ item.widget().deleteLater()
359
+
360
+ def check_region(self) -> None:
361
+ states = parse_states(self.region_edit.text())
362
+ self.system_view.set_selected(states)
363
+ self._clear_check()
364
+ result = check_region(self.ts, states)
365
+ self.last_check = result
366
+ if result.unknown:
367
+ verdict = Verdict("Not a region", "critical", result.explanation(self.ts.states))
368
+ else:
369
+ verdict = Verdict("Region" if result.is_region else "Not a region",
370
+ status_for(result.is_region), result.explanation(self.ts.states),
371
+ instance=instances.region_check(result, self.ts))
372
+ self.check_host.addWidget(verdict)
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # A typed transition system as a page of its own
377
+ # ---------------------------------------------------------------------------
378
+ class TransitionSystemPage(ConcealsResults, QWidget):
379
+ """Type a transition system (as in an exercise) and study its regions."""
380
+
381
+ status = Signal(str)
382
+ saved = Signal()
383
+ #: A model to open (the synthesised net).
384
+ open_model = Signal(object)
385
+
386
+ def __init__(self, document, parent=None) -> None:
387
+ super().__init__(parent)
388
+ self.document = document
389
+ self.panel: RegionsPanel | None = None
390
+ root = QVBoxLayout(self)
391
+ root.setContentsMargins(0, 0, 0, 0)
392
+ root.setSpacing(0)
393
+ self.header = PageHeader(document.name, "")
394
+ self.header.actions.addWidget(button("Export…", self.export,
395
+ tooltip="Save the transition system as a text "
396
+ "file"))
397
+ root.addWidget(self.header)
398
+
399
+ editor = Card("Transitions", "One per line, or separated by commas: s0 -a-> s1. "
400
+ "Optional lines: initial: s0, final: s7.")
401
+ self.editor = QPlainTextEdit(document.ts.to_text())
402
+ font = self.editor.font()
403
+ font.setFamily("Menlo, Consolas, monospace")
404
+ self.editor.setFont(font)
405
+ self.editor.setMinimumHeight(160)
406
+ editor.add(self.editor, 1)
407
+ self.apply_button = button("Apply", self.apply, kind="primary",
408
+ tooltip="Read the transitions again and recompute the "
409
+ "regions")
410
+ self.error = label("", "muted", wrap=True)
411
+ editor.add(hbox(self.apply_button, self.error, stretch_last=True))
412
+ editor.setMinimumWidth(240)
413
+
414
+ self.panel_host = QWidget()
415
+ self.panel_layout = QVBoxLayout(self.panel_host)
416
+ self.panel_layout.setContentsMargins(0, 0, 4, 0)
417
+ splitter = QSplitter()
418
+ splitter.setHandleWidth(14)
419
+ splitter.setStyleSheet("QSplitter::handle { background: transparent; }")
420
+ splitter.addWidget(editor)
421
+ splitter.addWidget(scroll(self.panel_host))
422
+ splitter.setStretchFactor(1, 1)
423
+ splitter.setSizes([300, 900])
424
+ body = QWidget()
425
+ body.setLayout(hbox(splitter, margins=(20, 0, 20, 16)))
426
+ root.addWidget(body, 1)
427
+ self.refresh_title()
428
+ self._compute()
429
+
430
+ def refresh_title(self) -> None:
431
+ where = Path(self.document.path).name if self.document.path else "not saved to a file"
432
+ self.header.set_text(self.document.name,
433
+ f"Transition system · {self.document.ts.summary()} · {where}")
434
+
435
+ def _compute(self) -> None:
436
+ from .workers import run_in_background
437
+ ts = self.document.ts
438
+ self._show_panel(None)
439
+ self.panel_layout.addWidget(label("Finding the regions…", "muted"))
440
+ run_in_background(lambda: region_result(ts), self._show_panel,
441
+ lambda message: self.error.setText(message))
442
+
443
+ def _show_panel(self, result) -> None:
444
+ while self.panel_layout.count():
445
+ item = self.panel_layout.takeAt(0)
446
+ if item.widget():
447
+ item.widget().hide()
448
+ item.widget().deleteLater()
449
+ self._concealed_cards = []
450
+ self.panel = None
451
+ if result is None:
452
+ return
453
+ self.panel = RegionsPanel(result)
454
+ self.panel.open_net.connect(self._open_net)
455
+ self.panel.set_concealment(getattr(self, "concealment", None))
456
+ self.panel_layout.addWidget(self.panel)
457
+
458
+ def set_concealment(self, concealment) -> None:
459
+ super().set_concealment(concealment)
460
+ if self.panel is not None:
461
+ self.panel.set_concealment(concealment)
462
+
463
+ def _open_net(self, net) -> None:
464
+ from .documents import ModelDocument
465
+ net.name = f"Regions · {self.document.name}"
466
+ self.open_model.emit(ModelDocument(net, origin=f"Regions of “{self.document.name}”",
467
+ derivation=self.panel.result if self.panel else
468
+ None))
469
+
470
+ def apply(self) -> None:
471
+ try:
472
+ ts = parse_transition_system(self.editor.toPlainText(), self.document.name)
473
+ except ValueError as error:
474
+ self.error.setText(str(error))
475
+ return
476
+ self.error.setText("")
477
+ self.document.ts = ts
478
+ self.refresh_title()
479
+ self._compute()
480
+ if self.document.path:
481
+ self.write_to(self.document.path, quiet=True)
482
+
483
+ def export(self) -> None:
484
+ path, _ = QFileDialog.getSaveFileName(
485
+ self, "Export transition system",
486
+ self.document.path or suggested_path(f"{self.document.name}.ts.txt"),
487
+ "Transition system (*.txt)")
488
+ if path:
489
+ if not path.lower().endswith(".txt"):
490
+ path += ".ts.txt"
491
+ self.write_to(path)
492
+
493
+ def write_to(self, path: str, quiet: bool = False) -> None:
494
+ from .workspace import atomic_write
495
+ text = self.document.ts.to_text() + "\n"
496
+ atomic_write(path, lambda temporary: Path(temporary).write_text(text, encoding="utf-8"))
497
+ self.document.path = path
498
+ self.document.missing = False
499
+ self.refresh_title()
500
+ if not quiet:
501
+ self.status.emit(f"Saved {Path(path).name}")
502
+ self.saved.emit()
@@ -0,0 +1,149 @@
1
+ """The sidebar's tree: open documents, files not open yet, and subfolders.
2
+
3
+ Rows carry what they stand for in their item data:
4
+
5
+ * ``Qt.UserRole`` -- the id of an open document;
6
+ * :data:`FILE_ROLE` -- the path of a file in the folder that is not open yet;
7
+ * :data:`FOLDER_ROLE` -- the path of a subfolder (in the Folder view).
8
+
9
+ The tree also handles dragging: rows can be dragged onto a subfolder (to move
10
+ the files on disk) or out of the window (to Finder, say), and files dragged
11
+ in from outside land in the subfolder they are dropped on. What a drop means
12
+ is the window's business: the tree only reports it (:attr:`paths_dropped`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Callable
18
+
19
+ from PySide6.QtCore import QMimeData, QPointF, Qt, QUrl, Signal
20
+ from PySide6.QtGui import QColor, QDrag, QPainter, QPainterPath, QPen
21
+ from PySide6.QtWidgets import QAbstractItemView, QTreeWidget, QTreeWidgetItem
22
+
23
+ from . import style
24
+
25
+ #: Sidebar item data: the path of a file in the folder that is not open yet.
26
+ FILE_ROLE = Qt.UserRole + 1
27
+ #: Sidebar item data: the path of a subfolder.
28
+ FOLDER_ROLE = Qt.UserRole + 2
29
+
30
+
31
+ class SidebarTree(QTreeWidget):
32
+ """A ``QTreeWidget`` whose rows can be dragged to subfolders, Finder or back in."""
33
+
34
+ #: Files were dropped: their paths, the folder they were dropped on (None:
35
+ #: not on a folder), and whether they were dragged from this sidebar.
36
+ paths_dropped = Signal(list, object, bool)
37
+
38
+ def __init__(self, parent=None) -> None:
39
+ super().__init__(parent)
40
+ #: Set by the window: the file or folder a row stands for (None: nothing on disk).
41
+ self.path_for: Callable[[QTreeWidgetItem], str | None] = lambda item: None
42
+ #: Set by the window: the folder a drop on this row (None: the empty
43
+ #: space below the rows) goes into, or None when drops do not go into folders.
44
+ self.drop_folder: Callable[[QTreeWidgetItem | None], str | None] = lambda item: None
45
+ self.setDragEnabled(True)
46
+ self.setAcceptDrops(True)
47
+ self.setDropIndicatorShown(False)
48
+ self.setDragDropMode(QAbstractItemView.DragDrop)
49
+ self._target: QTreeWidgetItem | None = None
50
+
51
+ # -- the ▸ / ▾ of a subfolder ----------------------------------------------------------
52
+ def drawBranches(self, painter: QPainter, rect, index) -> None: # noqa: N802
53
+ """A quiet chevron, and nothing else: Qt's own branch area also painted
54
+ the selected row's highlight in the indentation, so that is covered up."""
55
+ painter.fillRect(rect, QColor(style.tokens().sidebar))
56
+ item = self.itemFromIndex(index)
57
+ if item is None or item.childCount() == 0 or not item.data(0, FOLDER_ROLE):
58
+ return # only subfolders get one, not section headings
59
+ size = 3.5
60
+ centre = QPointF(rect.right() - self.indentation() / 2 + 2, rect.center().y() + 1)
61
+ path = QPainterPath()
62
+ if self.isExpanded(index):
63
+ path.moveTo(centre.x() - size, centre.y() - size / 2)
64
+ path.lineTo(centre.x(), centre.y() + size / 2)
65
+ path.lineTo(centre.x() + size, centre.y() - size / 2)
66
+ else:
67
+ path.moveTo(centre.x() - size / 2, centre.y() - size)
68
+ path.lineTo(centre.x() + size / 2, centre.y())
69
+ path.lineTo(centre.x() - size / 2, centre.y() + size)
70
+ painter.save()
71
+ painter.setRenderHint(QPainter.Antialiasing)
72
+ pen = QPen(QColor(style.tokens().text_secondary), 1.6)
73
+ pen.setCapStyle(Qt.RoundCap)
74
+ pen.setJoinStyle(Qt.RoundJoin)
75
+ painter.setPen(pen)
76
+ painter.drawPath(path)
77
+ painter.restore()
78
+
79
+ # -- dragging out ------------------------------------------------------------------
80
+ def startDrag(self, _actions) -> None: # noqa: N802
81
+ """Drag the selected rows' files.
82
+
83
+ Qt's own implementation removes the rows after a move; here nothing
84
+ changes until the window has moved the files, and the folder watcher
85
+ or the window then updates the rows. Copy is the default, so a drag
86
+ to Finder copies the file (⌘ in Finder makes it a move).
87
+ """
88
+ paths = [self.path_for(item) for item in self.selectedItems()]
89
+ paths = [path for path in paths if path]
90
+ if not paths:
91
+ return
92
+ data = QMimeData()
93
+ data.setUrls([QUrl.fromLocalFile(path) for path in paths])
94
+ drag = QDrag(self)
95
+ drag.setMimeData(data)
96
+ drag.exec(Qt.CopyAction | Qt.MoveAction, Qt.CopyAction)
97
+
98
+ # -- dropping in -------------------------------------------------------------------
99
+ def dragEnterEvent(self, event) -> None: # noqa: N802
100
+ if event.mimeData().hasUrls():
101
+ event.acceptProposedAction()
102
+ else:
103
+ event.ignore()
104
+
105
+ def dragMoveEvent(self, event) -> None: # noqa: N802
106
+ if not event.mimeData().hasUrls():
107
+ event.ignore()
108
+ return
109
+ item = self.itemAt(event.position().toPoint())
110
+ folder = self.drop_folder(item)
111
+ self._highlight(self._row_of_folder(item, folder))
112
+ event.acceptProposedAction()
113
+
114
+ def dragLeaveEvent(self, event) -> None: # noqa: N802
115
+ self._highlight(None)
116
+ super().dragLeaveEvent(event)
117
+
118
+ def dropEvent(self, event) -> None: # noqa: N802
119
+ self._highlight(None)
120
+ paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()]
121
+ if not paths:
122
+ event.ignore()
123
+ return
124
+ folder = self.drop_folder(self.itemAt(event.position().toPoint()))
125
+ internal = event.source() is self
126
+ if internal:
127
+ event.setDropAction(Qt.MoveAction)
128
+ event.accept()
129
+ self.paths_dropped.emit(paths, folder, internal)
130
+
131
+ def _row_of_folder(self, item, folder: str | None):
132
+ """The row to highlight for a drop into ``folder``: the folder's own row."""
133
+ while item is not None and folder is not None:
134
+ if item.data(0, FOLDER_ROLE) == folder:
135
+ return item
136
+ item = item.parent()
137
+ return None
138
+
139
+ def _highlight(self, item: QTreeWidgetItem | None) -> None:
140
+ if item is self._target:
141
+ return
142
+ if self._target is not None:
143
+ try:
144
+ self._target.setData(0, Qt.BackgroundRole, None)
145
+ except RuntimeError: # the row was rebuilt meanwhile
146
+ pass
147
+ self._target = item
148
+ if item is not None:
149
+ item.setBackground(0, style.qc(style.tokens().accent, 0.22))