openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,115 @@
1
+ """Small painted charts used on the overview page.
2
+
3
+ Only one form is needed so far: a column chart (case-length distribution).
4
+ It follows the data-visualisation mark specs: columns at most 24 px wide,
5
+ 4 px rounded tops anchored to a single baseline, a 2 px surface gap between
6
+ neighbours, hairline recessive axis, text in text colours (never in the data
7
+ colour), and a hover tooltip per column.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from PySide6.QtCore import QPointF, QRectF, Qt
13
+ from PySide6.QtGui import QColor, QFontMetricsF, QPainter, QPainterPath, QPen
14
+ from PySide6.QtWidgets import QToolTip, QWidget
15
+
16
+ from .. import theme
17
+ from . import style
18
+
19
+
20
+ class ColumnChart(QWidget):
21
+ def __init__(self, x_title: str = "", y_title: str = "", parent=None) -> None:
22
+ super().__init__(parent)
23
+ self.values: list[tuple[str, float]] = []
24
+ self.x_title, self.y_title = x_title, y_title
25
+ self.setMinimumHeight(180)
26
+ self.setMouseTracking(True)
27
+ self._bars: list[tuple[QRectF, str, float]] = []
28
+
29
+ def set_values(self, values: list[tuple[str, float]]) -> None:
30
+ self.values = values
31
+ self.update()
32
+
33
+ def paintEvent(self, _event) -> None: # noqa: N802
34
+ t = style.tokens()
35
+ painter = QPainter(self)
36
+ painter.setRenderHint(QPainter.Antialiasing)
37
+ font = painter.font()
38
+ theme.set_px(font, 10)
39
+ painter.setFont(font)
40
+ metrics = QFontMetricsF(font)
41
+ self._bars = []
42
+ if not self.values:
43
+ painter.setPen(QColor(t.text_muted))
44
+ painter.drawText(self.rect(), Qt.AlignCenter, "No data")
45
+ return
46
+ top_value = max(v for _, v in self.values) or 1
47
+ left, bottom, top, right = 40.0, 30.0, 12.0, 8.0
48
+ plot = QRectF(left, top, self.width() - left - right, self.height() - top - bottom)
49
+
50
+ # recessive gridlines at nice values
51
+ step = _nice(top_value / 3)
52
+ value = 0.0
53
+ while value <= top_value * 1.001:
54
+ y = plot.bottom() - value / top_value * plot.height()
55
+ painter.setPen(QPen(QColor(t.grid if value else t.axis), 1))
56
+ painter.drawLine(QPointF(plot.left(), y), QPointF(plot.right(), y))
57
+ painter.setPen(QColor(t.text_muted))
58
+ painter.drawText(QRectF(0, y - 8, left - 6, 16), Qt.AlignRight | Qt.AlignVCenter,
59
+ f"{int(value):,}")
60
+ value += step
61
+
62
+ slot = plot.width() / len(self.values)
63
+ width = min(24.0, max(slot - 2, 1.0)) # cap: let the rest be air
64
+ label_every = max(1, int(len(self.values) / max(plot.width() / 28, 1)))
65
+ for index, (name, value) in enumerate(self.values):
66
+ height = value / top_value * plot.height()
67
+ x = plot.left() + index * slot + (slot - width) / 2
68
+ bar = QRectF(x, plot.bottom() - height, width, height)
69
+ self._bars.append((QRectF(plot.left() + index * slot, plot.top(), slot, plot.height()),
70
+ name, value))
71
+ if height > 0.5:
72
+ painter.setPen(Qt.NoPen)
73
+ painter.setBrush(QColor(style.categorical(0)))
74
+ painter.drawPath(_rounded_top(bar, min(4.0, width / 2, height)))
75
+ if index % label_every == 0:
76
+ painter.setPen(QColor(t.text_muted))
77
+ w = metrics.horizontalAdvance(name) + 6
78
+ painter.drawText(QRectF(x + width / 2 - w / 2, plot.bottom() + 4, w, 14),
79
+ Qt.AlignCenter, name)
80
+ if self.x_title:
81
+ painter.setPen(QColor(t.text_muted))
82
+ painter.drawText(QRectF(plot.left(), self.height() - 14, plot.width(), 14),
83
+ Qt.AlignCenter, self.x_title)
84
+
85
+ def mouseMoveEvent(self, event) -> None: # noqa: N802
86
+ for rect, name, value in self._bars:
87
+ if rect.contains(event.position()):
88
+ QToolTip.showText(event.globalPosition().toPoint(),
89
+ f"{self.x_title} {name}: <b>{int(value):,}</b> {self.y_title}", self)
90
+ return
91
+ QToolTip.hideText()
92
+
93
+
94
+ def _rounded_top(rect: QRectF, radius: float) -> QPainterPath:
95
+ """A column with rounded data-end and a square baseline."""
96
+ path = QPainterPath()
97
+ path.moveTo(rect.bottomLeft())
98
+ path.lineTo(rect.left(), rect.top() + radius)
99
+ path.quadTo(rect.topLeft(), QPointF(rect.left() + radius, rect.top()))
100
+ path.lineTo(rect.right() - radius, rect.top())
101
+ path.quadTo(rect.topRight(), QPointF(rect.right(), rect.top() + radius))
102
+ path.lineTo(rect.bottomRight())
103
+ path.closeSubpath()
104
+ return path
105
+
106
+
107
+ def _nice(raw: float) -> float:
108
+ import math
109
+ if raw <= 0:
110
+ return 1
111
+ exponent = math.floor(math.log10(raw))
112
+ for multiple in (1, 2, 5, 10):
113
+ if multiple * 10 ** exponent >= raw:
114
+ return max(1, multiple * 10 ** exponent)
115
+ return 10 ** (exponent + 1)
@@ -0,0 +1,487 @@
1
+ """Compare two or more event logs side by side.
2
+
3
+ Four views, each answering "how do these logs differ?":
4
+
5
+ * **Overview** -- key figures in one table (one column per log, plus the
6
+ relative difference when there are two) and a bar chart of any figure;
7
+ * **Activities** -- for every activity, the share of cases that contain it
8
+ in each log, so missing or rarer steps stand out;
9
+ * **Variants** -- the most common variants and how often each log follows
10
+ them;
11
+ * **Dotted charts** -- one chart per log, with the *same* time scale and the
12
+ *same* colour for an activity in every chart; zooming one zooms all.
13
+
14
+ Each log keeps a colour of its own (the dot in front of its name), used in
15
+ the bars. Activities keep the colours of the dotted chart.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import statistics
22
+ from dataclasses import replace
23
+ from datetime import timedelta
24
+
25
+ from PySide6.QtCore import Qt, Signal
26
+ from PySide6.QtGui import QColor, QStandardItemModel
27
+ from PySide6.QtWidgets import (
28
+ QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHeaderView, QStackedWidget, QVBoxLayout,
29
+ QWidget,
30
+ )
31
+
32
+ from ...mining.stats import format_duration
33
+ from . import style
34
+ from .charts import ColumnChart
35
+ from .documents import ComparisonDocument, LogDocument
36
+ from .dotted_chart import X_MODES, X_TITLES, DotData, DotSettings, DottedChart
37
+ from .log_page import _item, _runs, _table
38
+ from .widgets import (
39
+ suggested_path,
40
+ BarDelegate, Card, ColourDot, Legend, LegendSwatch, PageHeader, SegmentedControl,
41
+ SequenceDelegate, button, hbox, label, scroll, vbox,
42
+ )
43
+
44
+ LOG_ROLE = Qt.UserRole + 1 # colour of a bar
45
+
46
+
47
+ def log_colour(index: int) -> str:
48
+ """Logs are told apart by the categorical palette, in the order given."""
49
+ return style.categorical(index)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Figures (no Qt)
54
+ # ---------------------------------------------------------------------------
55
+ def _seconds(value: timedelta | None) -> float | None:
56
+ return None if value is None else value.total_seconds()
57
+
58
+
59
+ def key_figures(document: LogDocument) -> list[tuple[str, str, float | None, str]]:
60
+ """``(figure, shown text, number for charts, kind)`` for one log.
61
+
62
+ ``kind`` is ``"count"``, ``"duration"`` (number in seconds), ``"percent"``
63
+ or ``"date"`` (not charted).
64
+ """
65
+ summary = document.summary
66
+ durations = [d.total_seconds() for d in summary.case_durations]
67
+ cases = summary.case_count
68
+ lengths = [length for length, n in summary.events_per_case.items() for _ in range(n)]
69
+ top_share = 100.0 * summary.variants[0].count / cases if cases and summary.variants else 0.0
70
+ span = (summary.end - summary.start) if summary.start and summary.end else None
71
+
72
+ def duration(seconds: float | None) -> tuple[str, float | None]:
73
+ return (format_duration(seconds) if seconds is not None else "–"), seconds
74
+
75
+ figures: list[tuple[str, str, float | None, str]] = [
76
+ ("Cases", f"{cases:,}", float(cases), "count"),
77
+ ("Events", f"{summary.event_count:,}", float(summary.event_count), "count"),
78
+ ("Activities", f"{summary.activity_count:,}", float(summary.activity_count), "count"),
79
+ ("Variants", f"{summary.variant_count:,}", float(summary.variant_count), "count"),
80
+ ("Cases in the most common variant", f"{top_share:.1f} %", top_share, "percent"),
81
+ ("Events per case (mean)",
82
+ f"{statistics.fmean(lengths):.2f}" if lengths else "–",
83
+ statistics.fmean(lengths) if lengths else None, "count"),
84
+ ("Events per case (max)", f"{max(lengths):,}" if lengths else "–",
85
+ float(max(lengths)) if lengths else None, "count"),
86
+ ]
87
+ for name, value in (
88
+ ("Case duration (mean)", statistics.fmean(durations) if durations else None),
89
+ ("Case duration (median)", statistics.median(durations) if durations else None),
90
+ ("Case duration (min)", min(durations) if durations else None),
91
+ ("Case duration (max)", max(durations) if durations else None),
92
+ ("Time span of the log", _seconds(span))):
93
+ text, number = duration(value)
94
+ figures.append((name, text, number, "duration"))
95
+ figures.append(("First event", summary.start.strftime("%Y-%m-%d %H:%M") if summary.start
96
+ else "–", None, "date"))
97
+ figures.append(("Last event", summary.end.strftime("%Y-%m-%d %H:%M") if summary.end
98
+ else "–", None, "date"))
99
+ return figures
100
+
101
+
102
+ def percent(share: float) -> str:
103
+ """0.952 -> "95.2 %", 1.0 -> "100 %"."""
104
+ return f"{100 * share:.1f}".rstrip("0").rstrip(".") + " %"
105
+
106
+
107
+ def relative_difference(a: float | None, b: float | None) -> str:
108
+ if a is None or b is None:
109
+ return "–"
110
+ if a == b:
111
+ return "="
112
+ if a == 0:
113
+ return "new"
114
+ change = 100.0 * (b - a) / abs(a)
115
+ return f"{change:+.0f} %"
116
+
117
+
118
+ def activity_shares(documents: list[LogDocument]) -> list[tuple[str, list[float], list[int]]]:
119
+ """Every activity with, per log, the share of cases containing it (0-1)
120
+ and the number of events. Sorted by the largest share."""
121
+ names: dict[str, None] = {}
122
+ per_log = []
123
+ for document in documents:
124
+ summary = document.summary
125
+ stats = {a.name: a for a in summary.activities}
126
+ per_log.append((stats, max(summary.case_count, 1)))
127
+ for name in stats:
128
+ names.setdefault(name, None)
129
+ rows = []
130
+ for name in names:
131
+ shares, counts = [], []
132
+ for stats, cases in per_log:
133
+ entry = stats.get(name)
134
+ shares.append(entry.cases / cases if entry else 0.0)
135
+ counts.append(entry.occurrences if entry else 0)
136
+ rows.append((name, shares, counts))
137
+ rows.sort(key=lambda row: (-max(row[1]), row[0]))
138
+ return rows
139
+
140
+
141
+ def variant_shares(documents: list[LogDocument], limit: int = 60):
142
+ """The most common variants over all logs with each log's share of cases."""
143
+ per_log = []
144
+ combined: dict[tuple[str, ...], float] = {}
145
+ for document in documents:
146
+ summary = document.summary
147
+ cases = max(summary.case_count, 1)
148
+ shares = {v.sequence: v.count / cases for v in summary.variants}
149
+ per_log.append(shares)
150
+ for sequence, share in shares.items():
151
+ combined[sequence] = combined.get(sequence, 0.0) + share
152
+ ordered = sorted(combined, key=lambda seq: -combined[seq])[:limit]
153
+ return [(sequence, [shares.get(sequence, 0.0) for shares in per_log])
154
+ for sequence in ordered], len(combined)
155
+
156
+
157
+ def align_colours(datas: list[DotData]) -> list[str]:
158
+ """Give every activity the same colour in all charts.
159
+
160
+ Each :class:`DotData` ranks its own colour values by frequency; re-rank
161
+ them over all logs together and renumber the dots accordingly.
162
+ """
163
+ totals: dict[str, int] = {}
164
+ for data in datas:
165
+ for value, count in data.colour_counts.items():
166
+ totals[value] = totals.get(value, 0) + count
167
+ shared = sorted(totals, key=lambda v: (-totals[v], v))
168
+ index = {v: i for i, v in enumerate(shared)}
169
+ for data in datas:
170
+ mapping = [index[v] for v in data.colour_values]
171
+ data.colour = [mapping[c] for c in data.colour]
172
+ data.colour_counts = {v: data.colour_counts.get(v, 0) for v in shared}
173
+ data.colour_values = list(shared)
174
+ return shared
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # The page
179
+ # ---------------------------------------------------------------------------
180
+ class ComparePage(QWidget):
181
+ status = Signal(str)
182
+ saved = Signal()
183
+
184
+ TABS = ["Overview", "Activities", "Variants", "Dotted charts"]
185
+
186
+ def __init__(self, document: ComparisonDocument, parent=None) -> None:
187
+ super().__init__(parent)
188
+ self.document = document
189
+ self.logs: list[LogDocument] = document.logs
190
+ root = QVBoxLayout(self)
191
+ root.setContentsMargins(0, 0, 0, 0)
192
+ root.setSpacing(0)
193
+ self.header = PageHeader(document.name, self._subtitle())
194
+ self.header.actions.addWidget(button("Export figures…", self.export,
195
+ tooltip="Save the key figures as CSV"))
196
+ root.addWidget(self.header)
197
+
198
+ body = QWidget()
199
+ layout = QVBoxLayout(body)
200
+ layout.setContentsMargins(20, 0, 20, 16)
201
+ layout.setSpacing(12)
202
+ legend = QWidget()
203
+ legend_row = hbox(spacing=14)
204
+ for index, log in enumerate(self.logs):
205
+ legend_row.addLayout(hbox(ColourDot(log_colour(index), 11), label(log.name),
206
+ spacing=6))
207
+ legend_row.addStretch(1)
208
+ legend.setLayout(legend_row)
209
+ self.log_legend = legend
210
+ self.tabs = SegmentedControl(self.TABS)
211
+ layout.addLayout(hbox(self.tabs, 16, legend))
212
+ self.stack = QStackedWidget()
213
+ self.builders = [self._build_overview, self._build_activities, self._build_variants,
214
+ self._build_dotted]
215
+ self.built: dict[int, QWidget] = {}
216
+ for _ in self.TABS:
217
+ self.stack.addWidget(QWidget())
218
+ self.tabs.changed.connect(self._show_tab)
219
+ layout.addWidget(self.stack, 1)
220
+ root.addWidget(body, 1)
221
+ self._show_tab(0)
222
+
223
+ def _subtitle(self) -> str:
224
+ return f"Comparing {len(self.logs)} event logs · " + \
225
+ " · ".join(f"{log.summary.case_count:,} cases" for log in self.logs)
226
+
227
+ def refresh_title(self) -> None:
228
+ self.header.set_text(self.document.name, self._subtitle())
229
+
230
+ def _show_tab(self, index: int) -> None:
231
+ """Tabs are built the first time they are shown (dotted charts cost)."""
232
+ if index not in self.built:
233
+ widget = self.builders[index]()
234
+ old = self.stack.widget(index)
235
+ self.stack.insertWidget(index, widget)
236
+ self.stack.removeWidget(old)
237
+ old.deleteLater()
238
+ self.built[index] = widget
239
+ self.stack.setCurrentIndex(index)
240
+ # In the dotted charts colours mean activities, not logs.
241
+ self.log_legend.setVisible(index != 3)
242
+
243
+ # -- overview ------------------------------------------------------------------
244
+ def _build_overview(self) -> QWidget:
245
+ figures = [key_figures(log) for log in self.logs]
246
+ self.figures = figures
247
+ two = len(self.logs) == 2
248
+ card = Card("Key figures", "The same numbers as each log's Overview, side by side."
249
+ + (" The last column is the change from the first log to the second."
250
+ if two else ""))
251
+ model = QStandardItemModel()
252
+ headers = ["Figure"] + [log.name for log in self.logs] + (["Change"] if two else [])
253
+ model.setHorizontalHeaderLabels(headers)
254
+ for row, name in enumerate(f[0] for f in figures[0]):
255
+ items = [_item(name)]
256
+ for log_figures in figures:
257
+ items.append(_item(log_figures[row][1], align_right=True))
258
+ if two:
259
+ a, b = figures[0][row][2], figures[1][row][2]
260
+ change = relative_difference(a, b) if figures[0][row][3] != "date" else ""
261
+ item = _item(change, align_right=True)
262
+ if change.startswith("+") or change.startswith("-"):
263
+ item.setForeground(QColor(style.tokens().text_secondary))
264
+ items.append(item)
265
+ model.appendRow(items)
266
+ table = _table(model)
267
+ header = table.horizontalHeader()
268
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
269
+ for column in range(1, model.columnCount()):
270
+ header.setSectionResizeMode(column, QHeaderView.Stretch)
271
+ table.setMinimumHeight(30 * (model.rowCount() + 1) + 8)
272
+ card.add(table)
273
+
274
+ chart_card = Card("Chart")
275
+ self.figure_box = QComboBox()
276
+ self.chartable = [(i, f[0]) for i, f in enumerate(figures[0]) if f[3] != "date"]
277
+ self.figure_box.addItems([name for _, name in self.chartable])
278
+ chart_card.header.addWidget(self.figure_box)
279
+ self.chart = ColumnChart()
280
+ self.chart.setMinimumHeight(240)
281
+ chart_card.add(self.chart)
282
+ self.chart_note = label("", "muted")
283
+ chart_card.add(self.chart_note)
284
+ self.figure_box.currentIndexChanged.connect(self._update_chart)
285
+ self._update_chart(0)
286
+
287
+ page = QWidget()
288
+ page.setLayout(vbox(card, chart_card, None, spacing=12))
289
+ return scroll(page)
290
+
291
+ def _update_chart(self, index: int) -> None:
292
+ if not 0 <= index < len(self.chartable):
293
+ return
294
+ row, name = self.chartable[index]
295
+ kind = self.figures[0][row][3]
296
+ numbers = [f[row][2] for f in self.figures]
297
+ unit, scale = "", 1.0
298
+ if kind == "duration":
299
+ largest = max((n for n in numbers if n is not None), default=0)
300
+ unit, scale = next(((u, s) for u, s in (("days", 86400), ("hours", 3600),
301
+ ("minutes", 60)) if largest >= 2 * s),
302
+ ("seconds", 1))
303
+ elif kind == "percent":
304
+ unit = "%"
305
+ values = [(log.name, (n or 0.0) / scale) for log, n in zip(self.logs, numbers)]
306
+ self.chart.set_values(values)
307
+ self.chart.y_title = unit
308
+ self.chart_note.setText(f"{name}" + (f", in {unit}" if unit and unit != "%" else ""))
309
+
310
+ # -- activities ---------------------------------------------------------------------
311
+ def _build_activities(self) -> QWidget:
312
+ rows = activity_shares(self.logs)
313
+ two = len(self.logs) == 2
314
+ card = Card("Activities", "Share of cases in which each activity occurs (the number "
315
+ "is the percentage; hover for event counts). Activities that occur in only "
316
+ "some of the logs are marked.")
317
+ model = QStandardItemModel()
318
+ model.setHorizontalHeaderLabels(["Activity"] + [log.name for log in self.logs]
319
+ + (["Change (pp)"] if two else []) + ["Note"])
320
+ for name, shares, counts in rows:
321
+ items = [_item(name)]
322
+ for index, (share, count) in enumerate(zip(shares, counts)):
323
+ item = _item(percent(share), data=share, colour=log_colour(index))
324
+ item.setToolTip(f"{count:,} events")
325
+ items.append(item)
326
+ if two:
327
+ items.append(_item(f"{100 * (shares[1] - shares[0]):+.1f}", align_right=True))
328
+ missing = [log.name for log, share in zip(self.logs, shares) if share == 0]
329
+ items.append(_item("not in " + ", ".join(missing) if missing else ""))
330
+ if missing:
331
+ items[-1].setForeground(QColor(style.STATUS["serious"]))
332
+ model.appendRow(items)
333
+ table = _table(model)
334
+ for column in range(1, len(self.logs) + 1):
335
+ table.setItemDelegateForColumn(column, BarDelegate(LOG_ROLE, table))
336
+ header = table.horizontalHeader()
337
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
338
+ for column in range(1, len(self.logs) + 1):
339
+ header.setSectionResizeMode(column, QHeaderView.Stretch)
340
+ card.add(table, 1)
341
+ return card
342
+
343
+ # -- variants ------------------------------------------------------------------------
344
+ def _build_variants(self) -> QWidget:
345
+ rows, total = variant_shares(self.logs)
346
+ card = Card("Variants", f"The {len(rows)} most common of {total:,} distinct variants "
347
+ "(over all logs), with the share of each log's cases that follows it.")
348
+ colours = self._activity_colours()
349
+ model = QStandardItemModel()
350
+ model.setHorizontalHeaderLabels([log.name for log in self.logs] + ["Variant"])
351
+ for sequence, shares in rows:
352
+ items = []
353
+ for index, share in enumerate(shares):
354
+ items.append(_item(percent(share), data=share, colour=log_colour(index)))
355
+ chips = [(a if n == 1 else f"{a} ×{n}", colours.get(a, style.categorical(99)), None)
356
+ for a, n in _runs(sequence)]
357
+ variant = _item(" → ".join(sequence), data=chips)
358
+ variant.setToolTip(" → ".join(sequence))
359
+ items.append(variant)
360
+ model.appendRow(items)
361
+ table = _table(model)
362
+ for column in range(len(self.logs)):
363
+ table.setItemDelegateForColumn(column, BarDelegate(LOG_ROLE, table))
364
+ table.setColumnWidth(column, 190)
365
+ table.setItemDelegateForColumn(len(self.logs), SequenceDelegate(table))
366
+ table.horizontalHeader().setSectionResizeMode(len(self.logs), QHeaderView.Stretch)
367
+ card.add(table, 1)
368
+ return card
369
+
370
+ def _activity_colours(self) -> dict[str, str]:
371
+ """One colour per activity, ranked over all logs (as in the dotted charts)."""
372
+ totals: dict[str, int] = {}
373
+ for log in self.logs:
374
+ for activity in log.summary.activities:
375
+ totals[activity.name] = totals.get(activity.name, 0) + activity.occurrences
376
+ ranked = sorted(totals, key=lambda a: (-totals[a], a))
377
+ return {a: style.categorical(i) for i, a in enumerate(ranked)}
378
+
379
+ # -- dotted charts -------------------------------------------------------------------
380
+ def _build_dotted(self) -> QWidget:
381
+ page = QWidget()
382
+ layout = QVBoxLayout(page)
383
+ layout.setContentsMargins(0, 0, 0, 0)
384
+ layout.setSpacing(10)
385
+ self.dot_settings = replace(DotSettings(), x_mode=1)
386
+ self.x_box = QComboBox()
387
+ self.x_box.addItems(X_MODES)
388
+ self.x_box.setCurrentIndex(1)
389
+ self.x_box.setToolTip("Time since case start (the default here) lines all cases up at "
390
+ "zero, which is what makes different logs comparable.")
391
+ self.colour_box = QComboBox()
392
+ self.colour_box.addItems(["Activity", "Resource", "Lifecycle", "None"])
393
+ self.size_box = QDoubleSpinBox()
394
+ self.size_box.setRange(1.0, 10.0)
395
+ self.size_box.setSingleStep(0.5)
396
+ self.size_box.setValue(3.5)
397
+ self.sync_box = QCheckBox("Zoom together")
398
+ self.sync_box.setChecked(True)
399
+ self.sync_box.setToolTip("Zooming or panning one chart does the same to the others")
400
+ controls = Card()
401
+ controls.add(hbox(label("x axis", "muted"), self.x_box, 12, label("Colour by", "muted"),
402
+ self.colour_box, 12, label("Dot size", "muted"), self.size_box, 12,
403
+ self.sync_box, None,
404
+ button("Reset zoom", lambda: [c.reset_view() for c in self.charts])))
405
+ self.dot_legend = Legend()
406
+ controls.add(self.dot_legend)
407
+ controls.add(label("All charts share one horizontal scale and one colour per value. "
408
+ "Drag a rectangle to zoom, double-click to zoom out.", "muted",
409
+ wrap=True))
410
+ layout.addWidget(controls)
411
+
412
+ self.charts: list[DottedChart] = []
413
+ self.chart_cards: list[Card] = []
414
+ charts_host = QWidget()
415
+ charts_layout = QVBoxLayout(charts_host)
416
+ charts_layout.setContentsMargins(0, 0, 0, 0)
417
+ charts_layout.setSpacing(10)
418
+ for index, log in enumerate(self.logs):
419
+ card = Card(log.name)
420
+ chart = DottedChart()
421
+ chart.setMinimumHeight(max(200, min(360, 520 // len(self.logs))))
422
+ chart.view_changed.connect(lambda c=chart: self._sync_views(c))
423
+ card.add(chart, 1)
424
+ charts_layout.addWidget(card, 1)
425
+ self.charts.append(chart)
426
+ self.chart_cards.append(card)
427
+ layout.addWidget(scroll(charts_host), 1)
428
+ self._syncing = False
429
+ for widget in (self.x_box, self.colour_box):
430
+ widget.currentIndexChanged.connect(lambda _: self._rebuild_dots())
431
+ self.size_box.valueChanged.connect(lambda _: self._rebuild_dots())
432
+ self._rebuild_dots()
433
+ return page
434
+
435
+ def _rebuild_dots(self) -> None:
436
+ settings = replace(self.dot_settings, x_mode=self.x_box.currentIndex(),
437
+ colour_by=self.colour_box.currentText(),
438
+ dot_size=self.size_box.value())
439
+ datas = [DotData(log, settings) for log in self.logs]
440
+ shared = align_colours(datas)
441
+ x_min = min(d.x_min for d in datas)
442
+ x_max = max(d.x_max for d in datas)
443
+ for chart, data in zip(self.charts, datas):
444
+ chart.shared_x = (x_min, x_max)
445
+ chart.set_data(data)
446
+ self._syncing = False
447
+ items = [(LegendSwatch("dot", style.categorical(i)), value or "–")
448
+ for i, value in enumerate(shared[:8])]
449
+ if len(shared) > 8:
450
+ items.append((LegendSwatch("dot", style.categorical(99)),
451
+ f"{len(shared) - 8} other values"))
452
+ if settings.colour_by == "None":
453
+ items = []
454
+ self.dot_legend.set_items(items)
455
+ title = X_TITLES[datas[0].mode] if datas else ""
456
+ for card, data, log in zip(self.chart_cards, datas, self.logs):
457
+ card.setToolTip(f"{log.name}: {len(data):,} events · {len(data.row_values):,} rows"
458
+ f" · x = {title}")
459
+
460
+ def _sync_views(self, source: DottedChart) -> None:
461
+ if self._syncing or not getattr(self, "sync_box", None) or not self.sync_box.isChecked():
462
+ return
463
+ self._syncing = True
464
+ try:
465
+ for chart in self.charts:
466
+ if chart is not source and chart.data is not None:
467
+ chart.set_view(source.vx0, source.vx1, chart.vy0, chart.vy1, record=False)
468
+ finally:
469
+ self._syncing = False
470
+
471
+ # -- export ----------------------------------------------------------------------------
472
+ def export(self) -> None:
473
+ path, _ = QFileDialog.getSaveFileName(self, "Export figures", suggested_path("comparison.csv"),
474
+ "CSV (*.csv)")
475
+ if not path:
476
+ return
477
+ figures = [key_figures(log) for log in self.logs]
478
+ with open(path, "w", newline="", encoding="utf-8") as handle:
479
+ writer = csv.writer(handle)
480
+ writer.writerow(["Figure"] + [log.name for log in self.logs])
481
+ for row, name in enumerate(f[0] for f in figures[0]):
482
+ writer.writerow([name] + [f[row][1] for f in figures])
483
+ writer.writerow([])
484
+ writer.writerow(["Activity (share of cases)"] + [log.name for log in self.logs])
485
+ for name, shares, _counts in activity_shares(self.logs):
486
+ writer.writerow([name] + [f"{100 * s:.1f}" for s in shares])
487
+ self.status.emit(f"Exported the comparison to {path}")