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
openprocess/model/net.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""The Coloured Petri Net data model.
|
|
2
|
+
|
|
3
|
+
Structure
|
|
4
|
+
---------
|
|
5
|
+
A model (:class:`CPNet`) owns a declaration block and a list of
|
|
6
|
+
:class:`Page` objects. Each page holds :class:`Place`, :class:`Transition` and
|
|
7
|
+
:class:`Arc` objects. Objects are identified by an opaque string ``id`` that
|
|
8
|
+
is stable across save/load, because arcs refer to their endpoints by id and
|
|
9
|
+
CPN Tools' file format does the same.
|
|
10
|
+
|
|
11
|
+
Two representations of every inscription
|
|
12
|
+
----------------------------------------
|
|
13
|
+
Each inscription (initial marking, arc expression, guard, time delay) is kept
|
|
14
|
+
twice:
|
|
15
|
+
|
|
16
|
+
* as the **source text** the modeller typed -- this is what gets saved and what
|
|
17
|
+
the editor shows;
|
|
18
|
+
* as a **parsed AST**, produced lazily by :meth:`CPNet.compile` -- this is what
|
|
19
|
+
the simulator runs.
|
|
20
|
+
|
|
21
|
+
Keeping the text authoritative means a model with one broken inscription still
|
|
22
|
+
loads, still displays, and still lets you fix the broken bit, instead of
|
|
23
|
+
failing to open at all. Compilation errors are collected in
|
|
24
|
+
:attr:`CPNet.errors` rather than raised.
|
|
25
|
+
|
|
26
|
+
Graphics
|
|
27
|
+
--------
|
|
28
|
+
Layout attributes (position, size, colours, fonts) are carried on
|
|
29
|
+
:class:`Graphics` so that the GUI has somewhere to read and write them, and so
|
|
30
|
+
that a file round-trips looking the same. The reader additionally stashes the
|
|
31
|
+
original XML element on each object (``source_element``), which lets the writer
|
|
32
|
+
preserve attributes this implementation does not model.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import itertools
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Any, Iterable, Iterator
|
|
41
|
+
|
|
42
|
+
from ..ml.ast_nodes import Expr
|
|
43
|
+
from ..ml.colorsets import ColourSet
|
|
44
|
+
from ..ml.errors import CPNMLError
|
|
45
|
+
from ..ml.evaluator import Environment, Evaluator
|
|
46
|
+
from ..ml.multiset import Multiset, TimedMultiset
|
|
47
|
+
from ..ml.parser import parse_arc_expression, parse_expression
|
|
48
|
+
from .declarations import DeclarationBlock
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Identifier generation
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
_ID_COUNTER = itertools.count(1)
|
|
54
|
+
#: Ids of the form we make: an element built with one (read from a file)
|
|
55
|
+
#: moves the counter past it.
|
|
56
|
+
_NUMBERED_ID = re.compile(r"ID(\d+)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def new_id(prefix: str = "ID") -> str:
|
|
60
|
+
"""Generate a fresh object id.
|
|
61
|
+
|
|
62
|
+
CPN Tools uses ids of the form ``ID1234567890``. We only need uniqueness
|
|
63
|
+
within a file, so a process-wide counter is enough; the reader preserves
|
|
64
|
+
ids from imported files rather than renumbering them.
|
|
65
|
+
"""
|
|
66
|
+
return f"{prefix}{next(_ID_COUNTER)}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _keep_unique(identifier: str) -> None:
|
|
70
|
+
"""An element was built with ``identifier``: new ids must not repeat it.
|
|
71
|
+
|
|
72
|
+
The counter starts at 1 in every session, while a file saved in an
|
|
73
|
+
earlier one already uses ``ID1``, ``ID2``, ...: a place added after
|
|
74
|
+
opening it could get a transition's id, and the file could no longer be
|
|
75
|
+
saved.
|
|
76
|
+
"""
|
|
77
|
+
global _ID_COUNTER
|
|
78
|
+
match = _NUMBERED_ID.fullmatch(identifier)
|
|
79
|
+
if match is None:
|
|
80
|
+
return
|
|
81
|
+
number = int(match.group(1))
|
|
82
|
+
upcoming = next(_ID_COUNTER)
|
|
83
|
+
_ID_COUNTER = itertools.count(max(upcoming, number + 1))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Graphical attributes
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
@dataclass
|
|
90
|
+
class Graphics:
|
|
91
|
+
"""Layout and appearance of one net element.
|
|
92
|
+
|
|
93
|
+
Coordinates follow CPN Tools' convention: the origin is at the centre of
|
|
94
|
+
the page and **y increases upwards**, which is the opposite of every screen
|
|
95
|
+
coordinate system. The GUI flips the sign when drawing; storing the file's
|
|
96
|
+
convention here keeps the reader and writer trivial.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
x: float = 0.0
|
|
100
|
+
y: float = 0.0
|
|
101
|
+
width: float = 60.0
|
|
102
|
+
height: float = 40.0
|
|
103
|
+
fill_colour: str = "White"
|
|
104
|
+
line_colour: str = "Black"
|
|
105
|
+
line_width: float = 1.0
|
|
106
|
+
text_colour: str = "Black"
|
|
107
|
+
font_size: int = 12
|
|
108
|
+
#: Offsets of the annotation labels relative to the element's own position
|
|
109
|
+
#: (for arcs: absolute positions, since an arc has no position of its own).
|
|
110
|
+
#: Keys are the CPN Tools element names: type, initmark, cond, time, annot,
|
|
111
|
+
#: and token / marking for the current-marking bubble and its text.
|
|
112
|
+
label_offsets: dict[str, tuple[float, float]] = field(default_factory=dict)
|
|
113
|
+
#: Places only: CPN Tools' "hide marking" flag. When set, only the token
|
|
114
|
+
#: count is drawn and the multiset itself appears on hover -- what keeps
|
|
115
|
+
#: busy models readable.
|
|
116
|
+
marking_hidden: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Net elements
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
@dataclass
|
|
123
|
+
class Place:
|
|
124
|
+
"""A place: a typed container of tokens.
|
|
125
|
+
|
|
126
|
+
``colour_set_name`` names an entry in the model's colour set registry. It
|
|
127
|
+
is stored as a *name* rather than as a resolved object so that editing a
|
|
128
|
+
colour set declaration updates every place that uses it without a rebind
|
|
129
|
+
step.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
id: str = field(default_factory=lambda: new_id("ID"))
|
|
133
|
+
name: str = ""
|
|
134
|
+
colour_set_name: str = "UNIT"
|
|
135
|
+
initial_marking_text: str = ""
|
|
136
|
+
graphics: Graphics = field(default_factory=Graphics)
|
|
137
|
+
#: Set when this place is a port (interface to a superpage) in a
|
|
138
|
+
#: hierarchical model: one of ``"In"``, ``"Out"``, ``"I/O"``, ``"General"``.
|
|
139
|
+
port_type: str | None = None
|
|
140
|
+
#: Name of the fusion set this place belongs to, if any. Places in the
|
|
141
|
+
#: same fusion set share a single marking.
|
|
142
|
+
fusion_group: str | None = None
|
|
143
|
+
#: The original XML element, kept for lossless re-serialisation.
|
|
144
|
+
source_element: Any = None
|
|
145
|
+
|
|
146
|
+
# Filled in by :meth:`CPNet.compile`.
|
|
147
|
+
initial_marking_ast: Expr | None = None
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
_keep_unique(self.id)
|
|
151
|
+
|
|
152
|
+
def __hash__(self) -> int:
|
|
153
|
+
return hash(self.id)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Transition:
|
|
158
|
+
"""A transition: an action that consumes and produces tokens.
|
|
159
|
+
|
|
160
|
+
``guard_text`` holds the boolean condition (CPN Tools writes it in square
|
|
161
|
+
brackets on the diagram, but stores it without them). ``time_text`` is the
|
|
162
|
+
transition-level delay ``@+ e`` applied to all output tokens; an output
|
|
163
|
+
arc's own ``@+`` adds to it.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
id: str = field(default_factory=lambda: new_id("ID"))
|
|
167
|
+
name: str = ""
|
|
168
|
+
guard_text: str = ""
|
|
169
|
+
time_text: str = ""
|
|
170
|
+
code_text: str = ""
|
|
171
|
+
priority_text: str = ""
|
|
172
|
+
graphics: Graphics = field(default_factory=Graphics)
|
|
173
|
+
#: For a substitution transition: the id of the subpage it stands for.
|
|
174
|
+
substitution_subpage: str | None = None
|
|
175
|
+
#: Port/socket assignments for a substitution transition, ``socket -> port``.
|
|
176
|
+
port_assignments: dict[str, str] = field(default_factory=dict)
|
|
177
|
+
source_element: Any = None
|
|
178
|
+
#: Plain nets only: a silent (τ) transition, drawn as a black bar and
|
|
179
|
+
#: invisible in the traces it produces.
|
|
180
|
+
silent: bool = False
|
|
181
|
+
|
|
182
|
+
guard_ast: Expr | None = None
|
|
183
|
+
time_ast: Expr | None = None
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def is_substitution(self) -> bool:
|
|
187
|
+
return self.substitution_subpage is not None
|
|
188
|
+
|
|
189
|
+
def __post_init__(self) -> None:
|
|
190
|
+
_keep_unique(self.id)
|
|
191
|
+
|
|
192
|
+
def __hash__(self) -> int:
|
|
193
|
+
return hash(self.id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class Arc:
|
|
198
|
+
"""A directed arc between a place and a transition.
|
|
199
|
+
|
|
200
|
+
``orientation`` mirrors the file format:
|
|
201
|
+
|
|
202
|
+
``"PtoT"``
|
|
203
|
+
place to transition -- an *input* arc; its inscription describes tokens
|
|
204
|
+
that are **consumed**.
|
|
205
|
+
``"TtoP"``
|
|
206
|
+
transition to place -- an *output* arc; tokens are **produced**.
|
|
207
|
+
``"BOTHDIR"``
|
|
208
|
+
a double-headed arc, shorthand for one of each with the same
|
|
209
|
+
inscription. We expand it during simulation rather than in the model,
|
|
210
|
+
so that the diagram keeps the single arc the modeller drew.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
id: str = field(default_factory=lambda: new_id("ID"))
|
|
214
|
+
place_id: str = ""
|
|
215
|
+
transition_id: str = ""
|
|
216
|
+
orientation: str = "PtoT"
|
|
217
|
+
expression_text: str = ""
|
|
218
|
+
graphics: Graphics = field(default_factory=Graphics)
|
|
219
|
+
bendpoints: list[tuple[float, float]] = field(default_factory=list)
|
|
220
|
+
source_element: Any = None
|
|
221
|
+
|
|
222
|
+
expression_ast: Expr | None = None
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def is_input(self) -> bool:
|
|
226
|
+
"""Does this arc take tokens *out* of the place?"""
|
|
227
|
+
return self.orientation in ("PtoT", "BOTHDIR")
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def is_output(self) -> bool:
|
|
231
|
+
"""Does this arc put tokens *into* the place?"""
|
|
232
|
+
return self.orientation in ("TtoP", "BOTHDIR")
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
_keep_unique(self.id)
|
|
236
|
+
|
|
237
|
+
def __hash__(self) -> int:
|
|
238
|
+
return hash(self.id)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class Page:
|
|
243
|
+
"""One diagram page. A model is a forest of pages linked by substitution."""
|
|
244
|
+
|
|
245
|
+
id: str = field(default_factory=lambda: new_id("ID"))
|
|
246
|
+
name: str = "New Page"
|
|
247
|
+
places: list[Place] = field(default_factory=list)
|
|
248
|
+
transitions: list[Transition] = field(default_factory=list)
|
|
249
|
+
arcs: list[Arc] = field(default_factory=list)
|
|
250
|
+
source_element: Any = None
|
|
251
|
+
|
|
252
|
+
def __post_init__(self) -> None:
|
|
253
|
+
_keep_unique(self.id)
|
|
254
|
+
|
|
255
|
+
# -- lookup helpers ------------------------------------------------------
|
|
256
|
+
def place(self, place_id: str) -> Place | None:
|
|
257
|
+
return next((p for p in self.places if p.id == place_id), None)
|
|
258
|
+
|
|
259
|
+
def transition(self, transition_id: str) -> Transition | None:
|
|
260
|
+
return next((t for t in self.transitions if t.id == transition_id), None)
|
|
261
|
+
|
|
262
|
+
def arcs_of(self, transition: Transition) -> list[Arc]:
|
|
263
|
+
return [a for a in self.arcs if a.transition_id == transition.id]
|
|
264
|
+
|
|
265
|
+
def input_arcs(self, transition: Transition) -> list[Arc]:
|
|
266
|
+
return [a for a in self.arcs_of(transition) if a.is_input]
|
|
267
|
+
|
|
268
|
+
def output_arcs(self, transition: Transition) -> list[Arc]:
|
|
269
|
+
return [a for a in self.arcs_of(transition) if a.is_output]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class CompileIssue:
|
|
274
|
+
"""One problem found while compiling inscriptions.
|
|
275
|
+
|
|
276
|
+
Collected rather than raised, so that a model with errors still opens.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
element_id: str
|
|
280
|
+
element_name: str
|
|
281
|
+
field_name: str
|
|
282
|
+
message: str
|
|
283
|
+
|
|
284
|
+
def __str__(self) -> str:
|
|
285
|
+
target = f"{self.element_name or self.element_id}"
|
|
286
|
+
return f"{target} ({self.field_name}): {self.message}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class CPNet:
|
|
290
|
+
"""A complete CPN model: declarations plus pages.
|
|
291
|
+
|
|
292
|
+
Typical use::
|
|
293
|
+
|
|
294
|
+
net = read_cpn("model.cpn") # or build one programmatically
|
|
295
|
+
net.compile() # parse inscriptions, build colour sets
|
|
296
|
+
marking = net.initial_marking()
|
|
297
|
+
sim = Simulator(net, marking)
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(self, name: str = "Untitled", seed: int | None = None) -> None:
|
|
301
|
+
self.name = name
|
|
302
|
+
#: Seed for the model's own random functions (``uniform``, ``discrete``
|
|
303
|
+
#: ...). Kept so that :meth:`compile` can seed the fresh evaluator.
|
|
304
|
+
self.seed = seed
|
|
305
|
+
self.declarations = DeclarationBlock()
|
|
306
|
+
self.pages: list[Page] = []
|
|
307
|
+
#: Drawn and edited as a plain Petri net (black tokens, arc weights;
|
|
308
|
+
#: see :mod:`openprocess.model.plain`) rather than as a coloured net.
|
|
309
|
+
self.plain = False
|
|
310
|
+
#: Names written under places and transitions (and draggable) rather
|
|
311
|
+
#: than inside them.
|
|
312
|
+
self.names_outside = False
|
|
313
|
+
#: Fusion sets: name -> list of place ids that share one marking.
|
|
314
|
+
self.fusion_sets: dict[str, list[str]] = {}
|
|
315
|
+
#: The evaluator holding the standard basis and the model's own
|
|
316
|
+
#: declarations. Recreated by :meth:`compile`.
|
|
317
|
+
self.evaluator = Evaluator(seed=seed)
|
|
318
|
+
self.errors: list[CompileIssue] = []
|
|
319
|
+
#: place id -> the id under which its tokens are stored in a Marking.
|
|
320
|
+
#: Identity for ordinary places; the group representative for places in
|
|
321
|
+
#: a fusion set (see :meth:`marking_key`).
|
|
322
|
+
self._fusion_representative: dict[str, str] = {}
|
|
323
|
+
self._assigned_ports: set[str] = set()
|
|
324
|
+
#: Preserved from the source file so that saving reproduces the header.
|
|
325
|
+
self.source_tree: Any = None
|
|
326
|
+
|
|
327
|
+
# -- traversal -----------------------------------------------------------
|
|
328
|
+
def all_places(self) -> Iterator[Place]:
|
|
329
|
+
for page in self.pages:
|
|
330
|
+
yield from page.places
|
|
331
|
+
|
|
332
|
+
def all_transitions(self) -> Iterator[Transition]:
|
|
333
|
+
for page in self.pages:
|
|
334
|
+
yield from page.transitions
|
|
335
|
+
|
|
336
|
+
def all_arcs(self) -> Iterator[Arc]:
|
|
337
|
+
for page in self.pages:
|
|
338
|
+
yield from page.arcs
|
|
339
|
+
|
|
340
|
+
def page_of(self, element: Place | Transition | Arc) -> Page | None:
|
|
341
|
+
for page in self.pages:
|
|
342
|
+
if element in page.places or element in page.transitions or element in page.arcs:
|
|
343
|
+
return page
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def find_place(self, place_id: str) -> Place | None:
|
|
347
|
+
return next((p for p in self.all_places() if p.id == place_id), None)
|
|
348
|
+
|
|
349
|
+
def find_transition(self, transition_id: str) -> Transition | None:
|
|
350
|
+
return next((t for t in self.all_transitions() if t.id == transition_id), None)
|
|
351
|
+
|
|
352
|
+
def colour_set_of(self, place: Place) -> ColourSet | None:
|
|
353
|
+
return self.declarations.colour_sets.get(place.colour_set_name)
|
|
354
|
+
|
|
355
|
+
# -- compilation ---------------------------------------------------------
|
|
356
|
+
def compile(self) -> list[CompileIssue]:
|
|
357
|
+
"""Build colour sets and parse every inscription.
|
|
358
|
+
|
|
359
|
+
Returns the list of problems found (also stored on :attr:`errors`). An
|
|
360
|
+
empty list means the model is ready to simulate.
|
|
361
|
+
"""
|
|
362
|
+
self.errors = []
|
|
363
|
+
self.evaluator = Evaluator(seed=self.seed)
|
|
364
|
+
self._build_fusion_map()
|
|
365
|
+
|
|
366
|
+
# 1. Declarations first: colour sets, variables, val/fun bindings.
|
|
367
|
+
try:
|
|
368
|
+
self.declarations.compile(self.evaluator)
|
|
369
|
+
except CPNMLError as error:
|
|
370
|
+
self.errors.append(CompileIssue("", self.name, "declarations", str(error)))
|
|
371
|
+
# Fall back to just the standard colour sets so that places whose
|
|
372
|
+
# colour set is INT/BOOL/... still work.
|
|
373
|
+
from ..ml.colorsets import standard_colour_sets
|
|
374
|
+
self.declarations.colour_sets = standard_colour_sets()
|
|
375
|
+
|
|
376
|
+
# 2. Places: colour set existence and the initial marking expression.
|
|
377
|
+
for place in self.all_places():
|
|
378
|
+
place.initial_marking_ast = None
|
|
379
|
+
if place.colour_set_name not in self.declarations.colour_sets:
|
|
380
|
+
self._record(place.id, place.name, "colour set",
|
|
381
|
+
f"unknown colour set '{place.colour_set_name}'")
|
|
382
|
+
text = place.initial_marking_text.strip()
|
|
383
|
+
if text:
|
|
384
|
+
try:
|
|
385
|
+
place.initial_marking_ast = parse_expression(text)
|
|
386
|
+
except CPNMLError as error:
|
|
387
|
+
self._record(place.id, place.name, "initial marking", str(error))
|
|
388
|
+
|
|
389
|
+
# 3. Transitions: guard and time delay.
|
|
390
|
+
for transition in self.all_transitions():
|
|
391
|
+
transition.guard_ast = None
|
|
392
|
+
transition.time_ast = None
|
|
393
|
+
guard = _strip_brackets(transition.guard_text)
|
|
394
|
+
if guard:
|
|
395
|
+
try:
|
|
396
|
+
transition.guard_ast = parse_guard(guard)
|
|
397
|
+
except CPNMLError as error:
|
|
398
|
+
self._record(transition.id, transition.name, "guard", str(error))
|
|
399
|
+
delay = transition.time_text.strip().lstrip("@+").strip()
|
|
400
|
+
if delay:
|
|
401
|
+
try:
|
|
402
|
+
transition.time_ast = parse_expression(delay)
|
|
403
|
+
except CPNMLError as error:
|
|
404
|
+
self._record(transition.id, transition.name, "time delay", str(error))
|
|
405
|
+
|
|
406
|
+
# 4. Arcs: the inscription, which may carry a `@+` delay.
|
|
407
|
+
for arc in self.all_arcs():
|
|
408
|
+
arc.expression_ast = None
|
|
409
|
+
text = arc.expression_text.strip()
|
|
410
|
+
if not text:
|
|
411
|
+
# An empty inscription on an arc into or out of a UNIT place is
|
|
412
|
+
# CPN Tools shorthand for a single black token.
|
|
413
|
+
text = "1`()"
|
|
414
|
+
try:
|
|
415
|
+
arc.expression_ast = parse_arc_expression(text)
|
|
416
|
+
except CPNMLError as error:
|
|
417
|
+
self._record(arc.id, f"arc {arc.id}", "inscription", str(error))
|
|
418
|
+
|
|
419
|
+
return self.errors
|
|
420
|
+
|
|
421
|
+
def _record(self, element_id: str, name: str, field_name: str, message: str) -> None:
|
|
422
|
+
self.errors.append(CompileIssue(element_id, name, field_name, message))
|
|
423
|
+
|
|
424
|
+
# -- fusion sets ---------------------------------------------------------
|
|
425
|
+
def _build_fusion_map(self) -> None:
|
|
426
|
+
"""Work out which places share a marking.
|
|
427
|
+
|
|
428
|
+
Two constructs make several drawn places one place:
|
|
429
|
+
|
|
430
|
+
* A **fusion set** -- places, possibly on different pages, that are
|
|
431
|
+
one place drawn several times.
|
|
432
|
+
* A **substitution transition** stands for a subpage. Each *port*
|
|
433
|
+
place on the subpage is assigned a *socket* place around the
|
|
434
|
+
substitution transition, and is that same place: tokens put into
|
|
435
|
+
the socket are in the port, and the subpage's transitions consume
|
|
436
|
+
and produce them there. This is how a hierarchical model runs as
|
|
437
|
+
one flat net.
|
|
438
|
+
|
|
439
|
+
Both are handled alike: every place of a group gets the same storage
|
|
440
|
+
key in the marking (union-find, so a port of a port of a socket ends
|
|
441
|
+
up with the socket). The key is the socket for ports, and the first
|
|
442
|
+
member for a fusion set, so the choice is deterministic.
|
|
443
|
+
|
|
444
|
+
A subpage used by several substitution transitions would need one
|
|
445
|
+
copy of its places per use; that is reported as a problem rather
|
|
446
|
+
than simulated wrongly with the instances sharing their tokens.
|
|
447
|
+
"""
|
|
448
|
+
self._fusion_representative = {}
|
|
449
|
+
#: Port places assigned to a socket: their own initial marking is
|
|
450
|
+
#: ignored, as in CPN Tools (the socket's counts).
|
|
451
|
+
self._assigned_ports = set()
|
|
452
|
+
parent: dict[str, str] = {}
|
|
453
|
+
|
|
454
|
+
def find(place_id: str) -> str:
|
|
455
|
+
root = place_id
|
|
456
|
+
while parent.get(root, root) != root:
|
|
457
|
+
root = parent[root]
|
|
458
|
+
while parent.get(place_id, place_id) != root: # path compression
|
|
459
|
+
parent[place_id], place_id = root, parent[place_id]
|
|
460
|
+
return root
|
|
461
|
+
|
|
462
|
+
def join(keep: str, other: str) -> None:
|
|
463
|
+
"""Merge the groups; ``keep``'s representative stays."""
|
|
464
|
+
keep_root, other_root = find(keep), find(other)
|
|
465
|
+
parent.setdefault(keep_root, keep_root)
|
|
466
|
+
if keep_root != other_root:
|
|
467
|
+
parent[other_root] = keep_root
|
|
468
|
+
|
|
469
|
+
groups: dict[str, list[str]] = {}
|
|
470
|
+
# Places may declare their group inline (``fusioninfo``) or be listed
|
|
471
|
+
# in a top-level ``<fusion>`` element; merge both sources.
|
|
472
|
+
for place in self.all_places():
|
|
473
|
+
if place.fusion_group:
|
|
474
|
+
groups.setdefault(place.fusion_group, []).append(place.id)
|
|
475
|
+
for group, members in self.fusion_sets.items():
|
|
476
|
+
existing = groups.setdefault(group, [])
|
|
477
|
+
for member in members:
|
|
478
|
+
if member not in existing:
|
|
479
|
+
existing.append(member)
|
|
480
|
+
for members in groups.values():
|
|
481
|
+
for member in members:
|
|
482
|
+
join(members[0], member)
|
|
483
|
+
|
|
484
|
+
pages = {page.id: page for page in self.pages}
|
|
485
|
+
uses: dict[str, list[Transition]] = {}
|
|
486
|
+
for page in self.pages:
|
|
487
|
+
for transition in page.transitions:
|
|
488
|
+
subpage = pages.get(transition.substitution_subpage or "")
|
|
489
|
+
if transition.is_substitution and subpage is None:
|
|
490
|
+
self._record(transition.id, transition.name, "subpage",
|
|
491
|
+
f"its subpage ({transition.substitution_subpage}) is not "
|
|
492
|
+
"in the model")
|
|
493
|
+
if subpage is None:
|
|
494
|
+
continue
|
|
495
|
+
uses.setdefault(subpage.id, []).append(transition)
|
|
496
|
+
if len(uses[subpage.id]) > 1:
|
|
497
|
+
continue # reported below; do not merge instances
|
|
498
|
+
own = {p.id for p in page.places}
|
|
499
|
+
ports = {p.id for p in subpage.places}
|
|
500
|
+
for first, second in transition.port_assignments.items():
|
|
501
|
+
# The file lists (socket, port) pairs; tell them apart by
|
|
502
|
+
# page rather than trusting the order.
|
|
503
|
+
if first in own and second in ports:
|
|
504
|
+
socket, port = first, second
|
|
505
|
+
elif second in own and first in ports:
|
|
506
|
+
socket, port = second, first
|
|
507
|
+
else:
|
|
508
|
+
self._record(transition.id, transition.name, "port assignment",
|
|
509
|
+
f"{first} and {second} are not a socket on its page "
|
|
510
|
+
"and a port on its subpage")
|
|
511
|
+
continue
|
|
512
|
+
join(socket, port)
|
|
513
|
+
self._assigned_ports.add(port)
|
|
514
|
+
for page_id, transitions in uses.items():
|
|
515
|
+
if len(transitions) > 1:
|
|
516
|
+
names = ", ".join(f"'{t.name}'" for t in transitions)
|
|
517
|
+
self._record(transitions[1].id, transitions[1].name, "subpage",
|
|
518
|
+
f"page '{pages[page_id].name}' is used by {len(transitions)} "
|
|
519
|
+
f"substitution transitions ({names}); OpenProcess runs each subpage "
|
|
520
|
+
"once, so give every use its own copy of the page")
|
|
521
|
+
|
|
522
|
+
for place_id in parent:
|
|
523
|
+
self._fusion_representative[place_id] = find(place_id)
|
|
524
|
+
|
|
525
|
+
def marking_key(self, place_id: str) -> str:
|
|
526
|
+
"""The key under which ``place_id``'s tokens live in a :class:`Marking`."""
|
|
527
|
+
return self._fusion_representative.get(place_id, place_id)
|
|
528
|
+
|
|
529
|
+
# -- markings ------------------------------------------------------------
|
|
530
|
+
def initial_marking(self) -> "Marking":
|
|
531
|
+
"""Evaluate every place's initial marking expression.
|
|
532
|
+
|
|
533
|
+
Requires :meth:`compile` to have run. Type-checks each token against
|
|
534
|
+
the place's colour set, because an initial marking that does not fit is
|
|
535
|
+
a modelling error worth catching immediately rather than three firings
|
|
536
|
+
later.
|
|
537
|
+
"""
|
|
538
|
+
marking = Marking()
|
|
539
|
+
for place in self.all_places():
|
|
540
|
+
colour_set = self.colour_set_of(place)
|
|
541
|
+
timed = bool(colour_set and colour_set.timed)
|
|
542
|
+
|
|
543
|
+
# (tokens, time stamp) groups: `1`x@5 +++ 1`y@0` stamps each term.
|
|
544
|
+
groups: list[tuple[Multiset, Any]] = []
|
|
545
|
+
if place.initial_marking_ast is not None:
|
|
546
|
+
from ..ml.evaluator import expand_lists, to_multiset
|
|
547
|
+
from ..ml.multiset import TimedTokens
|
|
548
|
+
value = self.evaluator.evaluate(place.initial_marking_ast, self.evaluator.globals)
|
|
549
|
+
if isinstance(value, TimedTokens):
|
|
550
|
+
groups = value.stamped(0)
|
|
551
|
+
else:
|
|
552
|
+
groups = [(to_multiset(value), 0)]
|
|
553
|
+
groups = [(expand_lists(part, colour_set), stamp) for part, stamp in groups]
|
|
554
|
+
if colour_set is not None:
|
|
555
|
+
for part, _stamp in groups:
|
|
556
|
+
for token, _count in part.items():
|
|
557
|
+
if not colour_set.contains(token):
|
|
558
|
+
self._record(
|
|
559
|
+
place.id, place.name, "initial marking",
|
|
560
|
+
f"token {token} is not a member of '{colour_set.name}'",
|
|
561
|
+
)
|
|
562
|
+
key = self.marking_key(place.id)
|
|
563
|
+
if place.id in self._assigned_ports:
|
|
564
|
+
# A port is its socket: the socket's initial marking counts.
|
|
565
|
+
if key not in marking.place_ids():
|
|
566
|
+
marking.set(key, TimedMultiset.empty() if timed else Multiset.empty())
|
|
567
|
+
continue
|
|
568
|
+
if timed:
|
|
569
|
+
stored = TimedMultiset.empty()
|
|
570
|
+
for part, stamp in groups:
|
|
571
|
+
stored = stored.add(TimedMultiset.from_multiset(part, stamp))
|
|
572
|
+
else:
|
|
573
|
+
stored = Multiset.empty()
|
|
574
|
+
for part, _stamp in groups:
|
|
575
|
+
stored = stored + part
|
|
576
|
+
# Members of a fusion set share one marking. CPN Tools expects their
|
|
577
|
+
# initial marking expressions to agree; if they do not, the first
|
|
578
|
+
# non-empty one wins and the rest are ignored.
|
|
579
|
+
already = marking.get(key) if key in marking.place_ids() else None
|
|
580
|
+
if already is not None and already.size():
|
|
581
|
+
continue
|
|
582
|
+
marking.set(key, stored)
|
|
583
|
+
return marking
|
|
584
|
+
|
|
585
|
+
# -- construction helpers (used by the GUI and by tests) -----------------
|
|
586
|
+
def add_page(self, name: str = "New Page") -> Page:
|
|
587
|
+
page = Page(name=name)
|
|
588
|
+
self.pages.append(page)
|
|
589
|
+
return page
|
|
590
|
+
|
|
591
|
+
def add_declaration(self, source: str) -> None:
|
|
592
|
+
"""Add one declaration, routing it to the right bucket by keyword."""
|
|
593
|
+
from .declarations import classify_declaration, parse_variable_declaration
|
|
594
|
+
kind = classify_declaration(source)
|
|
595
|
+
if kind == "colset":
|
|
596
|
+
# The name is the identifier right after `colset`.
|
|
597
|
+
name = source.split()[1].split("=")[0].strip()
|
|
598
|
+
self.declarations.colour_set_sources.append((name, source))
|
|
599
|
+
elif kind == "var":
|
|
600
|
+
for declaration in parse_variable_declaration(source):
|
|
601
|
+
self.declarations.variable_sources.append(
|
|
602
|
+
(declaration.name, declaration.colour_set_name, source)
|
|
603
|
+
)
|
|
604
|
+
elif kind == "globref":
|
|
605
|
+
body = source.strip()[len("globref"):].strip().rstrip(";")
|
|
606
|
+
name, _, initial = body.partition("=")
|
|
607
|
+
self.declarations.globref_sources.append((name.strip(), initial.strip()))
|
|
608
|
+
else:
|
|
609
|
+
self.declarations.ml_sources.append(source)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class Marking:
|
|
613
|
+
"""A marking: what every place holds right now.
|
|
614
|
+
|
|
615
|
+
Maps place id to a :class:`~openprocess.ml.multiset.Multiset` (untimed place) or a
|
|
616
|
+
:class:`~openprocess.ml.multiset.TimedMultiset` (timed place). Markings are
|
|
617
|
+
treated as immutable values: :meth:`set` returns nothing but is only used
|
|
618
|
+
while building one, and the simulator always constructs a *new* marking for
|
|
619
|
+
the successor state rather than mutating the current one. That is what
|
|
620
|
+
makes state space exploration safe.
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
__slots__ = ("_places", "_hash")
|
|
624
|
+
|
|
625
|
+
def __init__(self, places: dict[str, Any] | None = None) -> None:
|
|
626
|
+
self._places: dict[str, Any] = dict(places or {})
|
|
627
|
+
self._hash: int | None = None
|
|
628
|
+
|
|
629
|
+
def get(self, place_id: str) -> Any:
|
|
630
|
+
return self._places.get(place_id, Multiset.empty())
|
|
631
|
+
|
|
632
|
+
def set(self, place_id: str, tokens: Any) -> None:
|
|
633
|
+
self._places[place_id] = tokens
|
|
634
|
+
self._hash = None
|
|
635
|
+
|
|
636
|
+
def copy(self) -> "Marking":
|
|
637
|
+
return Marking(self._places)
|
|
638
|
+
|
|
639
|
+
def place_ids(self) -> Iterable[str]:
|
|
640
|
+
return self._places.keys()
|
|
641
|
+
|
|
642
|
+
def items(self) -> Iterable[tuple[str, Any]]:
|
|
643
|
+
return sorted(self._places.items())
|
|
644
|
+
|
|
645
|
+
def total_tokens(self) -> int:
|
|
646
|
+
return sum(tokens.size() for tokens in self._places.values())
|
|
647
|
+
|
|
648
|
+
def __eq__(self, other: object) -> bool:
|
|
649
|
+
return isinstance(other, Marking) and self._places == other._places
|
|
650
|
+
|
|
651
|
+
def __hash__(self) -> int:
|
|
652
|
+
if self._hash is None:
|
|
653
|
+
self._hash = hash(frozenset((k, v) for k, v in self._places.items()))
|
|
654
|
+
return self._hash
|
|
655
|
+
|
|
656
|
+
def describe(self, net: CPNet) -> str:
|
|
657
|
+
"""Human-readable dump, using place names rather than ids."""
|
|
658
|
+
lines = []
|
|
659
|
+
for place in net.all_places():
|
|
660
|
+
tokens = self.get(net.marking_key(place.id))
|
|
661
|
+
if tokens.size():
|
|
662
|
+
lines.append(f"{place.name}: {tokens}")
|
|
663
|
+
return "\n".join(lines) if lines else "(all places empty)"
|
|
664
|
+
|
|
665
|
+
def __repr__(self) -> str:
|
|
666
|
+
inner = ", ".join(f"{k}={v}" for k, v in self.items() if v.size())
|
|
667
|
+
return f"Marking({inner})"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def parse_guard(text: str):
|
|
671
|
+
"""Parse a guard, including CPN Tools' *list* form.
|
|
672
|
+
|
|
673
|
+
A guard may be a single boolean expression, ``[x > 0]``, or a comma
|
|
674
|
+
separated list, ``[r = cr + 1, c = cr]``, which means *all* conditions
|
|
675
|
+
must hold. We parse the list form as an ML list and fold it into a chain
|
|
676
|
+
of ``andalso``, so the simulator only ever sees one boolean expression.
|
|
677
|
+
"""
|
|
678
|
+
from ..ml.ast_nodes import BinOp, ListExpr
|
|
679
|
+
try:
|
|
680
|
+
return parse_expression(text)
|
|
681
|
+
except CPNMLError as first_error:
|
|
682
|
+
try:
|
|
683
|
+
as_list = parse_expression("[" + text + "]")
|
|
684
|
+
except CPNMLError:
|
|
685
|
+
raise first_error from None
|
|
686
|
+
if not isinstance(as_list, ListExpr) or not as_list.items:
|
|
687
|
+
raise first_error
|
|
688
|
+
guard = as_list.items[0]
|
|
689
|
+
for condition in as_list.items[1:]:
|
|
690
|
+
guard = BinOp("andalso", guard, condition)
|
|
691
|
+
return guard
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _strip_brackets(text: str) -> str:
|
|
695
|
+
"""Guards are shown as ``[g]`` on the diagram; the brackets are not part
|
|
696
|
+
of the expression. CPN Tools stores them both ways depending on version,
|
|
697
|
+
so we tolerate either."""
|
|
698
|
+
stripped = text.strip()
|
|
699
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
700
|
+
return stripped[1:-1].strip()
|
|
701
|
+
return stripped
|