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