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,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