openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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"]
|