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
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())