openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
openprocess/cli.py
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""Command-line interface: ``openprocess <command> [options]``.
|
|
2
|
+
|
|
3
|
+
The CLI exists so that the engine is usable without the GUI -- in a script, in
|
|
4
|
+
a notebook, from a Makefile, or over SSH. Every command works on a ``.cpn``
|
|
5
|
+
file, except ``example``, which writes one of the built-in demo models so you
|
|
6
|
+
have something to try immediately.
|
|
7
|
+
|
|
8
|
+
Commands
|
|
9
|
+
--------
|
|
10
|
+
``check`` parse a model and list any problems
|
|
11
|
+
``info`` summarise pages, declarations and net elements
|
|
12
|
+
``simulate`` run the simulator and print the firing log
|
|
13
|
+
``statespace`` generate the reachability graph and print the report
|
|
14
|
+
``example`` write a built-in example model to a ``.cpn`` file
|
|
15
|
+
``gui`` launch the CPN editor
|
|
16
|
+
``studio`` launch OpenProcess Studio (the desktop app)
|
|
17
|
+
``mine`` process mining from the command line:
|
|
18
|
+
``stats``, ``filter``, ``discover``, ``conform``,
|
|
19
|
+
``soundness``, ``invariants``
|
|
20
|
+
``exercises`` ``check``, ``marks``, ``import``, ``computes``: exercise packs (OpenProcess Learn)
|
|
21
|
+
``run`` run a ``.cpnflow`` workflow headless; ``--check`` re-runs and
|
|
22
|
+
compares with the record, ``--sweep`` varies a setting,
|
|
23
|
+
``--lock`` writes the requirements the record names
|
|
24
|
+
``boxes`` list the boxes (OpenProcess's own, a folder's ``boxes/``, packages)
|
|
25
|
+
``datasets`` the public logs known by name: ``list``, ``fetch``, ``where``
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from .analysis.state_space import StateSpace
|
|
35
|
+
from .io.cpn_reader import read_cpn
|
|
36
|
+
from .io.cpn_writer import write_cpn
|
|
37
|
+
from .model.net import CPNet
|
|
38
|
+
from .sim.simulator import DeadMarkingError, Simulator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load(path: str) -> CPNet:
|
|
42
|
+
net = read_cpn(path)
|
|
43
|
+
if net.errors:
|
|
44
|
+
print(f"{len(net.errors)} problem(s) found while compiling '{path}':",
|
|
45
|
+
file=sys.stderr)
|
|
46
|
+
for issue in net.errors:
|
|
47
|
+
print(f" {issue}", file=sys.stderr)
|
|
48
|
+
return net
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Commands
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
def command_check(arguments: argparse.Namespace) -> int:
|
|
55
|
+
net = _load(arguments.model)
|
|
56
|
+
if net.errors:
|
|
57
|
+
return 1
|
|
58
|
+
print(f"'{net.name}' compiled cleanly: "
|
|
59
|
+
f"{_count(net.all_places(), 'place')}, "
|
|
60
|
+
f"{_count(net.all_transitions(), 'transition')}, "
|
|
61
|
+
f"{_count(net.all_arcs(), 'arc')}.")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _count(items, noun: str) -> str:
|
|
66
|
+
"""``1 place`` / ``3 places``."""
|
|
67
|
+
number = sum(1 for _ in items)
|
|
68
|
+
return f"{number} {noun}{'' if number == 1 else 's'}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def command_info(arguments: argparse.Namespace) -> int:
|
|
72
|
+
net = _load(arguments.model)
|
|
73
|
+
print(f"Model: {net.name}")
|
|
74
|
+
print(f"Pages: {len(net.pages)}")
|
|
75
|
+
for page in net.pages:
|
|
76
|
+
print(f" {page.name}: {len(page.places)} places, "
|
|
77
|
+
f"{len(page.transitions)} transitions, {len(page.arcs)} arcs")
|
|
78
|
+
|
|
79
|
+
print("\nColour sets:")
|
|
80
|
+
for name, colour_set in sorted(net.declarations.colour_sets.items()):
|
|
81
|
+
finite = "finite" if colour_set.is_finite() else "infinite"
|
|
82
|
+
timed = ", timed" if colour_set.timed else ""
|
|
83
|
+
print(f" {name:<16}{type(colour_set).__name__:<22}{finite}{timed}")
|
|
84
|
+
|
|
85
|
+
print("\nVariables:")
|
|
86
|
+
for name, colour_set_name in sorted(net.declarations.variables.items()):
|
|
87
|
+
print(f" {name} : {colour_set_name}")
|
|
88
|
+
|
|
89
|
+
print("\nInitial marking:")
|
|
90
|
+
print(net.initial_marking().describe(net) or " (empty)")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def command_simulate(arguments: argparse.Namespace) -> int:
|
|
95
|
+
net = _load(arguments.model)
|
|
96
|
+
if net.errors:
|
|
97
|
+
return 1
|
|
98
|
+
simulator = Simulator(net, seed=arguments.seed)
|
|
99
|
+
|
|
100
|
+
print("Initial marking:")
|
|
101
|
+
print(simulator.marking.describe(net))
|
|
102
|
+
print()
|
|
103
|
+
|
|
104
|
+
taken = simulator.run(arguments.steps)
|
|
105
|
+
for record in simulator.log:
|
|
106
|
+
print(record.describe(net))
|
|
107
|
+
|
|
108
|
+
print()
|
|
109
|
+
print(f"Fired {taken} step(s); final state at time {simulator.clock}:")
|
|
110
|
+
print(simulator.marking.describe(net))
|
|
111
|
+
|
|
112
|
+
if taken < arguments.steps:
|
|
113
|
+
print("\nStopped early: the marking is dead (nothing further is enabled).")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def command_statespace(arguments: argparse.Namespace) -> int:
|
|
118
|
+
net = _load(arguments.model)
|
|
119
|
+
if net.errors:
|
|
120
|
+
return 1
|
|
121
|
+
space = StateSpace(net).generate(max_nodes=arguments.max_nodes)
|
|
122
|
+
print(space.report())
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def command_example(arguments: argparse.Namespace) -> int:
|
|
127
|
+
# Imported lazily so that the examples directory is optional at runtime.
|
|
128
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples"))
|
|
129
|
+
try:
|
|
130
|
+
import models # type: ignore[import-not-found]
|
|
131
|
+
except ImportError:
|
|
132
|
+
print("The examples directory is not available.", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
if arguments.name not in models.ALL_EXAMPLES:
|
|
136
|
+
print(f"Unknown example '{arguments.name}'. Available: "
|
|
137
|
+
f"{', '.join(sorted(models.ALL_EXAMPLES))}", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
net = models.ALL_EXAMPLES[arguments.name]()
|
|
141
|
+
path = write_cpn(net, arguments.output)
|
|
142
|
+
print(f"Wrote {path}")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def command_gui(arguments: argparse.Namespace) -> int:
|
|
147
|
+
try:
|
|
148
|
+
from .gui.app import main as gui_main
|
|
149
|
+
except ImportError:
|
|
150
|
+
print("The GUI needs PySide6. Install it with:\n"
|
|
151
|
+
" pip install 'openprocess[app]'", file=sys.stderr)
|
|
152
|
+
return 1
|
|
153
|
+
argv = ["openprocess-cpn"] + ([arguments.model] if arguments.model else [])
|
|
154
|
+
return gui_main(argv)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def command_studio(arguments: argparse.Namespace) -> int:
|
|
158
|
+
try:
|
|
159
|
+
from .gui.studio.app import main as studio_main
|
|
160
|
+
except ImportError:
|
|
161
|
+
print("OpenProcess Studio needs PySide6. Install it with:\n"
|
|
162
|
+
" pip install 'openprocess[app]'", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
return studio_main(["openprocess-studio"] + list(arguments.files))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Process mining commands
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
def _read_log(path: str):
|
|
171
|
+
"""An event log from .xes/.xes.gz/.csv, or textbook notation in a .txt file."""
|
|
172
|
+
from .mining import EventLog, parse_simple_log, read_csv, read_xes
|
|
173
|
+
lower = path.lower()
|
|
174
|
+
if lower.endswith(".csv"):
|
|
175
|
+
return read_csv(path)
|
|
176
|
+
if lower.endswith(".txt"):
|
|
177
|
+
return EventLog.from_simple_log(parse_simple_log(Path(path).read_text(encoding="utf-8")), Path(path).stem)
|
|
178
|
+
return read_xes(path)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def command_mine_stats(arguments: argparse.Namespace) -> int:
|
|
182
|
+
from .mining import summarise
|
|
183
|
+
from .mining.stats import format_duration
|
|
184
|
+
log = _read_log(arguments.log)
|
|
185
|
+
s = summarise(log)
|
|
186
|
+
print(f"{log.name}: {s.case_count:,} cases, {s.event_count:,} events, "
|
|
187
|
+
f"{_plural(s.activity_count, 'activity').replace('activitys', 'activities')}, "
|
|
188
|
+
f"{_plural(s.variant_count, 'variant')} "
|
|
189
|
+
f"(classifier: {log.default_classifier().name})")
|
|
190
|
+
if s.start:
|
|
191
|
+
print(f"From {s.start} to {s.end}; median case duration "
|
|
192
|
+
f"{format_duration(s.median_case_duration)}")
|
|
193
|
+
print("\nActivities:")
|
|
194
|
+
for a in s.activities:
|
|
195
|
+
print(f" {a.name:<30} {a.occurrences:>8,} events {a.cases:>6,} cases")
|
|
196
|
+
print(f"\nTop variants (of {s.variant_count:,}):")
|
|
197
|
+
for variant in s.variants[:arguments.top]:
|
|
198
|
+
print(f" {variant.count:>6,} x " + ", ".join(variant.sequence))
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def command_mine_discover(arguments: argparse.Namespace) -> int:
|
|
203
|
+
from .mining import alpha_miner, inductive_miner, write_pnml
|
|
204
|
+
simple = _read_log(arguments.log).simple_log()
|
|
205
|
+
if arguments.algorithm == "alpha":
|
|
206
|
+
result = alpha_miner(simple)
|
|
207
|
+
for step, content in result.steps():
|
|
208
|
+
print(f"{step}\n {content}")
|
|
209
|
+
for warning in result.warnings:
|
|
210
|
+
print(f"warning: {warning}")
|
|
211
|
+
elif arguments.algorithm == "heuristics":
|
|
212
|
+
from .mining.discovery.heuristics import END, START, heuristics_net
|
|
213
|
+
result = heuristics_net(simple, dependency_threshold=arguments.dependency)
|
|
214
|
+
print(f"Dependency graph: {len(result.graph.edges)} arcs "
|
|
215
|
+
f"(a => b >= {arguments.dependency:g})")
|
|
216
|
+
|
|
217
|
+
def shown(binding) -> str:
|
|
218
|
+
return "{" + ", ".join(sorted("start" if a == START else "end" if a == END
|
|
219
|
+
else a for a in binding)) + "}"
|
|
220
|
+
for activity in sorted((set(result.causal.inputs) | set(result.causal.outputs))
|
|
221
|
+
- {START, END}):
|
|
222
|
+
for side, bindings in (("in ", result.causal.inputs), ("out", result.causal.outputs)):
|
|
223
|
+
listed = " or ".join(f"{shown(b)} x{n}" for b, n in
|
|
224
|
+
bindings.get(activity, {}).most_common())
|
|
225
|
+
print(f" {activity} {side}: {listed or '-'}")
|
|
226
|
+
else:
|
|
227
|
+
noise = arguments.noise if arguments.algorithm == "imf" else 0.0
|
|
228
|
+
result = inductive_miner(simple, noise_threshold=noise)
|
|
229
|
+
print("Process tree:", result.tree)
|
|
230
|
+
print("\n".join(result.trace_of_steps))
|
|
231
|
+
print(f"\nModel: {result.net.summary()}")
|
|
232
|
+
if arguments.output:
|
|
233
|
+
write_pnml(result.net, arguments.output)
|
|
234
|
+
print(f"Wrote {arguments.output}")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def command_mine_conform(arguments: argparse.Namespace) -> int:
|
|
239
|
+
from .mining import align_log, generalisation, precision, read_pnml, simplicity, token_replay
|
|
240
|
+
net = read_pnml(arguments.model)
|
|
241
|
+
simple = _read_log(arguments.log).simple_log()
|
|
242
|
+
replay = token_replay(net, simple)
|
|
243
|
+
alignments = align_log(net, simple)
|
|
244
|
+
print(f"Fitness (alignments) {alignments.average_fitness:.4f} "
|
|
245
|
+
f"log-level {alignments.log_fitness:.4f}")
|
|
246
|
+
print(f"Fitness (token replay) {replay.fitness:.4f} "
|
|
247
|
+
f"p={replay.produced} c={replay.consumed} m={replay.missing} r={replay.remaining}")
|
|
248
|
+
print(f"Precision (ETC) {precision(net, simple):.4f}")
|
|
249
|
+
print(f"Generalisation {generalisation(replay):.4f}")
|
|
250
|
+
print(f"Simplicity {simplicity(net):.4f}")
|
|
251
|
+
print(f"Fitting cases {alignments.fitting_traces} / {alignments.trace_count}")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def command_mine_soundness(arguments: argparse.Namespace) -> int:
|
|
256
|
+
from .mining import check_soundness, read_pnml
|
|
257
|
+
report = check_soundness(read_pnml(arguments.model))
|
|
258
|
+
verdict = {True: "SOUND", False: "NOT SOUND", None: "UNDECIDED"}[report.sound]
|
|
259
|
+
print(verdict)
|
|
260
|
+
for finding in report.findings:
|
|
261
|
+
print(f" - {finding}")
|
|
262
|
+
return 0 if report.sound else 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def command_mine_filter(arguments: argparse.Namespace) -> int:
|
|
266
|
+
from .mining.filtering import FilterSettings, apply_filters
|
|
267
|
+
from .mining.stats import summarise
|
|
268
|
+
|
|
269
|
+
def names(text: str | None) -> set[str] | None:
|
|
270
|
+
return None if text is None else {n.strip() for n in text.split(",") if n.strip()}
|
|
271
|
+
|
|
272
|
+
log = _read_log(arguments.log)
|
|
273
|
+
settings = FilterSettings(
|
|
274
|
+
start_activities=names(arguments.start), end_activities=names(arguments.end),
|
|
275
|
+
variant_coverage=arguments.variants, top_variants=arguments.top_variants)
|
|
276
|
+
for option, mode in (("keep", "keep events"), ("mandatory", "mandatory"),
|
|
277
|
+
("forbidden", "forbidden")):
|
|
278
|
+
if getattr(arguments, option) is not None:
|
|
279
|
+
settings.activities = (names(getattr(arguments, option)), mode)
|
|
280
|
+
if arguments.min_length is not None or arguments.max_length is not None:
|
|
281
|
+
settings.case_length = (arguments.min_length or 0,
|
|
282
|
+
arguments.max_length if arguments.max_length is not None
|
|
283
|
+
else 10**9)
|
|
284
|
+
filtered = apply_filters(log, settings)
|
|
285
|
+
before, after = summarise(log), summarise(filtered)
|
|
286
|
+
print(f"Filters: {filtered.attributes['openprocess:filter']}")
|
|
287
|
+
print(f"Kept {after.case_count:,} of {_plural(before.case_count, 'case')}, "
|
|
288
|
+
f"{_plural(after.event_count, 'event')}, {_plural(after.variant_count, 'variant')}")
|
|
289
|
+
if arguments.output:
|
|
290
|
+
if arguments.output.lower().endswith(".csv"):
|
|
291
|
+
from .mining.csv_import import write_csv
|
|
292
|
+
write_csv(filtered, arguments.output)
|
|
293
|
+
else:
|
|
294
|
+
from .mining import write_xes
|
|
295
|
+
write_xes(filtered, arguments.output)
|
|
296
|
+
print(f"Wrote {arguments.output}")
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def command_mine_invariants(arguments: argparse.Namespace) -> int:
|
|
301
|
+
from .mining import read_pnml
|
|
302
|
+
from .mining.analysis import check_workflow_net, short_circuit
|
|
303
|
+
from .mining.invariants import invariants
|
|
304
|
+
net = read_pnml(arguments.model)
|
|
305
|
+
found = invariants(net)
|
|
306
|
+
names = [net.node_name(t) for t in found.transitions]
|
|
307
|
+
width = max([len(net.node_name(p)) for p in found.places] + [5])
|
|
308
|
+
print("Incidence matrix C (rows: places, columns: transitions)")
|
|
309
|
+
print(" " * width + " " + " ".join(f"{n:>{max(3, len(n))}}" for n in names))
|
|
310
|
+
for place, row in zip(found.places, found.incidence):
|
|
311
|
+
cells = " ".join(f"{value:>{max(3, len(n))}d}" for value, n in zip(row, names))
|
|
312
|
+
print(f"{net.node_name(place):<{width}} {cells}")
|
|
313
|
+
print("\nP-invariants (weighted token count in the initial marking):")
|
|
314
|
+
for invariant in found.p_invariants:
|
|
315
|
+
print(" " + found.describe(invariant, with_value=True))
|
|
316
|
+
uncovered = found.uncovered_places()
|
|
317
|
+
print(" covered by P-invariants: " + ("yes (structurally bounded)" if not uncovered else
|
|
318
|
+
"no, missing " + ", ".join(net.node_name(p) for p in uncovered)))
|
|
319
|
+
workflow = check_workflow_net(net)
|
|
320
|
+
if workflow.is_workflow_net:
|
|
321
|
+
found = invariants(short_circuit(net, workflow.source, workflow.sink))
|
|
322
|
+
print("\nT-invariants of the short-circuited net (with t* from o to i):")
|
|
323
|
+
else:
|
|
324
|
+
print("\nT-invariants:")
|
|
325
|
+
for invariant in found.t_invariants:
|
|
326
|
+
print(" " + found.describe(invariant))
|
|
327
|
+
uncovered = found.uncovered_transitions()
|
|
328
|
+
print(" covered by T-invariants: " + ("yes" if not uncovered else
|
|
329
|
+
"no, missing " + ", ".join(found.net.node_name(t) for t in uncovered)))
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Argument parsing
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
def command_exercises_check(arguments: argparse.Namespace) -> int:
|
|
337
|
+
"""List a pack's exercises and answer boxes; report mistakes in them."""
|
|
338
|
+
from .learn.checks import Context, TaskError, model_answer_text, validate
|
|
339
|
+
from .learn.pack import load_pack
|
|
340
|
+
pack = load_pack(arguments.folder)
|
|
341
|
+
if not pack.exercises:
|
|
342
|
+
print(f"No exercises in {arguments.folder} (an exercise is a folder with a "
|
|
343
|
+
"question.md).", file=sys.stderr)
|
|
344
|
+
return 1
|
|
345
|
+
print(f"{pack.title}: {len(pack.exercises)} exercise(s)")
|
|
346
|
+
problems = 0
|
|
347
|
+
for exercise in pack.exercises:
|
|
348
|
+
chapter = pack.chapter(exercise)
|
|
349
|
+
print(f"\n{chapter + ' › ' if chapter else ''}{exercise.title} "
|
|
350
|
+
f"({exercise.folder.name})")
|
|
351
|
+
found = validate(exercise)
|
|
352
|
+
context = Context(exercise)
|
|
353
|
+
for task in exercise.sheet.tasks:
|
|
354
|
+
checked = "checked" if task.checkable else "self-checked"
|
|
355
|
+
line = f" - {task.id:<8} {task.type:<10} {checked}"
|
|
356
|
+
if arguments.answers and task.type not in ("net", "footprint", "trace", "open"):
|
|
357
|
+
try:
|
|
358
|
+
answer = model_answer_text(exercise, task, context)
|
|
359
|
+
except TaskError as error:
|
|
360
|
+
answer = f"(cannot work it out: {error})"
|
|
361
|
+
line += " → " + " ".join(answer.split())[:100]
|
|
362
|
+
print(line)
|
|
363
|
+
for problem in found:
|
|
364
|
+
print(f" ! {problem}")
|
|
365
|
+
problems += len(found)
|
|
366
|
+
print(f"\n{problems} problem(s) found." if problems else "\nNo problems found.")
|
|
367
|
+
return 1 if problems else 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def command_exercises_marks(arguments: argparse.Namespace) -> int:
|
|
371
|
+
"""The points a pack's answers earn, per exercise and per answer box."""
|
|
372
|
+
from .learn.exam import marks, marks_csv
|
|
373
|
+
from .learn.pack import load_pack
|
|
374
|
+
pack = load_pack(arguments.folder)
|
|
375
|
+
if not pack.exercises:
|
|
376
|
+
print(f"No exercises in {arguments.folder}.", file=sys.stderr)
|
|
377
|
+
return 1
|
|
378
|
+
if arguments.csv:
|
|
379
|
+
print(marks_csv(pack), end="")
|
|
380
|
+
return 0
|
|
381
|
+
student = pack.student()
|
|
382
|
+
print(f"{pack.title}" + (f" — {student}" if student else ""))
|
|
383
|
+
total = earned = 0.0
|
|
384
|
+
for row in marks(pack):
|
|
385
|
+
where = (row["chapter"] + " › " if row["chapter"] else "") + row["exercise"]
|
|
386
|
+
print(f" {where:<56} {row['earned']:>6g} / {row['points']:<6g} "
|
|
387
|
+
f"({row['answered']} of {row['of']} answered)")
|
|
388
|
+
if arguments.blocks:
|
|
389
|
+
for block_id, block in row["blocks"].items():
|
|
390
|
+
print(f" {block_id:<10} {block['status'] or '—':<10} "
|
|
391
|
+
f"{block['earned']:>6g} / {block['points']:g}")
|
|
392
|
+
total += row["points"]
|
|
393
|
+
earned += row["earned"]
|
|
394
|
+
print(f" {'TOTAL':<56} {earned:>6g} / {total:<6g}")
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def command_exercises_import(arguments: argparse.Namespace) -> int:
|
|
399
|
+
"""A past exam's text into a skeleton pack, one exercise per question."""
|
|
400
|
+
from .learn.importer import todo_list, write_pack
|
|
401
|
+
source = Path(arguments.exam)
|
|
402
|
+
try:
|
|
403
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
404
|
+
written = write_pack(text, arguments.target, arguments.title or source.stem)
|
|
405
|
+
except (OSError, ValueError) as error:
|
|
406
|
+
print(f"Could not make a pack from {source}: {error}", file=sys.stderr)
|
|
407
|
+
return 1
|
|
408
|
+
for path in written:
|
|
409
|
+
print(f" wrote {path}")
|
|
410
|
+
todos = todo_list(arguments.target)
|
|
411
|
+
print(f"\n{len(todos)} TODO(s) left for you to finish; then run: openprocess exercises check "
|
|
412
|
+
f"\"{arguments.target}\"")
|
|
413
|
+
for todo in todos:
|
|
414
|
+
print(" " + todo)
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def command_exercises_computes(arguments: argparse.Namespace) -> int:
|
|
419
|
+
"""Every ``compute:`` an answer block may use, with what it works out."""
|
|
420
|
+
from .learn.computed import describe_all
|
|
421
|
+
groups = {"log": "Of the log", "net": "Of the net (net.pnml, or of: file.pnml)",
|
|
422
|
+
"ts": "Of the transition system (ts.txt)", "box": "Of a box or a workflow",
|
|
423
|
+
"": "Other"}
|
|
424
|
+
rows = describe_all()
|
|
425
|
+
for key, heading in groups.items():
|
|
426
|
+
found = [(name, text) for name, of, text in rows if of == key]
|
|
427
|
+
if not found:
|
|
428
|
+
continue
|
|
429
|
+
print(heading)
|
|
430
|
+
for name, text in found:
|
|
431
|
+
print(f" {name:<28} {text}")
|
|
432
|
+
print()
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
# Workflows
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
def command_run(arguments: argparse.Namespace) -> int:
|
|
440
|
+
"""Run a workflow file without the app, and say what every box gave."""
|
|
441
|
+
from .flow import Runner, Sweep, check, differences, library_for, load, save
|
|
442
|
+
from .flow.record import requirements_lock
|
|
443
|
+
path = Path(arguments.workflow)
|
|
444
|
+
folder = path.parent
|
|
445
|
+
library = library_for(folder)
|
|
446
|
+
workflow, record = load(path, library)
|
|
447
|
+
if arguments.lock:
|
|
448
|
+
target = folder / "requirements.lock"
|
|
449
|
+
target.write_text(requirements_lock(record), encoding="utf-8")
|
|
450
|
+
print(f"Wrote {target}")
|
|
451
|
+
return 0
|
|
452
|
+
for spec in arguments.sweep or []:
|
|
453
|
+
name, _, values = spec.partition("=")
|
|
454
|
+
node_id, _, setting = name.rpartition(".")
|
|
455
|
+
targets = [workflow.nodes[node_id]] if node_id in workflow.nodes else \
|
|
456
|
+
[n for n in workflow.nodes.values() if workflow.spec(n).setting(setting) is not None]
|
|
457
|
+
if not targets:
|
|
458
|
+
print(f"openprocess: no box has a setting {setting!r}", file=sys.stderr)
|
|
459
|
+
return 2
|
|
460
|
+
for node in targets:
|
|
461
|
+
node.settings[setting] = Sweep.parse(values)
|
|
462
|
+
for line in differences(record, workflow, folder):
|
|
463
|
+
print(f"note: {line}")
|
|
464
|
+
problems = workflow.validate()
|
|
465
|
+
for line in problems:
|
|
466
|
+
print(f"openprocess: {line}", file=sys.stderr)
|
|
467
|
+
if problems:
|
|
468
|
+
return 2
|
|
469
|
+
run = Runner(library, folder=folder).run(workflow) # file settings are relative to the folder
|
|
470
|
+
for node in workflow.order():
|
|
471
|
+
result = run.result(node)
|
|
472
|
+
value = run.value(node)
|
|
473
|
+
line = f"{workflow.title(node):28} {result.status:8}"
|
|
474
|
+
if result.status == "done":
|
|
475
|
+
line += f" {result.duration * 1000:7.1f} ms {_brief(value)}"
|
|
476
|
+
elif result.error:
|
|
477
|
+
line += f" {result.error}"
|
|
478
|
+
elif result.message:
|
|
479
|
+
line += f" {result.message}"
|
|
480
|
+
print(line)
|
|
481
|
+
if arguments.verbose and result.explanation.notes:
|
|
482
|
+
for note in result.explanation.notes:
|
|
483
|
+
print(f"{'':28} · {note}")
|
|
484
|
+
if len(run.variants) > 1:
|
|
485
|
+
print(f"{len(run.variants)} sweep variants")
|
|
486
|
+
if arguments.output:
|
|
487
|
+
_write_outputs(workflow, run, Path(arguments.output))
|
|
488
|
+
if arguments.export:
|
|
489
|
+
from .flow.record import export_experiment
|
|
490
|
+
print(f"Wrote {export_experiment(workflow, run, arguments.export, folder, library)}")
|
|
491
|
+
if arguments.check:
|
|
492
|
+
report = check(workflow, record, run)
|
|
493
|
+
if report:
|
|
494
|
+
for line in report:
|
|
495
|
+
print(f"DIFFERS: {line}")
|
|
496
|
+
return 1
|
|
497
|
+
print("Every recorded result was reproduced.")
|
|
498
|
+
elif arguments.save:
|
|
499
|
+
save(workflow, path, run, folder)
|
|
500
|
+
print(f"Recorded the results in {path.name}")
|
|
501
|
+
return 0 if run.ok else 1
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _brief(value) -> str:
|
|
505
|
+
from .flow.types import Figure, Scores, Table
|
|
506
|
+
if isinstance(value, Scores):
|
|
507
|
+
return ", ".join(f"{k} {v:.3f}" if isinstance(v, float) else f"{k} {v}" for k, v in value.metrics.items())
|
|
508
|
+
if isinstance(value, Table):
|
|
509
|
+
return f"table {len(value.rows)} × {len(value.columns)}"
|
|
510
|
+
if isinstance(value, Figure):
|
|
511
|
+
return "figure"
|
|
512
|
+
summary = getattr(value, "summary", None)
|
|
513
|
+
if callable(summary):
|
|
514
|
+
return summary()
|
|
515
|
+
if hasattr(value, "event_count"):
|
|
516
|
+
return f"{len(value)} cases, {value.event_count} events"
|
|
517
|
+
return type(value).__name__ if value is not None else ""
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _write_outputs(workflow, run, folder: Path) -> None:
|
|
521
|
+
from .flow.types import Figure, Scores, Table
|
|
522
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
for node in workflow.order():
|
|
524
|
+
value = run.value(node)
|
|
525
|
+
stem = f"{node.id} {workflow.title(node)}".replace("/", "-")
|
|
526
|
+
if isinstance(value, Table):
|
|
527
|
+
value.to_csv(folder / f"{stem}.csv")
|
|
528
|
+
elif isinstance(value, Scores):
|
|
529
|
+
Table.from_scores([value]).to_csv(folder / f"{stem}.csv")
|
|
530
|
+
elif isinstance(value, Figure):
|
|
531
|
+
value.save(folder / f"{stem}.svg" if value.svg else folder / f"{stem}.png")
|
|
532
|
+
elif value.__class__.__name__ == "PetriNet":
|
|
533
|
+
from .mining.pnml import write_pnml
|
|
534
|
+
write_pnml(value, folder / f"{stem}.pnml")
|
|
535
|
+
print(f"Wrote the results into {folder}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def command_boxes(arguments: argparse.Namespace) -> int:
|
|
539
|
+
from .flow import library_for
|
|
540
|
+
library = library_for(arguments.folder)
|
|
541
|
+
for group, specs in library.by_group().items():
|
|
542
|
+
print(f"{group}")
|
|
543
|
+
for spec in specs:
|
|
544
|
+
print(" " + spec.describe().replace("\n", "\n ") if arguments.verbose else
|
|
545
|
+
f" {spec.name:28} {spec.function.__name__:24} "
|
|
546
|
+
f"{'(' + spec.unavailable_reason + ')' if not spec.available else ''}")
|
|
547
|
+
for broken in library.broken:
|
|
548
|
+
print(f"broken: {broken.file}: {broken.reason}")
|
|
549
|
+
return 0
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def command_datasets(arguments: argparse.Namespace) -> int:
|
|
553
|
+
from .flow import datasets
|
|
554
|
+
if arguments.datasets_command == "list":
|
|
555
|
+
for name in datasets.names():
|
|
556
|
+
entry = datasets.info(name)
|
|
557
|
+
mark = "cached" if (datasets.cache_dir() / entry.file).is_file() else "not fetched"
|
|
558
|
+
print(f"{entry.name:42} {entry.size:>7} {mark}\n {entry.description}\n {entry.page}")
|
|
559
|
+
return 0
|
|
560
|
+
if arguments.datasets_command == "where":
|
|
561
|
+
print(datasets.cache_dir())
|
|
562
|
+
return 0
|
|
563
|
+
try:
|
|
564
|
+
path = datasets.fetch(arguments.name)
|
|
565
|
+
except datasets.DatasetMissing as error:
|
|
566
|
+
print(f"openprocess: {error}", file=sys.stderr)
|
|
567
|
+
return 1
|
|
568
|
+
print(f"{arguments.name}: {path}\nsha256 {datasets.sha256_file(path)}")
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
573
|
+
parser = argparse.ArgumentParser(
|
|
574
|
+
prog="openprocess",
|
|
575
|
+
description="Coloured Petri Net tools: edit, simulate and analyse .cpn models.",
|
|
576
|
+
)
|
|
577
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
578
|
+
|
|
579
|
+
check = subparsers.add_parser("check", help="parse a model and report problems")
|
|
580
|
+
check.add_argument("model")
|
|
581
|
+
check.set_defaults(handler=command_check)
|
|
582
|
+
|
|
583
|
+
info = subparsers.add_parser("info", help="summarise a model")
|
|
584
|
+
info.add_argument("model")
|
|
585
|
+
info.set_defaults(handler=command_info)
|
|
586
|
+
|
|
587
|
+
simulate = subparsers.add_parser("simulate", help="run the simulator")
|
|
588
|
+
simulate.add_argument("model")
|
|
589
|
+
simulate.add_argument("-n", "--steps", type=int, default=50,
|
|
590
|
+
help="maximum number of steps (default 50)")
|
|
591
|
+
simulate.add_argument("--seed", type=int, default=None,
|
|
592
|
+
help="seed for reproducible random choices")
|
|
593
|
+
simulate.set_defaults(handler=command_simulate)
|
|
594
|
+
|
|
595
|
+
statespace = subparsers.add_parser("statespace", help="generate the state space")
|
|
596
|
+
statespace.add_argument("model")
|
|
597
|
+
statespace.add_argument("--max-nodes", type=int, default=10_000,
|
|
598
|
+
help="stop after this many nodes (default 10000)")
|
|
599
|
+
statespace.set_defaults(handler=command_statespace)
|
|
600
|
+
|
|
601
|
+
example = subparsers.add_parser("example", help="write a built-in example model")
|
|
602
|
+
example.add_argument("name", help="simple_transfer | dining_philosophers | "
|
|
603
|
+
"timed_conveyor | guarded_choice")
|
|
604
|
+
example.add_argument("-o", "--output", default="example.cpn")
|
|
605
|
+
example.set_defaults(handler=command_example)
|
|
606
|
+
|
|
607
|
+
gui = subparsers.add_parser("gui", help="launch the desktop application")
|
|
608
|
+
gui.add_argument("model", nargs="?", default=None)
|
|
609
|
+
gui.set_defaults(handler=command_gui)
|
|
610
|
+
|
|
611
|
+
studio = subparsers.add_parser("studio", help="launch OpenProcess Studio (process mining)")
|
|
612
|
+
studio.add_argument("files", nargs="*", help="logs (.xes/.csv) or nets (.pnml) to open")
|
|
613
|
+
studio.set_defaults(handler=command_studio)
|
|
614
|
+
|
|
615
|
+
exercises = subparsers.add_parser("exercises", help="exercise packs")
|
|
616
|
+
exercise_commands = exercises.add_subparsers(dest="exercises_command", required=True)
|
|
617
|
+
exercise_check = exercise_commands.add_parser(
|
|
618
|
+
"check", help="list a pack's exercises and report mistakes in its answer blocks")
|
|
619
|
+
exercise_check.add_argument("folder", help="the pack (or one exercise) folder")
|
|
620
|
+
exercise_check.add_argument("--answers", action="store_true",
|
|
621
|
+
help="also print the right answers the app works out")
|
|
622
|
+
exercise_check.set_defaults(handler=command_exercises_check)
|
|
623
|
+
exercise_marks = exercise_commands.add_parser(
|
|
624
|
+
"marks", help="the points a pack's answers earn, per exercise (and per box)")
|
|
625
|
+
exercise_marks.add_argument("folder", help="the pack folder")
|
|
626
|
+
exercise_marks.add_argument("--csv", action="store_true", help="as CSV, one row per answer box")
|
|
627
|
+
exercise_marks.add_argument("--blocks", action="store_true", help="also every answer box")
|
|
628
|
+
exercise_marks.set_defaults(handler=command_exercises_marks)
|
|
629
|
+
exercise_import = exercise_commands.add_parser(
|
|
630
|
+
"import", help="a past exam's text (numbered questions, lettered parts) into a skeleton pack")
|
|
631
|
+
exercise_import.add_argument("exam", help="the exam as a text or Markdown file")
|
|
632
|
+
exercise_import.add_argument("target", help="the pack folder to write")
|
|
633
|
+
exercise_import.add_argument("--title", default=None, help="the pack's title (default: the file's name)")
|
|
634
|
+
exercise_import.set_defaults(handler=command_exercises_import)
|
|
635
|
+
exercise_computes = exercise_commands.add_parser(
|
|
636
|
+
"computes", help="list every compute: an answer block may use")
|
|
637
|
+
exercise_computes.set_defaults(handler=command_exercises_computes)
|
|
638
|
+
|
|
639
|
+
run = subparsers.add_parser("run", help="run a .cpnflow workflow without the app")
|
|
640
|
+
run.add_argument("workflow", help="the .cpnflow file")
|
|
641
|
+
run.add_argument("--check", action="store_true",
|
|
642
|
+
help="re-run and fail if any result differs from the recorded one")
|
|
643
|
+
run.add_argument("--save", action="store_true", help="record the results in the file")
|
|
644
|
+
run.add_argument("--sweep", action="append", metavar="SETTING=VALUES",
|
|
645
|
+
help="vary a setting, e.g. noise=0..0.5 step 0.1 or n3.seed=1,2,3")
|
|
646
|
+
run.add_argument("--lock", action="store_true",
|
|
647
|
+
help="write requirements.lock from the record's environment")
|
|
648
|
+
run.add_argument("-o", "--output", metavar="FOLDER", help="write every result as a file")
|
|
649
|
+
run.add_argument("--export", metavar="FILE.zip",
|
|
650
|
+
help="zip the workflow, its inputs, boxes and results, with a README")
|
|
651
|
+
run.add_argument("-v", "--verbose", action="store_true", help="also print the boxes' notes")
|
|
652
|
+
run.set_defaults(handler=command_run)
|
|
653
|
+
|
|
654
|
+
boxes = subparsers.add_parser("boxes", help="list the boxes")
|
|
655
|
+
boxes.add_argument("folder", nargs="?", help="a folder whose boxes/ to include")
|
|
656
|
+
boxes.add_argument("-v", "--verbose", action="store_true", help="inputs, outputs and settings")
|
|
657
|
+
boxes.set_defaults(handler=command_boxes)
|
|
658
|
+
|
|
659
|
+
datasets = subparsers.add_parser("datasets", help="the public logs known by name")
|
|
660
|
+
dataset_commands = datasets.add_subparsers(dest="datasets_command", required=True)
|
|
661
|
+
dataset_commands.add_parser("list", help="every dataset, and whether it is cached")
|
|
662
|
+
fetch = dataset_commands.add_parser("fetch", help="fetch one into the cache and print its hash")
|
|
663
|
+
fetch.add_argument("name")
|
|
664
|
+
dataset_commands.add_parser("where", help="print the cache folder")
|
|
665
|
+
datasets.set_defaults(handler=command_datasets)
|
|
666
|
+
|
|
667
|
+
mine = subparsers.add_parser("mine", help="process mining on event logs")
|
|
668
|
+
mining = mine.add_subparsers(dest="mining_command", required=True)
|
|
669
|
+
stats = mining.add_parser("stats", help="summarise an event log")
|
|
670
|
+
stats.add_argument("log", help=".xes, .xes.gz, .csv, or .txt in textbook notation")
|
|
671
|
+
stats.add_argument("--top", type=int, default=10, help="variants to list (default 10)")
|
|
672
|
+
stats.set_defaults(handler=command_mine_stats)
|
|
673
|
+
discover = mining.add_parser("discover", help="discover a Petri net")
|
|
674
|
+
discover.add_argument("log")
|
|
675
|
+
discover.add_argument("-a", "--algorithm", choices=["alpha", "im", "imf", "heuristics"],
|
|
676
|
+
default="im")
|
|
677
|
+
discover.add_argument("--noise", type=float, default=0.2, help="IMf noise threshold")
|
|
678
|
+
discover.add_argument("--dependency", type=float, default=0.5,
|
|
679
|
+
help="Heuristics Miner dependency threshold (default 0.5)")
|
|
680
|
+
discover.add_argument("-o", "--output", help="write the model as PNML")
|
|
681
|
+
discover.set_defaults(handler=command_mine_discover)
|
|
682
|
+
conform = mining.add_parser("conform", help="fitness, precision, ... of a model on a log")
|
|
683
|
+
conform.add_argument("model", help="a .pnml file")
|
|
684
|
+
conform.add_argument("log")
|
|
685
|
+
conform.set_defaults(handler=command_mine_conform)
|
|
686
|
+
soundness = mining.add_parser("soundness", help="check WF-net soundness")
|
|
687
|
+
soundness.add_argument("model", help="a .pnml file")
|
|
688
|
+
soundness.set_defaults(handler=command_mine_soundness)
|
|
689
|
+
filtering = mining.add_parser("filter", help="keep part of a log (variants, "
|
|
690
|
+
"activities, start/end, length)")
|
|
691
|
+
filtering.add_argument("log")
|
|
692
|
+
filtering.add_argument("--variants", type=float, metavar="PERCENT",
|
|
693
|
+
help="keep the most frequent variants covering PERCENT of cases")
|
|
694
|
+
filtering.add_argument("--top-variants", type=int, metavar="K",
|
|
695
|
+
help="keep the K most frequent variants")
|
|
696
|
+
filtering.add_argument("--keep", metavar="A,B",
|
|
697
|
+
help="keep only the events of these activities")
|
|
698
|
+
filtering.add_argument("--mandatory", metavar="A,B",
|
|
699
|
+
help="keep cases that contain one of these activities")
|
|
700
|
+
filtering.add_argument("--forbidden", metavar="A,B",
|
|
701
|
+
help="remove cases that contain one of these activities")
|
|
702
|
+
filtering.add_argument("--start", metavar="A,B", help="keep cases starting with these")
|
|
703
|
+
filtering.add_argument("--end", metavar="A,B", help="keep cases ending with these")
|
|
704
|
+
filtering.add_argument("--min-length", type=int, help="at least this many events")
|
|
705
|
+
filtering.add_argument("--max-length", type=int, help="at most this many events")
|
|
706
|
+
filtering.add_argument("-o", "--output", help="write the result (.xes, .xes.gz or .csv)")
|
|
707
|
+
filtering.set_defaults(handler=command_mine_filter)
|
|
708
|
+
invariant = mining.add_parser("invariants",
|
|
709
|
+
help="incidence matrix, P- and T-invariants of a net")
|
|
710
|
+
invariant.add_argument("model", help="a .pnml file")
|
|
711
|
+
invariant.set_defaults(handler=command_mine_invariants)
|
|
712
|
+
|
|
713
|
+
return parser
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _plural(count: int, word: str) -> str:
|
|
717
|
+
return f"{count:,} {word}{'' if count == 1 else 's'}"
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def main(argv: list[str] | None = None) -> int:
|
|
721
|
+
parser = build_parser()
|
|
722
|
+
arguments = parser.parse_args(argv)
|
|
723
|
+
try:
|
|
724
|
+
return arguments.handler(arguments)
|
|
725
|
+
except BrokenPipeError:
|
|
726
|
+
# The output went into `head` or similar, which stopped reading: not
|
|
727
|
+
# an error. Point stdout at nowhere so the exit flush stays quiet.
|
|
728
|
+
import os
|
|
729
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
730
|
+
return 0
|
|
731
|
+
except FileNotFoundError as error:
|
|
732
|
+
# A file that is not there, or a file that is not what it should be:
|
|
733
|
+
# one line saying so, not a traceback.
|
|
734
|
+
print(f"openprocess: no such file: {error.filename}", file=sys.stderr)
|
|
735
|
+
return 1
|
|
736
|
+
except (OSError, ValueError, SyntaxError) as error: # ParseError is a SyntaxError
|
|
737
|
+
print(f"openprocess: could not read the input: {error}", file=sys.stderr)
|
|
738
|
+
return 1
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
if __name__ == "__main__": # pragma: no cover
|
|
742
|
+
raise SystemExit(main())
|