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,665 @@
|
|
|
1
|
+
"""What the side panel shows for a box: its result, how it got there, its
|
|
2
|
+
code, and its settings.
|
|
3
|
+
|
|
4
|
+
:func:`result_widget` picks a viewer by the result's type: the net drawing
|
|
5
|
+
(with *Edit a copy*), a log's figures and variants (with *Open as log*),
|
|
6
|
+
a transition system, a process tree, a process map, a footprint, a replay
|
|
7
|
+
or alignment table, scores as meters, a table, a figure, a report.
|
|
8
|
+
:func:`how_widget` lays out a box's :class:`~openprocess.flow.explain.Explanation`
|
|
9
|
+
in order: notes as lines, derivation steps as a two-column table, shown
|
|
10
|
+
values each in their own viewer. :func:`code_widget` shows the source with
|
|
11
|
+
a small Python highlighter. :class:`SettingsWidget` makes one control per
|
|
12
|
+
setting from the box's spec and reports changes.
|
|
13
|
+
|
|
14
|
+
Every tab can be opened in its own window (:func:`pop_out`), for a figure
|
|
15
|
+
for a paper, a replay table with a hundred rows, or an algorithm of five
|
|
16
|
+
hundred lines.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from html import escape
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from PySide6.QtCore import QRegularExpression, Qt, QTimer, Signal
|
|
25
|
+
from PySide6.QtGui import QColor, QDesktopServices, QFont, QSyntaxHighlighter, QTextCharFormat
|
|
26
|
+
from PySide6.QtWidgets import (
|
|
27
|
+
QAbstractItemView, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel,
|
|
28
|
+
QLayout, QLineEdit, QPlainTextEdit, QProgressBar, QSizePolicy, QSpinBox, QTableWidget, QTableWidgetItem,
|
|
29
|
+
QVBoxLayout, QWidget,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from ...flow.box import BoxSpec, Called, Setting, algorithm_calls
|
|
33
|
+
from ...flow.explain import Explanation
|
|
34
|
+
from ...flow.runner import Result
|
|
35
|
+
from ...flow.sweep import Sweep, is_sweep
|
|
36
|
+
from ...flow.types import (AlignmentResult, CPNet, DFG, Dataset, EventLog, Figure, Footprint, PetriNet,
|
|
37
|
+
Predictions, Predictor, ProcessTree, Regions, ReplayResult, Scores, SimpleLog,
|
|
38
|
+
Table, Text, TransitionSystem)
|
|
39
|
+
from .. import theme
|
|
40
|
+
from ..studio import style
|
|
41
|
+
from ..studio.graph_builders import dfg_specs, petri_net_specs
|
|
42
|
+
from ..studio.graph_view import GraphView
|
|
43
|
+
from ..studio.widgets import Card, StatTile, button, footprint_table, hbox, label, scroll, vbox
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Small helpers
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
def _table(columns: list[str], rows: list[list], max_rows: int = 500) -> QTableWidget:
|
|
50
|
+
table = QTableWidget(min(len(rows), max_rows), len(columns))
|
|
51
|
+
table.setHorizontalHeaderLabels([str(c) for c in columns])
|
|
52
|
+
table.verticalHeader().setVisible(False)
|
|
53
|
+
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
54
|
+
table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
55
|
+
table.setAlternatingRowColors(True)
|
|
56
|
+
table.setShowGrid(False)
|
|
57
|
+
for r, row in enumerate(rows[:max_rows]):
|
|
58
|
+
for c, value in enumerate(row):
|
|
59
|
+
text = f"{value:.4f}" if isinstance(value, float) else "" if value is None else str(value)
|
|
60
|
+
item = QTableWidgetItem(text)
|
|
61
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
62
|
+
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
63
|
+
table.setItem(r, c, item)
|
|
64
|
+
table.resizeColumnsToContents()
|
|
65
|
+
table.horizontalHeader().setStretchLastSection(True)
|
|
66
|
+
height = table.horizontalHeader().height() + sum(table.rowHeight(i) for i in range(table.rowCount())) + 6
|
|
67
|
+
table.setMinimumHeight(min(max(height, 60), 420))
|
|
68
|
+
table.setMaximumHeight(min(max(height, 60), 420))
|
|
69
|
+
return table
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _graph(nodes, edges, positions=None, layer_gap: float = 48.0, height: int = 260) -> GraphView:
|
|
73
|
+
view = GraphView()
|
|
74
|
+
view.setMinimumHeight(height)
|
|
75
|
+
view.graph.populate(nodes, edges, positions, layer_gap=layer_gap)
|
|
76
|
+
QTimer.singleShot(0, view.fit)
|
|
77
|
+
return view
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _meter(name: str, value) -> QWidget:
|
|
81
|
+
row = QWidget()
|
|
82
|
+
layout = QHBoxLayout(row)
|
|
83
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
84
|
+
layout.setSpacing(8)
|
|
85
|
+
caption = label(name[:1].upper() + name[1:])
|
|
86
|
+
caption.setMinimumWidth(110)
|
|
87
|
+
layout.addWidget(caption)
|
|
88
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= float(value) <= 1:
|
|
89
|
+
bar = QProgressBar()
|
|
90
|
+
bar.setRange(0, 1000)
|
|
91
|
+
bar.setValue(int(float(value) * 1000))
|
|
92
|
+
bar.setTextVisible(False)
|
|
93
|
+
bar.setFixedHeight(8)
|
|
94
|
+
layout.addWidget(bar, 1)
|
|
95
|
+
number = label(f"{float(value):.3f}")
|
|
96
|
+
number.setStyleSheet("font-weight: 600;")
|
|
97
|
+
number.setMinimumWidth(44)
|
|
98
|
+
number.setAlignment(Qt.AlignRight)
|
|
99
|
+
layout.addWidget(number)
|
|
100
|
+
else:
|
|
101
|
+
text = label(str(value))
|
|
102
|
+
good = str(value).lower().startswith(("sound", "yes", "true"))
|
|
103
|
+
bad = str(value).lower().startswith(("not", "no", "false"))
|
|
104
|
+
if good or bad:
|
|
105
|
+
text.setStyleSheet(f"font-weight: 600; color: {style.STATUS['good' if good else 'critical']};")
|
|
106
|
+
layout.addWidget(text, 1)
|
|
107
|
+
return row
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# Result viewers
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
def result_widget(value, page=None) -> QWidget:
|
|
114
|
+
"""A viewer for ``value``. ``page`` (the workflow page) receives the
|
|
115
|
+
*Open as …* requests; None gives a read-only viewer."""
|
|
116
|
+
host = QWidget()
|
|
117
|
+
layout = QVBoxLayout(host)
|
|
118
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
119
|
+
layout.setSpacing(8)
|
|
120
|
+
|
|
121
|
+
def add(widget) -> None:
|
|
122
|
+
if isinstance(widget, QLayout):
|
|
123
|
+
layout.addLayout(widget)
|
|
124
|
+
else:
|
|
125
|
+
layout.addWidget(widget)
|
|
126
|
+
|
|
127
|
+
from ...mining.discovery.alpha import AlphaResult
|
|
128
|
+
from ...mining.discovery.heuristics import HeuristicsResult
|
|
129
|
+
from ...mining.discovery.inductive import InductiveResult
|
|
130
|
+
from ...mining.discovery.state_regions import RegionResult
|
|
131
|
+
if isinstance(value, (AlphaResult, InductiveResult, HeuristicsResult)):
|
|
132
|
+
from ..studio.derivation_view import derivation_view
|
|
133
|
+
add(derivation_view(value)) # the typeset derivation the Discover tab shows
|
|
134
|
+
elif isinstance(value, RegionResult):
|
|
135
|
+
from ..studio.regions_view import RegionsPanel
|
|
136
|
+
add(RegionsPanel(value, show_net=False, flat=True))
|
|
137
|
+
elif isinstance(value, PetriNet):
|
|
138
|
+
nodes, edges = petri_net_specs(value, show_place_names=len(value.places) <= 40)
|
|
139
|
+
positions = None
|
|
140
|
+
if all(p.position for p in value.places.values()) and all(t.position for t in value.transitions.values()):
|
|
141
|
+
positions = {p.id: p.position for p in value.places.values()}
|
|
142
|
+
positions |= {t.id: t.position for t in value.transitions.values()}
|
|
143
|
+
add(_graph(nodes, edges, positions))
|
|
144
|
+
add(label(value.summary() + (f" · {value.info['algorithm']}" if value.info.get("algorithm") else ""),
|
|
145
|
+
"muted", wrap=True))
|
|
146
|
+
if page is not None:
|
|
147
|
+
add(hbox(button("Open as model", lambda: page.open_as_model(value),
|
|
148
|
+
tooltip="Open it as a model page: token game, analysis, conformance"),
|
|
149
|
+
button("✎ Edit a copy", lambda: page.edit_copy(value),
|
|
150
|
+
tooltip="Open a copy on the net canvas, to play and change it"), None))
|
|
151
|
+
elif isinstance(value, EventLog):
|
|
152
|
+
from ...mining.stats import format_duration, summarise
|
|
153
|
+
summary = summarise(value)
|
|
154
|
+
tiles = hbox(StatTile("Cases", f"{summary.case_count:,}"), StatTile("Events", f"{summary.event_count:,}"),
|
|
155
|
+
StatTile("Variants", f"{summary.variant_count:,}"),
|
|
156
|
+
StatTile("Activities", f"{len(summary.activities):,}"), spacing=8)
|
|
157
|
+
add_layout = QWidget()
|
|
158
|
+
add_layout.setLayout(tiles)
|
|
159
|
+
add(add_layout)
|
|
160
|
+
rows = [["⟨" + ", ".join(v.sequence) + "⟩", v.count] for v in summary.variants[:200]]
|
|
161
|
+
add(_table(["variant", "cases"], rows))
|
|
162
|
+
if summary.start:
|
|
163
|
+
add(label(f"From {summary.start:%Y-%m-%d} to {summary.end:%Y-%m-%d}; median case duration "
|
|
164
|
+
f"{format_duration(summary.median_case_duration)}", "muted", wrap=True))
|
|
165
|
+
if page is not None:
|
|
166
|
+
add(hbox(button("Open as log", lambda: page.open_as_log(value),
|
|
167
|
+
tooltip="Open it as a log page: dotted chart, process map, filters"), None))
|
|
168
|
+
elif isinstance(value, SimpleLog):
|
|
169
|
+
from ...mining.log import format_simple_log
|
|
170
|
+
text = QPlainTextEdit(format_simple_log(value))
|
|
171
|
+
text.setReadOnly(True)
|
|
172
|
+
text.setMaximumHeight(120)
|
|
173
|
+
add(text)
|
|
174
|
+
elif isinstance(value, TransitionSystem):
|
|
175
|
+
from ..studio.regions_view import TransitionSystemView
|
|
176
|
+
view = TransitionSystemView(value)
|
|
177
|
+
add(view)
|
|
178
|
+
add(label(f"{len(value.states)} states, {len(value.transitions)} transitions"
|
|
179
|
+
+ (f" ({value.abstraction})" if value.abstraction else ""), "muted", wrap=True))
|
|
180
|
+
elif isinstance(value, ProcessTree):
|
|
181
|
+
from ..studio.derivation_view import TreeView, tree_formula, tree_specs
|
|
182
|
+
nodes, edges, positions = tree_specs(value)
|
|
183
|
+
view = TreeView()
|
|
184
|
+
view.graph.populate(nodes, edges, positions, layer_gap=60)
|
|
185
|
+
QTimer.singleShot(0, view.fit_height)
|
|
186
|
+
add(view)
|
|
187
|
+
formula = label(tree_formula(value), wrap=True)
|
|
188
|
+
formula.setTextFormat(Qt.RichText)
|
|
189
|
+
add(formula)
|
|
190
|
+
elif isinstance(value, DFG):
|
|
191
|
+
nodes, edges = dfg_specs(value)
|
|
192
|
+
add(_graph(nodes, edges, layer_gap=90))
|
|
193
|
+
add(label(f"{len(value.activities)} activities, {len(value.edges)} arcs, {value.trace_count} cases",
|
|
194
|
+
"muted"))
|
|
195
|
+
elif isinstance(value, Footprint):
|
|
196
|
+
add(footprint_table(value, compact=len(value.activities) > 8))
|
|
197
|
+
elif isinstance(value, ReplayResult):
|
|
198
|
+
rows = [["⟨" + ", ".join(t.trace) + "⟩", t.count, t.produced, t.consumed, t.missing, t.remaining,
|
|
199
|
+
round(t.fitness, 4)] for t in value.traces]
|
|
200
|
+
add(_table(["variant", "cases", "p", "c", "m", "r", "fitness"], rows))
|
|
201
|
+
add(label(f"Fitness {value.fitness:.4f}: p = {value.produced}, c = {value.consumed}, "
|
|
202
|
+
f"m = {value.missing}, r = {value.remaining}; {value.fitting_traces} of "
|
|
203
|
+
f"{value.trace_count} cases fit.", "muted", wrap=True))
|
|
204
|
+
elif isinstance(value, AlignmentResult):
|
|
205
|
+
rows = [["⟨" + ", ".join(a.trace) + "⟩", a.count, a.cost, round(a.fitness, 4),
|
|
206
|
+
" ".join(f"({m.log}|{m.model})" for m in a.moves)] for a in value.alignments]
|
|
207
|
+
add(_table(["variant", "cases", "cost", "fitness", "alignment"], rows))
|
|
208
|
+
add(label(f"Average fitness {value.average_fitness:.4f}; {value.fitting_traces} of "
|
|
209
|
+
f"{value.trace_count} cases align without a deviation.", "muted", wrap=True))
|
|
210
|
+
elif isinstance(value, Regions):
|
|
211
|
+
rows = [[value.name_of(r), value.region_text(r), "minimal" if r in value.minimal else ""]
|
|
212
|
+
for r in value.regions[:200]]
|
|
213
|
+
add(_table(["region", "states", ""], rows))
|
|
214
|
+
add(label(f"{len(value.regions)} regions, {len(value.minimal)} minimal; "
|
|
215
|
+
f"elementary: {value.elementary}", "muted", wrap=True))
|
|
216
|
+
elif isinstance(value, Scores):
|
|
217
|
+
box = QWidget()
|
|
218
|
+
meters = QVBoxLayout(box)
|
|
219
|
+
meters.setContentsMargins(0, 0, 0, 0)
|
|
220
|
+
meters.setSpacing(6)
|
|
221
|
+
for name, metric in value.metrics.items():
|
|
222
|
+
meters.addWidget(_meter(name, metric))
|
|
223
|
+
add(label(value.model, "cardTitle"))
|
|
224
|
+
add(box)
|
|
225
|
+
if value.context:
|
|
226
|
+
add(label("Settings: " + ", ".join(f"{k} = {v}" for k, v in value.context.items()), "muted", wrap=True))
|
|
227
|
+
if value.note:
|
|
228
|
+
add(label(value.note, "muted", wrap=True))
|
|
229
|
+
elif isinstance(value, Table):
|
|
230
|
+
add(label(value.name, "cardTitle"))
|
|
231
|
+
add(_table(value.columns, value.rows))
|
|
232
|
+
if value.samples:
|
|
233
|
+
add(label(f"{len(value.samples)} samples behind these rows (for Compare samples and Plot).", "muted",
|
|
234
|
+
wrap=True))
|
|
235
|
+
if value.note:
|
|
236
|
+
add(label(value.note, "muted", wrap=True))
|
|
237
|
+
elif isinstance(value, Figure):
|
|
238
|
+
shown = False
|
|
239
|
+
if value.svg:
|
|
240
|
+
try:
|
|
241
|
+
from PySide6.QtSvgWidgets import QSvgWidget
|
|
242
|
+
svg = QSvgWidget()
|
|
243
|
+
svg.load(value.svg.encode("utf-8"))
|
|
244
|
+
svg.setMinimumHeight(260)
|
|
245
|
+
svg.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
246
|
+
size = svg.renderer().defaultSize()
|
|
247
|
+
if size.width() > 0:
|
|
248
|
+
svg.setMaximumHeight(int(min(600, max(260, size.height() * 1.4))))
|
|
249
|
+
add(svg)
|
|
250
|
+
shown = True
|
|
251
|
+
except ImportError:
|
|
252
|
+
pass
|
|
253
|
+
if not shown and value.png:
|
|
254
|
+
from PySide6.QtGui import QPixmap
|
|
255
|
+
pixmap = QPixmap()
|
|
256
|
+
pixmap.loadFromData(value.png)
|
|
257
|
+
picture = QLabel()
|
|
258
|
+
picture.setPixmap(pixmap.scaledToWidth(560, Qt.SmoothTransformation))
|
|
259
|
+
add(picture)
|
|
260
|
+
shown = True
|
|
261
|
+
if not shown:
|
|
262
|
+
add(label("The figure has no SVG or PNG form.", "muted"))
|
|
263
|
+
if value.caption:
|
|
264
|
+
add(label(value.caption, "muted", wrap=True))
|
|
265
|
+
if page is not None:
|
|
266
|
+
add(hbox(button("Save as SVG…", lambda: page.save_figure(value)), None))
|
|
267
|
+
elif isinstance(value, Text):
|
|
268
|
+
text = QPlainTextEdit(value.text)
|
|
269
|
+
text.setReadOnly(True)
|
|
270
|
+
text.setFont(theme.mono_font(11))
|
|
271
|
+
text.setMinimumHeight(200)
|
|
272
|
+
add(text)
|
|
273
|
+
elif isinstance(value, Dataset):
|
|
274
|
+
add(label(f"{len(value)} rows × {len(value.feature_names)} features · label: {value.label} "
|
|
275
|
+
f"({value.kind})", "muted", wrap=True))
|
|
276
|
+
rows = [[*(list(row) if not hasattr(row, "tolist") else row.tolist()), y]
|
|
277
|
+
for row, y in zip(value.X[:50], value.y[:50])]
|
|
278
|
+
add(_table([*value.feature_names, value.label], rows))
|
|
279
|
+
elif isinstance(value, Predictions):
|
|
280
|
+
rows = [[str(v), str(t), "✓" if v == t else ""] for v, t in zip(value.values[:100], value.dataset.y[:100])]
|
|
281
|
+
add(_table(["predicted", "actual", ""], rows))
|
|
282
|
+
add(label(f"{len(value.values)} predictions by {value.name}", "muted"))
|
|
283
|
+
elif isinstance(value, Predictor):
|
|
284
|
+
add(label(f"{getattr(value, 'name', type(value).__name__)}: a fitted model. Connect it to Predict.",
|
|
285
|
+
"muted", wrap=True))
|
|
286
|
+
elif isinstance(value, CPNet):
|
|
287
|
+
add(label(f"{value.name}: {sum(1 for _ in value.all_places())} places, "
|
|
288
|
+
f"{sum(1 for _ in value.all_transitions())} transitions, {len(value.pages)} page(s)",
|
|
289
|
+
"muted", wrap=True))
|
|
290
|
+
if page is not None:
|
|
291
|
+
add(hbox(button("Open as coloured net", lambda: page.open_as_cpn(value)), None))
|
|
292
|
+
elif value is None:
|
|
293
|
+
add(label("This box gives nothing back (it saves, or shows).", "muted", wrap=True))
|
|
294
|
+
else:
|
|
295
|
+
text = QPlainTextEdit(repr(value)[:5000])
|
|
296
|
+
text.setReadOnly(True)
|
|
297
|
+
text.setMaximumHeight(160)
|
|
298
|
+
add(label(type(value).__name__, "muted"))
|
|
299
|
+
add(text)
|
|
300
|
+
return host
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# How
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
def how_widget(explanation: Explanation, page=None) -> QWidget:
|
|
307
|
+
host = QWidget()
|
|
308
|
+
layout = QVBoxLayout(host)
|
|
309
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
310
|
+
layout.setSpacing(10)
|
|
311
|
+
if explanation.empty:
|
|
312
|
+
layout.addWidget(label("This box has nothing to add: its result is all there is.", "muted", wrap=True))
|
|
313
|
+
return host
|
|
314
|
+
t = style.tokens()
|
|
315
|
+
for entry in explanation.entries:
|
|
316
|
+
if entry[0] == "note":
|
|
317
|
+
text = label("· " + entry[1], "muted" if not entry[1].startswith("Warning") else None, wrap=True,
|
|
318
|
+
selectable=True)
|
|
319
|
+
if entry[1].startswith("Warning"):
|
|
320
|
+
text.setStyleSheet(f"color: {style.STATUS['warning']};")
|
|
321
|
+
layout.addWidget(text)
|
|
322
|
+
elif entry[0] == "steps":
|
|
323
|
+
rows = []
|
|
324
|
+
for title, content in entry[1]:
|
|
325
|
+
rows.append(f"<tr><td valign='top' style='padding: 4px 10px 4px 0; color: {t.text_muted}; "
|
|
326
|
+
f"white-space: nowrap'><b>{escape(title)}</b></td>"
|
|
327
|
+
f"<td style='padding: 4px 0; font-family: {theme.mono_font(11).family()}; "
|
|
328
|
+
f"font-size: 11.5px'>{escape(content)}</td></tr>")
|
|
329
|
+
table = QLabel(f"<table cellspacing='0' width='100%'>{''.join(rows)}</table>")
|
|
330
|
+
table.setTextFormat(Qt.RichText)
|
|
331
|
+
table.setWordWrap(True)
|
|
332
|
+
table.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
333
|
+
layout.addWidget(table)
|
|
334
|
+
elif entry[0] == "show":
|
|
335
|
+
caption, value = entry[1], entry[2]
|
|
336
|
+
if caption:
|
|
337
|
+
layout.addWidget(label(caption.upper(), "sectionLabel"))
|
|
338
|
+
layout.addWidget(result_widget(value, page))
|
|
339
|
+
return host
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Code
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
class PythonHighlighter(QSyntaxHighlighter):
|
|
346
|
+
KEYWORDS = ("def", "return", "if", "elif", "else", "for", "in", "while", "import", "from", "as", "class",
|
|
347
|
+
"raise", "try", "except", "finally", "with", "lambda", "not", "and", "or", "is", "None", "True",
|
|
348
|
+
"False", "yield", "pass", "global", "del", "assert")
|
|
349
|
+
|
|
350
|
+
def __init__(self, document) -> None:
|
|
351
|
+
super().__init__(document)
|
|
352
|
+
t = style.tokens()
|
|
353
|
+
self.rules = []
|
|
354
|
+
keyword = QTextCharFormat()
|
|
355
|
+
keyword.setForeground(QColor(t.accent))
|
|
356
|
+
keyword.setFontWeight(QFont.DemiBold)
|
|
357
|
+
self.rules.append((QRegularExpression(r"\b(" + "|".join(self.KEYWORDS) + r")\b"), keyword))
|
|
358
|
+
decorator = QTextCharFormat()
|
|
359
|
+
decorator.setForeground(QColor(style.categorical(1)))
|
|
360
|
+
self.rules.append((QRegularExpression(r"^\s*@\w+(\([^)]*\))?"), decorator))
|
|
361
|
+
string = QTextCharFormat()
|
|
362
|
+
string.setForeground(QColor(style.STATUS["good"]))
|
|
363
|
+
self.rules.append((QRegularExpression(r"(f|r|b)?\"[^\"\n]*\"|(f|r|b)?'[^'\n]*'"), string))
|
|
364
|
+
comment = QTextCharFormat()
|
|
365
|
+
comment.setForeground(QColor(t.text_muted))
|
|
366
|
+
comment.setFontItalic(True)
|
|
367
|
+
self.rules.append((QRegularExpression(r"#[^\n]*"), comment))
|
|
368
|
+
self.docstring = QTextCharFormat()
|
|
369
|
+
self.docstring.setForeground(QColor(style.categorical(3)))
|
|
370
|
+
self.triple = QRegularExpression('"""')
|
|
371
|
+
|
|
372
|
+
def highlightBlock(self, text: str) -> None: # noqa: N802
|
|
373
|
+
for pattern, fmt in self.rules:
|
|
374
|
+
match = pattern.globalMatch(text)
|
|
375
|
+
while match.hasNext():
|
|
376
|
+
found = match.next()
|
|
377
|
+
self.setFormat(found.capturedStart(), found.capturedLength(), fmt)
|
|
378
|
+
# Docstrings span lines: state 1 = inside one.
|
|
379
|
+
self.setCurrentBlockState(0)
|
|
380
|
+
start = 0 if self.previousBlockState() == 1 else text.find('"""')
|
|
381
|
+
while start >= 0:
|
|
382
|
+
end = text.find('"""', start + (3 if self.previousBlockState() != 1 or start else 0))
|
|
383
|
+
if end < 0:
|
|
384
|
+
self.setCurrentBlockState(1)
|
|
385
|
+
self.setFormat(start, len(text) - start, self.docstring)
|
|
386
|
+
break
|
|
387
|
+
self.setFormat(start, end + 3 - start, self.docstring)
|
|
388
|
+
start = text.find('"""', end + 3)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _code_view(source: str, compact: bool, placeholder: str = "(the source is not available)") -> QPlainTextEdit:
|
|
392
|
+
"""Read-only, highlighted Python; in the panel sized to the code (120–420 px)."""
|
|
393
|
+
editor = QPlainTextEdit(source or placeholder)
|
|
394
|
+
editor.setReadOnly(True)
|
|
395
|
+
editor.setFont(theme.mono_font(11))
|
|
396
|
+
editor.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
397
|
+
PythonHighlighter(editor.document())
|
|
398
|
+
lines = (source or "").count("\n") + 1
|
|
399
|
+
if compact:
|
|
400
|
+
editor.setMinimumHeight(min(max(lines * 18 + 20, 120), 420))
|
|
401
|
+
editor.setMaximumHeight(min(max(lines * 18 + 20, 120), 420))
|
|
402
|
+
return editor
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _open_in_editor(file: str) -> None:
|
|
406
|
+
QDesktopServices.openUrl(__import__("PySide6.QtCore", fromlist=["QUrl"]).QUrl.fromLocalFile(file))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def show_file(called: Called, parent=None) -> QDialog:
|
|
410
|
+
"""The whole file an algorithm lives in, in a window of its own, scrolled to
|
|
411
|
+
the function: the module's docstring at its top says what the algorithm
|
|
412
|
+
does and where it comes from."""
|
|
413
|
+
from PySide6.QtGui import QTextCursor
|
|
414
|
+
|
|
415
|
+
def make() -> QWidget:
|
|
416
|
+
host = QWidget()
|
|
417
|
+
layout = QVBoxLayout(host)
|
|
418
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
419
|
+
layout.setSpacing(8)
|
|
420
|
+
try:
|
|
421
|
+
text = Path(called.file).read_text(encoding="utf-8", errors="replace")
|
|
422
|
+
except OSError as error:
|
|
423
|
+
text = f"(could not read {called.file}: {error})"
|
|
424
|
+
layout.addWidget(label(f"{called.where} · the whole file; <b>{escape(called.name)}</b> starts at "
|
|
425
|
+
f"line {called.line}", "muted", wrap=True, selectable=True))
|
|
426
|
+
editor = _code_view(text, compact=False)
|
|
427
|
+
editor.setMinimumHeight(560)
|
|
428
|
+
layout.addWidget(editor, 1)
|
|
429
|
+
if Path(called.file).is_file():
|
|
430
|
+
layout.addLayout(hbox(button("Open in your editor", lambda: _open_in_editor(called.file)), None))
|
|
431
|
+
|
|
432
|
+
def scroll_to_function() -> None:
|
|
433
|
+
block = editor.document().findBlockByNumber(max(called.line - 1, 0))
|
|
434
|
+
cursor = QTextCursor(block)
|
|
435
|
+
editor.setTextCursor(cursor)
|
|
436
|
+
editor.centerCursor()
|
|
437
|
+
QTimer.singleShot(0, scroll_to_function)
|
|
438
|
+
return host
|
|
439
|
+
return pop_out(f"{Path(called.file).name} · {called.name}", make, parent)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _follows(called: Called) -> QLabel | None:
|
|
443
|
+
"""The published work(s) the algorithm follows, from the app's References
|
|
444
|
+
(*Help ▸ References*), so the code can be checked against its source."""
|
|
445
|
+
import re
|
|
446
|
+
from ...references import reference, topics_for_module
|
|
447
|
+
lines = []
|
|
448
|
+
for topic in topics_for_module(called.module):
|
|
449
|
+
works = []
|
|
450
|
+
for key in topic.sources:
|
|
451
|
+
work = reference(key)
|
|
452
|
+
if work is None:
|
|
453
|
+
continue
|
|
454
|
+
citation = re.sub(r"\*(.+?)\*", r"<i>\1</i>", escape(work.citation))
|
|
455
|
+
works.append(f"<a href=\"{work.url}\">{citation}</a>" if work.url else citation)
|
|
456
|
+
if works:
|
|
457
|
+
lines.append(f"<b>Follows</b> ({escape(topic.name)}): " + " · ".join(works))
|
|
458
|
+
if not lines:
|
|
459
|
+
return None
|
|
460
|
+
text = label("<br>".join(lines), "muted", wrap=True)
|
|
461
|
+
text.setObjectName("algorithmFollows")
|
|
462
|
+
text.setTextFormat(Qt.RichText)
|
|
463
|
+
text.setOpenExternalLinks(True)
|
|
464
|
+
return text
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def code_widget(spec: BoxSpec, page=None, compact: bool = True) -> QWidget:
|
|
468
|
+
"""The box's own source and, under it, the code of every algorithm it
|
|
469
|
+
calls (see :func:`openprocess.flow.box.algorithm_calls`): the box is a thin
|
|
470
|
+
wrapper, so the code to read when assessing correctness is the latter."""
|
|
471
|
+
host = QWidget()
|
|
472
|
+
layout = QVBoxLayout(host)
|
|
473
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
474
|
+
layout.setSpacing(8)
|
|
475
|
+
calls = algorithm_calls(spec)
|
|
476
|
+
where = spec.file if spec.custom else f"openprocess/flow/boxes/{spec.module.rsplit('.', 1)[-1]}.py"
|
|
477
|
+
if calls:
|
|
478
|
+
layout.addWidget(label("THE BOX", "sectionLabel"))
|
|
479
|
+
layout.addWidget(label(f"{where}:{spec.line}" if spec.line else where, "muted", wrap=True, selectable=True))
|
|
480
|
+
layout.addWidget(_code_view(spec.source, compact), 1)
|
|
481
|
+
actions = []
|
|
482
|
+
if spec.file and Path(spec.file).is_file():
|
|
483
|
+
actions.append(button("Open in your editor", lambda: _open_in_editor(spec.file),
|
|
484
|
+
tooltip="Open the file in the editor your system uses for .py files; the box "
|
|
485
|
+
"reloads when you save"))
|
|
486
|
+
if spec.needs:
|
|
487
|
+
actions.append(label("Needs " + ", ".join(spec.needs)
|
|
488
|
+
+ ("" if spec.available else " (not installed: the box is greyed out)"), "muted"))
|
|
489
|
+
if actions:
|
|
490
|
+
layout.addLayout(hbox(*actions, None))
|
|
491
|
+
if calls:
|
|
492
|
+
layout.addSpacing(6)
|
|
493
|
+
layout.addWidget(label("THE ALGORITHM", "sectionLabel"))
|
|
494
|
+
layout.addWidget(label("The box only wraps these: the actual work is in the functions it calls, "
|
|
495
|
+
"shown here in the order they are called. <i>Whole file</i> opens the module "
|
|
496
|
+
"they live in, whose docstring explains the algorithm and names its source.",
|
|
497
|
+
"muted", wrap=True))
|
|
498
|
+
for called in calls:
|
|
499
|
+
title = label(f"<b>{escape(called.name)}</b> · {escape(called.where)}", "muted", wrap=True,
|
|
500
|
+
selectable=True)
|
|
501
|
+
whole = button("Whole file ⤢", lambda _checked=False, c=called: show_file(c, host.window()),
|
|
502
|
+
tooltip="The module this function lives in, in a window of its own")
|
|
503
|
+
whole.setObjectName("algorithmFile")
|
|
504
|
+
layout.addLayout(hbox(title, None, whole))
|
|
505
|
+
follows = _follows(called)
|
|
506
|
+
if follows:
|
|
507
|
+
layout.addWidget(follows)
|
|
508
|
+
layout.addWidget(_code_view(called.source, compact))
|
|
509
|
+
layout.addWidget(label("The function is the box: call it from a script or a notebook and it runs the same way.",
|
|
510
|
+
"muted", wrap=True))
|
|
511
|
+
return host
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# Settings
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
class SettingsWidget(QWidget):
|
|
518
|
+
"""One control per setting of a box; ``changed`` carries the setting's
|
|
519
|
+
name and its new value (a :class:`Sweep` when swept)."""
|
|
520
|
+
|
|
521
|
+
changed = Signal(str, object)
|
|
522
|
+
|
|
523
|
+
def __init__(self, spec: BoxSpec, settings: dict, folder: Path | None = None, parent=None) -> None:
|
|
524
|
+
super().__init__(parent)
|
|
525
|
+
self.spec, self.folder = spec, folder
|
|
526
|
+
layout = QVBoxLayout(self)
|
|
527
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
528
|
+
layout.setSpacing(10)
|
|
529
|
+
if not spec.settings:
|
|
530
|
+
layout.addWidget(label("This box has no settings: its function takes only its inputs.", "muted",
|
|
531
|
+
wrap=True))
|
|
532
|
+
self.controls: dict[str, QWidget] = {}
|
|
533
|
+
for setting in spec.settings:
|
|
534
|
+
layout.addWidget(self._row(setting, settings.get(setting.name, setting.default)))
|
|
535
|
+
layout.addStretch(1)
|
|
536
|
+
|
|
537
|
+
def _row(self, setting: Setting, value) -> QWidget:
|
|
538
|
+
row = QWidget()
|
|
539
|
+
column = QVBoxLayout(row)
|
|
540
|
+
column.setContentsMargins(0, 0, 0, 0)
|
|
541
|
+
column.setSpacing(3)
|
|
542
|
+
title = label(setting.name.replace("_", " "))
|
|
543
|
+
title.setStyleSheet("font-weight: 500;")
|
|
544
|
+
column.addWidget(title)
|
|
545
|
+
swept = is_sweep(value)
|
|
546
|
+
control: QWidget
|
|
547
|
+
if setting.kind == "choice":
|
|
548
|
+
control = QComboBox()
|
|
549
|
+
for choice in setting.choices:
|
|
550
|
+
control.addItem(str(choice), choice)
|
|
551
|
+
control.setCurrentIndex(max(0, list(setting.choices).index(value) if value in setting.choices else 0))
|
|
552
|
+
control.currentIndexChanged.connect(lambda _i, s=setting, c=control: self.changed.emit(s.name, c.currentData()))
|
|
553
|
+
elif setting.kind == "bool":
|
|
554
|
+
control = QCheckBox("on")
|
|
555
|
+
control.setChecked(bool(value) if not swept else bool(setting.default))
|
|
556
|
+
control.toggled.connect(lambda on, s=setting: self.changed.emit(s.name, on))
|
|
557
|
+
elif setting.kind == "int":
|
|
558
|
+
control = QSpinBox()
|
|
559
|
+
control.setRange(-1_000_000, 1_000_000_000)
|
|
560
|
+
control.setValue(int(value) if not swept else int(setting.default))
|
|
561
|
+
control.valueChanged.connect(lambda v, s=setting: self.changed.emit(s.name, v))
|
|
562
|
+
elif setting.kind == "float":
|
|
563
|
+
control = QDoubleSpinBox()
|
|
564
|
+
control.setRange(-1e9, 1e9)
|
|
565
|
+
control.setDecimals(3)
|
|
566
|
+
control.setSingleStep(0.05)
|
|
567
|
+
control.setValue(float(value) if not swept else float(setting.default))
|
|
568
|
+
control.valueChanged.connect(lambda v, s=setting: self.changed.emit(s.name, v))
|
|
569
|
+
elif setting.kind == "path":
|
|
570
|
+
control = QComboBox()
|
|
571
|
+
control.setEditable(True)
|
|
572
|
+
for name in self._files():
|
|
573
|
+
control.addItem(name)
|
|
574
|
+
control.setCurrentText("" if value in (None, "") else str(value))
|
|
575
|
+
control.lineEdit().editingFinished.connect(
|
|
576
|
+
lambda s=setting, c=control: self.changed.emit(s.name, c.currentText()))
|
|
577
|
+
control.activated.connect(lambda _i, s=setting, c=control: self.changed.emit(s.name, c.currentText()))
|
|
578
|
+
else:
|
|
579
|
+
control = QLineEdit(str(value) if not swept else "")
|
|
580
|
+
control.editingFinished.connect(lambda s=setting, c=control: self.changed.emit(s.name, c.text()))
|
|
581
|
+
self.controls[setting.name] = control
|
|
582
|
+
column.addWidget(control)
|
|
583
|
+
if setting.kind in ("int", "float"):
|
|
584
|
+
sweep_row = QHBoxLayout()
|
|
585
|
+
sweep_row.setSpacing(6)
|
|
586
|
+
sweep_box = QCheckBox("Sweep")
|
|
587
|
+
sweep_box.setToolTip("Run the boxes after this one once per value, e.g. 0..0.5 step 0.1 or 1, 2, 3")
|
|
588
|
+
sweep_edit = QLineEdit(value.describe().replace(" … ", "..") if swept else "")
|
|
589
|
+
sweep_edit.setPlaceholderText("0..0.5 step 0.1 or 1, 2, 3")
|
|
590
|
+
sweep_edit.setVisible(swept)
|
|
591
|
+
sweep_box.setChecked(swept)
|
|
592
|
+
if swept:
|
|
593
|
+
sweep_edit.setText(", ".join(str(v) for v in value.values) if len(value.values) <= 12
|
|
594
|
+
else f"{value.values[0]}..{value.values[-1]} step {value.values[1] - value.values[0]}")
|
|
595
|
+
|
|
596
|
+
def toggle(on, s=setting, e=sweep_edit, c=control):
|
|
597
|
+
e.setVisible(on)
|
|
598
|
+
c.setEnabled(not on)
|
|
599
|
+
if not on:
|
|
600
|
+
self.changed.emit(s.name, c.value())
|
|
601
|
+
elif e.text().strip():
|
|
602
|
+
self._sweep(s, e)
|
|
603
|
+
sweep_box.toggled.connect(toggle)
|
|
604
|
+
sweep_edit.editingFinished.connect(lambda s=setting, e=sweep_edit: self._sweep(s, e))
|
|
605
|
+
control.setEnabled(not swept)
|
|
606
|
+
sweep_row.addWidget(sweep_box)
|
|
607
|
+
sweep_row.addWidget(sweep_edit, 1)
|
|
608
|
+
column.addLayout(sweep_row)
|
|
609
|
+
if setting.help:
|
|
610
|
+
column.addWidget(label(setting.help, "muted", wrap=True))
|
|
611
|
+
return row
|
|
612
|
+
|
|
613
|
+
def _sweep(self, setting: Setting, edit: QLineEdit) -> None:
|
|
614
|
+
try:
|
|
615
|
+
sweep = Sweep.parse(edit.text())
|
|
616
|
+
except ValueError as error:
|
|
617
|
+
edit.setToolTip(str(error))
|
|
618
|
+
edit.setStyleSheet(f"border: 1px solid {style.STATUS['critical']};")
|
|
619
|
+
return
|
|
620
|
+
edit.setStyleSheet("")
|
|
621
|
+
self.changed.emit(setting.name, sweep)
|
|
622
|
+
|
|
623
|
+
def _files(self) -> list[str]:
|
|
624
|
+
if self.folder is None or not Path(self.folder).is_dir():
|
|
625
|
+
return []
|
|
626
|
+
names = []
|
|
627
|
+
for path in sorted(Path(self.folder).rglob("*")):
|
|
628
|
+
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in (
|
|
629
|
+
".xes", ".gz", ".csv", ".txt", ".pnml", ".cpn") and len(names) < 200:
|
|
630
|
+
names.append(str(path.relative_to(self.folder)))
|
|
631
|
+
return names
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------------------
|
|
635
|
+
# A tab in its own window
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
_windows: list[QDialog] = []
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def pop_out(title: str, make_widget, parent=None) -> QDialog:
|
|
641
|
+
"""Show ``make_widget()`` in a window of its own (non-modal, resizable)."""
|
|
642
|
+
dialog = QDialog(parent)
|
|
643
|
+
dialog.setWindowTitle(title)
|
|
644
|
+
dialog.setModal(False)
|
|
645
|
+
dialog.resize(960, 700)
|
|
646
|
+
dialog.setLayout(vbox(scroll(make_widget()), margins=(16, 16, 16, 16)))
|
|
647
|
+
dialog.show()
|
|
648
|
+
_windows.append(dialog)
|
|
649
|
+
dialog.finished.connect(lambda _r: _windows.remove(dialog) if dialog in _windows else None)
|
|
650
|
+
return dialog
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def status_text(result: Result | None) -> tuple[str, str]:
|
|
654
|
+
"""(chip text, status word) for a box's result."""
|
|
655
|
+
if result is None:
|
|
656
|
+
return "Waiting", "muted"
|
|
657
|
+
if result.status == "done":
|
|
658
|
+
return (f"Done in {result.duration * 1000:.0f} ms" + (" (kept)" if result.cached else "")), "good"
|
|
659
|
+
if result.status == "running":
|
|
660
|
+
return "Running…", "accent"
|
|
661
|
+
if result.status == "failed":
|
|
662
|
+
return "Failed", "critical"
|
|
663
|
+
if result.status == "blocked":
|
|
664
|
+
return result.message or "Not allowed to run yet", "warning"
|
|
665
|
+
return result.message or "Waiting", "muted"
|