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,354 @@
1
+ """Place/Transition nets, the model class that discovery produces.
2
+
3
+ Definitions (van der Aalst, *Workflow Verification*, §2)
4
+ -------------------------------------------------------
5
+ A Petri net is a triple ``(P, T, F)``:
6
+
7
+ * ``P`` -- a finite set of **places** (drawn as circles),
8
+ * ``T`` -- a finite set of **transitions** (drawn as squares), disjoint from P,
9
+ * ``F ⊆ (P × T) ∪ (T × P)`` -- the **flow relation** (the arcs).
10
+
11
+ We also allow an arc *weight* (default 1), which the textbook's basic nets do
12
+ not need but PNML files sometimes contain.
13
+
14
+ The **pre-set** ``•x`` of a node is the set of nodes with an arc *into* x,
15
+ the **post-set** ``x•`` those with an arc *out of* x.
16
+
17
+ A **marking** assigns a number of tokens to every place; formally a multiset
18
+ over P, e.g. ``[i]`` or ``[p1, p2^2]``.
19
+
20
+ **Firing rule.** Transition ``t`` is *enabled* in marking M iff every input
21
+ place holds at least as many tokens as the arc weight requires. Firing t
22
+ removes those tokens and adds tokens to every output place::
23
+
24
+ M' = M - •t + t•
25
+
26
+ Labelled nets
27
+ -------------
28
+ In process mining every transition carries an **activity label** (e.g.
29
+ ``"register request"``) -- or no label at all. An unlabelled transition is a
30
+ **silent** (τ, "tau") transition: it moves tokens but leaves no trace in the
31
+ log. Discovery algorithms such as the Inductive Miner use τ transitions to
32
+ express skips and loop-backs. Two transitions may share a label
33
+ (*duplicate* labels); the α-algorithm never produces those, but hand-made
34
+ models can have them.
35
+
36
+ Accepting Petri nets
37
+ --------------------
38
+ For conformance checking a net also needs an **initial** and a **final**
39
+ marking: a log trace "fits" if the model can start in the initial marking,
40
+ replay the trace, and end in the final marking. :class:`PetriNet` therefore
41
+ stores both.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import itertools
47
+ from collections import Counter
48
+ from dataclasses import dataclass, field
49
+ from typing import Iterable, Iterator, Mapping
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Markings
54
+ # ---------------------------------------------------------------------------
55
+ class Marking(Mapping[str, int]):
56
+ """An immutable multiset of place ids.
57
+
58
+ Immutability matters: markings are stored in sets and used as dictionary
59
+ keys during state-space exploration, which requires a hash that never
60
+ changes. Every operation therefore returns a *new* marking.
61
+
62
+ Zero counts are never stored, so ``Marking({"p": 0}) == Marking()``.
63
+ """
64
+
65
+ __slots__ = ("_counts", "_hash")
66
+
67
+ def __init__(self, counts: Mapping[str, int] | Iterable[str] | None = None) -> None:
68
+ if counts is None:
69
+ data: dict[str, int] = {}
70
+ elif isinstance(counts, Mapping):
71
+ # Counts are ints, or math.inf (ω) in coverability graphs.
72
+ data = {place: n for place, n in counts.items() if n}
73
+ else:
74
+ data = dict(Counter(counts))
75
+ self._counts = data
76
+ self._hash: int | None = None
77
+
78
+ # Mapping protocol -------------------------------------------------------
79
+ def __getitem__(self, place: str) -> int:
80
+ return self._counts.get(place, 0)
81
+
82
+ def __iter__(self) -> Iterator[str]:
83
+ return iter(self._counts)
84
+
85
+ def __len__(self) -> int:
86
+ return len(self._counts)
87
+
88
+ def __contains__(self, place: object) -> bool:
89
+ return place in self._counts
90
+
91
+ def __hash__(self) -> int:
92
+ if self._hash is None:
93
+ self._hash = hash(frozenset(self._counts.items()))
94
+ return self._hash
95
+
96
+ def __eq__(self, other: object) -> bool:
97
+ if isinstance(other, Marking):
98
+ return self._counts == other._counts
99
+ if isinstance(other, Mapping):
100
+ return self._counts == {k: v for k, v in other.items() if v}
101
+ return NotImplemented
102
+
103
+ # Multiset algebra -------------------------------------------------------
104
+ def __add__(self, other: Mapping[str, int]) -> "Marking":
105
+ result = dict(self._counts)
106
+ for place, n in other.items():
107
+ result[place] = result.get(place, 0) + n
108
+ return Marking(result)
109
+
110
+ def __sub__(self, other: Mapping[str, int]) -> "Marking":
111
+ """Multiset difference; the caller guarantees ``other ≤ self``."""
112
+ result = dict(self._counts)
113
+ for place, n in other.items():
114
+ result[place] = result.get(place, 0) - n
115
+ return Marking(result)
116
+
117
+ def __ge__(self, other: Mapping[str, int]) -> bool: # type: ignore[override]
118
+ """``self ≥ other`` pointwise: every place has at least as many tokens."""
119
+ return all(self._counts.get(place, 0) >= n for place, n in other.items())
120
+
121
+ def __le__(self, other: Mapping[str, int]) -> bool: # type: ignore[override]
122
+ return all(other.get(place, 0) >= n for place, n in self._counts.items())
123
+
124
+ @property
125
+ def total(self) -> int:
126
+ return sum(self._counts.values())
127
+
128
+ def items(self): # type: ignore[override]
129
+ return self._counts.items()
130
+
131
+ def describe(self, net: "PetriNet | None" = None) -> str:
132
+ """Textbook notation, e.g. ``[p1, p2^2]``; ``[]`` for the empty marking."""
133
+ def name(place_id: str) -> str:
134
+ if net is not None and place_id in net.places:
135
+ return net.places[place_id].name
136
+ return place_id
137
+ parts = []
138
+ for place_id in sorted(self._counts, key=name):
139
+ n = self._counts[place_id]
140
+ count = "ω" if n == float("inf") else str(n)
141
+ parts.append(name(place_id) + (f"^{count}" if n != 1 else ""))
142
+ return "[" + ", ".join(parts) + "]"
143
+
144
+ def __repr__(self) -> str:
145
+ return f"Marking({self._counts!r})"
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Nodes and arcs
150
+ # ---------------------------------------------------------------------------
151
+ @dataclass(eq=False)
152
+ class Place:
153
+ id: str
154
+ name: str
155
+ position: tuple[float, float] | None = None
156
+ #: Where the name label sits, relative to the node (drawing only).
157
+ name_offset: tuple[float, float] | None = None
158
+
159
+
160
+ @dataclass(eq=False)
161
+ class Transition:
162
+ id: str
163
+ name: str
164
+ label: str | None = None # None means silent (τ)
165
+ position: tuple[float, float] | None = None
166
+ #: Where the name label sits, relative to the node (drawing only).
167
+ name_offset: tuple[float, float] | None = None
168
+
169
+ @property
170
+ def silent(self) -> bool:
171
+ return self.label is None
172
+
173
+
174
+ @dataclass(eq=False)
175
+ class Arc:
176
+ source: str # a place id or transition id
177
+ target: str
178
+ weight: int = 1
179
+ #: Bend points from source to target (drawing only; PNML ``<position>``s).
180
+ points: list[tuple[float, float]] = field(default_factory=list)
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # The net
185
+ # ---------------------------------------------------------------------------
186
+ @dataclass(eq=False)
187
+ class PetriNet:
188
+ """A labelled P/T net with an initial and a final marking."""
189
+
190
+ name: str = "Petri net"
191
+ places: dict[str, Place] = field(default_factory=dict)
192
+ transitions: dict[str, Transition] = field(default_factory=dict)
193
+ arcs: list[Arc] = field(default_factory=list)
194
+ initial_marking: Marking = field(default_factory=Marking)
195
+ final_marking: Marking = field(default_factory=Marking)
196
+ #: Free-form provenance, e.g. ``{"algorithm": "Alpha", "log": "..."}``.
197
+ info: dict[str, str] = field(default_factory=dict)
198
+
199
+ def __post_init__(self) -> None:
200
+ self._ids = itertools.count(1)
201
+ self._cache: dict | None = None
202
+
203
+ # -- construction -----------------------------------------------------
204
+ def _fresh_id(self, prefix: str) -> str:
205
+ while True:
206
+ candidate = f"{prefix}{next(self._ids)}"
207
+ if candidate not in self.places and candidate not in self.transitions:
208
+ return candidate
209
+
210
+ def add_place(self, name: str | None = None, id: str | None = None) -> Place:
211
+ place_id = id or self._fresh_id("p")
212
+ place = Place(place_id, name if name is not None else place_id)
213
+ self.places[place_id] = place
214
+ self._cache = None
215
+ return place
216
+
217
+ def add_transition(self, label: str | None, name: str | None = None,
218
+ id: str | None = None) -> Transition:
219
+ """Add a transition; ``label=None`` makes it silent."""
220
+ transition_id = id or self._fresh_id("t")
221
+ display = name if name is not None else (label if label is not None else "τ")
222
+ transition = Transition(transition_id, display, label)
223
+ self.transitions[transition_id] = transition
224
+ self._cache = None
225
+ return transition
226
+
227
+ def add_arc(self, source: Place | Transition | str, target: Place | Transition | str,
228
+ weight: int = 1) -> Arc:
229
+ source_id = source if isinstance(source, str) else source.id
230
+ target_id = target if isinstance(target, str) else target.id
231
+ source_is_place = source_id in self.places
232
+ target_is_place = target_id in self.places
233
+ if source_is_place == target_is_place:
234
+ raise ValueError("An arc must connect a place and a transition "
235
+ f"({source_id!r} -> {target_id!r}).")
236
+ arc = Arc(source_id, target_id, weight)
237
+ self.arcs.append(arc)
238
+ self._cache = None
239
+ return arc
240
+
241
+ def remove_place(self, place_id: str) -> None:
242
+ self.places.pop(place_id, None)
243
+ self.arcs = [a for a in self.arcs if place_id not in (a.source, a.target)]
244
+ self._cache = None
245
+
246
+ def remove_transition(self, transition_id: str) -> None:
247
+ self.transitions.pop(transition_id, None)
248
+ self.arcs = [a for a in self.arcs if transition_id not in (a.source, a.target)]
249
+ self._cache = None
250
+
251
+ # -- structure (cached; rebuilt after any edit) ------------------------
252
+ def _structure(self) -> dict:
253
+ if self._cache is None:
254
+ pre: dict[str, Counter] = {t: Counter() for t in self.transitions}
255
+ post: dict[str, Counter] = {t: Counter() for t in self.transitions}
256
+ place_in: dict[str, set[str]] = {p: set() for p in self.places}
257
+ place_out: dict[str, set[str]] = {p: set() for p in self.places}
258
+ for arc in self.arcs:
259
+ if arc.source in self.transitions: # t -> p
260
+ post[arc.source][arc.target] += arc.weight
261
+ place_in[arc.target].add(arc.source)
262
+ else: # p -> t
263
+ pre[arc.target][arc.source] += arc.weight
264
+ place_out[arc.source].add(arc.target)
265
+ self._cache = {
266
+ "pre": {t: Marking(c) for t, c in pre.items()},
267
+ "post": {t: Marking(c) for t, c in post.items()},
268
+ "place_in": place_in,
269
+ "place_out": place_out,
270
+ }
271
+ return self._cache
272
+
273
+ def pre(self, transition_id: str) -> Marking:
274
+ """The input places of ``t`` with arc weights (•t as a multiset)."""
275
+ return self._structure()["pre"][transition_id]
276
+
277
+ def post(self, transition_id: str) -> Marking:
278
+ """The output places of ``t`` with arc weights (t• as a multiset)."""
279
+ return self._structure()["post"][transition_id]
280
+
281
+ def preset(self, node_id: str) -> set[str]:
282
+ """•x for any node."""
283
+ if node_id in self.transitions:
284
+ return set(self.pre(node_id))
285
+ return set(self._structure()["place_in"][node_id])
286
+
287
+ def postset(self, node_id: str) -> set[str]:
288
+ """x• for any node."""
289
+ if node_id in self.transitions:
290
+ return set(self.post(node_id))
291
+ return set(self._structure()["place_out"][node_id])
292
+
293
+ # -- behaviour --------------------------------------------------------
294
+ def is_enabled(self, marking: Marking, transition_id: str) -> bool:
295
+ return marking >= self.pre(transition_id)
296
+
297
+ def enabled(self, marking: Marking) -> list[str]:
298
+ """Ids of all transitions enabled in ``marking``, in a stable order."""
299
+ return [t for t in self.transitions if marking >= self.pre(t)]
300
+
301
+ def fire(self, marking: Marking, transition_id: str) -> Marking:
302
+ """``M' = M - •t + t•``. Raises if ``t`` is not enabled."""
303
+ pre = self.pre(transition_id)
304
+ if not marking >= pre:
305
+ raise ValueError(f"Transition {self.transitions[transition_id].name!r} "
306
+ f"is not enabled in {marking.describe(self)}.")
307
+ return marking - pre + self.post(transition_id)
308
+
309
+ # -- queries ----------------------------------------------------------
310
+ def labels(self) -> set[str]:
311
+ """The set of visible activity labels."""
312
+ return {t.label for t in self.transitions.values() if t.label is not None}
313
+
314
+ def transitions_with_label(self, label: str) -> list[str]:
315
+ return [t.id for t in self.transitions.values() if t.label == label]
316
+
317
+ def source_places(self) -> list[str]:
318
+ """Places with an empty pre-set."""
319
+ return [p for p in self.places if not self._structure()["place_in"][p]]
320
+
321
+ def sink_places(self) -> list[str]:
322
+ """Places with an empty post-set."""
323
+ return [p for p in self.places if not self._structure()["place_out"][p]]
324
+
325
+ def node_name(self, node_id: str) -> str:
326
+ if node_id in self.places:
327
+ return self.places[node_id].name
328
+ if node_id in self.transitions:
329
+ return self.transitions[node_id].name
330
+ return node_id
331
+
332
+ def copy(self) -> "PetriNet":
333
+ clone = PetriNet(self.name)
334
+ for p in self.places.values():
335
+ clone.places[p.id] = Place(p.id, p.name, p.position)
336
+ for t in self.transitions.values():
337
+ clone.transitions[t.id] = Transition(t.id, t.name, t.label, t.position)
338
+ clone.arcs = [Arc(a.source, a.target, a.weight) for a in self.arcs]
339
+ clone.initial_marking = self.initial_marking
340
+ clone.final_marking = self.final_marking
341
+ clone.info = dict(self.info)
342
+ return clone
343
+
344
+ def summary(self) -> str:
345
+ silent = sum(1 for t in self.transitions.values() if t.silent)
346
+
347
+ def count(number: int, noun: str) -> str:
348
+ return f"{number} {noun}{'' if number == 1 else 's'}"
349
+ return (f"{count(len(self.places), 'place')}, "
350
+ f"{count(len(self.transitions), 'transition')} ({silent} silent), "
351
+ f"{count(len(self.arcs), 'arc')}")
352
+
353
+ def __repr__(self) -> str:
354
+ return f"<PetriNet {self.name!r}: {self.summary()}>"
@@ -0,0 +1,75 @@
1
+ """Play-out: generate an event log by simulating a Petri net.
2
+
3
+ This is the reverse direction of discovery. Starting from the initial
4
+ marking, repeatedly pick a random enabled transition and fire it; the labels
5
+ of the visible transitions fired form one trace. A run ends when
6
+
7
+ * the net reaches its **final marking** (a completed case), or
8
+ * nothing is enabled (a deadlock -- the trace is kept but marked), or
9
+ * the run exceeds ``max_length`` firings (e.g. a loop that could go on forever).
10
+
11
+ Why it is useful in the course: simulate a model, mine the generated log,
12
+ and compare the discovered model with the original. A sound model's log
13
+ rediscovered by the Inductive Miner should give back the same behaviour.
14
+
15
+ Timestamps are synthetic: each case starts ``case_gap`` after the previous
16
+ one and each visible event takes ``event_gap``, so the dotted chart and
17
+ performance views have something regular to show.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import random
23
+ from collections import Counter
24
+ from dataclasses import dataclass
25
+ from datetime import datetime, timedelta, timezone
26
+
27
+ from .log import KEY_NAME, KEY_TIME, Event, EventLog, Trace
28
+ from .petrinet import PetriNet
29
+
30
+
31
+ @dataclass
32
+ class PlayoutResult:
33
+ log: EventLog
34
+ completed: int # runs that reached the final marking
35
+ deadlocked: int # runs that got stuck elsewhere
36
+ cut_off: int # runs stopped at max_length
37
+
38
+
39
+ def random_run(net: PetriNet, rng: random.Random, max_length: int = 200):
40
+ """One random run: (visible labels, how it ended)."""
41
+ marking = net.initial_marking
42
+ labels: list[str] = []
43
+ for _ in range(max_length):
44
+ if net.final_marking and marking == net.final_marking:
45
+ return labels, "completed"
46
+ enabled = net.enabled(marking)
47
+ if not enabled:
48
+ return labels, "deadlocked"
49
+ transition = rng.choice(enabled)
50
+ marking = net.fire(marking, transition)
51
+ label = net.transitions[transition].label
52
+ if label is not None:
53
+ labels.append(label)
54
+ if net.final_marking and marking == net.final_marking:
55
+ return labels, "completed"
56
+ return labels, "cut off"
57
+
58
+
59
+ def play_out(net: PetriNet, traces: int = 100, max_length: int = 200, seed: int | None = None,
60
+ name: str | None = None, start: datetime | None = None,
61
+ case_gap: timedelta = timedelta(minutes=5),
62
+ event_gap: timedelta = timedelta(minutes=1)) -> PlayoutResult:
63
+ rng = random.Random(seed)
64
+ start = start or datetime(2024, 1, 1, 9, 0, tzinfo=timezone.utc)
65
+ log = EventLog(attributes={KEY_NAME: name or f"Play-out of {net.name}"})
66
+ outcomes: Counter = Counter()
67
+ for number in range(traces):
68
+ labels, outcome = random_run(net, rng, max_length)
69
+ outcomes[outcome] += 1
70
+ begin = start + number * case_gap
71
+ trace = Trace({KEY_NAME: f"case {number + 1}", "playout:outcome": outcome})
72
+ for position, label in enumerate(labels):
73
+ trace.events.append(Event({KEY_NAME: label, KEY_TIME: begin + position * event_gap}))
74
+ log.traces.append(trace)
75
+ return PlayoutResult(log, outcomes["completed"], outcomes["deadlocked"], outcomes["cut off"])
@@ -0,0 +1,82 @@
1
+ """Optional bridge to PM4Py for the algorithms not implemented natively.
2
+
3
+ PM4Py is *not* a dependency. If it is installed (``pip install -e ".[pm4py]"``)
4
+ the app offers these extra discovery algorithms:
5
+
6
+ * **Heuristics → Petri net (PM4Py)** -- PM4Py's own heuristics net, to
7
+ compare with the built-in one (:func:`..discovery.heuristics.heuristics_net`);
8
+ * **ILP Miner** -- region-based discovery via integer linear programming;
9
+ * **Split Miner**-style / other miners can be added here the same way.
10
+
11
+ Models travel between the two libraries as PNML text, the neutral exchange
12
+ format, so nothing in the rest of OpenProcess ever imports PM4Py.
13
+
14
+ Licensing note: PM4Py is AGPL-3.0. Importing it at run time from this
15
+ optional module keeps the OpenProcess code base itself independent of it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import tempfile
22
+ from collections import Counter
23
+ from importlib.util import find_spec
24
+
25
+ from .log import SimpleLog
26
+ from .petrinet import PetriNet
27
+ from .pnml import parse_pnml
28
+
29
+
30
+ def available() -> bool:
31
+ return find_spec("pm4py") is not None
32
+
33
+
34
+ def _dataframe(log: SimpleLog):
35
+ import pandas as pd
36
+
37
+ rows, case = [], 0
38
+ base = pd.Timestamp("2000-01-01")
39
+ for sequence, count in log.items():
40
+ for _ in range(count):
41
+ case += 1
42
+ for position, activity in enumerate(sequence):
43
+ rows.append({"case:concept:name": str(case), "concept:name": activity,
44
+ "time:timestamp": base + pd.Timedelta(minutes=position)})
45
+ return pd.DataFrame(rows)
46
+
47
+
48
+ def _to_native(net, initial, final, name: str) -> PetriNet:
49
+ import pm4py
50
+
51
+ handle, path = tempfile.mkstemp(suffix=".pnml")
52
+ os.close(handle)
53
+ try:
54
+ pm4py.write_pnml(net, initial, final, path)
55
+ with open(path, "rb") as stream:
56
+ native = parse_pnml(stream.read())
57
+ finally:
58
+ os.unlink(path)
59
+ native.name = name
60
+ return native
61
+
62
+
63
+ def heuristics_petri_net(log: SimpleLog, dependency_threshold: float = 0.5) -> PetriNet:
64
+ import pm4py
65
+
66
+ net, im, fm = pm4py.discover_petri_net_heuristics(
67
+ _dataframe(log), dependency_threshold=dependency_threshold)
68
+ result = _to_native(net, im, fm, "Heuristics (PM4Py)")
69
+ result.info["algorithm"] = f"Heuristics Miner via PM4Py (threshold {dependency_threshold:g})"
70
+ return result
71
+
72
+
73
+ def ilp_petri_net(log: SimpleLog, alpha: float = 1.0) -> PetriNet:
74
+ import pm4py
75
+
76
+ net, im, fm = pm4py.discover_petri_net_ilp(_dataframe(log), alpha=alpha)
77
+ result = _to_native(net, im, fm, "ILP (PM4Py)")
78
+ result.info["algorithm"] = f"ILP Miner via PM4Py (alpha {alpha:g})"
79
+ return result
80
+
81
+
82
+ __all__ = ["available", "heuristics_petri_net", "ilp_petri_net", "Counter"]