qplotter 1.6.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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Promote the bottom eligible dataset when a plot's source is removed."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from PyQt6 import QtWidgets as qtw
|
|
8
|
+
|
|
9
|
+
from ._plot_drop_axes import DropAxisPlan
|
|
10
|
+
from ._refresh_interval import refresh_interval_value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class _DatasetAppearance:
|
|
15
|
+
kind: str
|
|
16
|
+
key: Any
|
|
17
|
+
source: Any
|
|
18
|
+
plan: DropAxisPlan
|
|
19
|
+
style: Any = None
|
|
20
|
+
opacity: float = 1.0
|
|
21
|
+
visible: bool = True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _remaining_datasets(owner, selected):
|
|
25
|
+
datasets = []
|
|
26
|
+
for kind, key in owner._plot_item_order:
|
|
27
|
+
if (kind, key) in selected:
|
|
28
|
+
continue
|
|
29
|
+
if kind == "trace":
|
|
30
|
+
item = owner.lines[key]
|
|
31
|
+
style = deepcopy(owner._ensure_trace_styles()[key])
|
|
32
|
+
order = item.source_axis_order() or ("x", "y")
|
|
33
|
+
plan = DropAxisPlan(order, style.x_axis, style.y_axis, ())
|
|
34
|
+
datasets.append(_DatasetAppearance(kind, key, item.from_win, plan, style))
|
|
35
|
+
else:
|
|
36
|
+
item = owner.heatmaps[key]
|
|
37
|
+
sides = owner._heatmap_axis_sides(item)
|
|
38
|
+
plan = DropAxisPlan(item.source_axis_order() or ("x", "y"), sides[0], sides[1], ())
|
|
39
|
+
datasets.append(_DatasetAppearance(
|
|
40
|
+
kind, key, item.from_win, plan,
|
|
41
|
+
opacity=owner._heatmap_opacity(item), visible=owner._heatmap_is_visible(item),
|
|
42
|
+
))
|
|
43
|
+
return datasets
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _copy_operations(source, target):
|
|
47
|
+
options = {target.list_options.item(index).operation_row.label: target.list_options.item(index)
|
|
48
|
+
for index in range(target.list_options.count())}
|
|
49
|
+
for index in range(source.list_order.count()):
|
|
50
|
+
row = source.list_order.item(index)
|
|
51
|
+
if row.isHidden():
|
|
52
|
+
continue
|
|
53
|
+
option = options[row.label]
|
|
54
|
+
option.input.setChecked(True)
|
|
55
|
+
old_input, new_input = row.input, option.operation_row.input
|
|
56
|
+
if isinstance(old_input, qtw.QLineEdit):
|
|
57
|
+
new_input.setText(old_input.text())
|
|
58
|
+
elif isinstance(old_input, qtw.QComboBox):
|
|
59
|
+
new_input.setCurrentText(old_input.currentText())
|
|
60
|
+
elif isinstance(old_input, qtw.QCheckBox):
|
|
61
|
+
new_input.setChecked(old_input.isChecked())
|
|
62
|
+
target._apply_generation = source._apply_generation
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _copy_view(owner, replacement):
|
|
66
|
+
for axis in ("x", "y", "x2", "y2"):
|
|
67
|
+
if not replacement._axis_scale_axis_is_used(axis):
|
|
68
|
+
continue
|
|
69
|
+
old = owner._axis_scale_viewbox(axis)
|
|
70
|
+
new = replacement._axis_scale_viewbox(axis)
|
|
71
|
+
dimension = 0 if axis in ("x", "x2") else 1
|
|
72
|
+
replacement._axis_scale_log_toggled(axis, owner._axis_scale_log_mode(axis))
|
|
73
|
+
new.setRange(**{"xRange" if dimension == 0 else "yRange": old.viewRange()[dimension]}, padding=0)
|
|
74
|
+
new.enableAutoRange(dimension, old.state["autoRange"][dimension])
|
|
75
|
+
dimension_name = "x" if dimension == 0 else "y"
|
|
76
|
+
new.setAutoVisible(**{dimension_name: old.state["autoVisibleOnly"][dimension]})
|
|
77
|
+
new.setAutoPan(**{dimension_name: old.state["autoPan"][dimension]})
|
|
78
|
+
new.setMouseEnabled(**{"x" if dimension == 0 else "y": old.state["mouseEnabled"][dimension]})
|
|
79
|
+
if dimension == 0:
|
|
80
|
+
new.invertX(old.state["xInverted"])
|
|
81
|
+
else:
|
|
82
|
+
new.invertY(old.state["yInverted"])
|
|
83
|
+
replacement._axis_scale_custom_auto_axes = set(owner._axis_scale_custom_auto_axes)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _copy_window_layout(owner, replacement):
|
|
87
|
+
for window in (owner, replacement):
|
|
88
|
+
for widget_type in (qtw.QDockWidget, qtw.QToolBar):
|
|
89
|
+
for widget in window.findChildren(widget_type):
|
|
90
|
+
widget.setObjectName(f"{widget_type.__name__}:{widget.windowTitle()}")
|
|
91
|
+
replacement.restoreState(owner.saveState())
|
|
92
|
+
replacement.setGeometry(owner.geometry())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def promote_plot_source(main: Any, owner: Any, selected: list[tuple[str, Any]]) -> Any:
|
|
96
|
+
"""Rebuild the source-specific controls and release the old plot owner.
|
|
97
|
+
|
|
98
|
+
Construct the replacement before closing the old owner, so every surviving
|
|
99
|
+
dataset stays retained and a construction failure leaves the plot intact.
|
|
100
|
+
Rebuilding also supports a change between trace and heatmap source types.
|
|
101
|
+
"""
|
|
102
|
+
datasets = _remaining_datasets(owner, selected)
|
|
103
|
+
# Cuts have their own linked controls and cannot host a mixed plot. Keep
|
|
104
|
+
# their overlays in place while choosing the bottom eligible full source.
|
|
105
|
+
primary = next((dataset for dataset in reversed(datasets)
|
|
106
|
+
if dataset.source.operation_kind in {"plot1d", "plot2d"}), None)
|
|
107
|
+
if primary is None:
|
|
108
|
+
raise ValueError("Keep a full line or heatmap dataset in this plot; cuts cannot be its primary source.")
|
|
109
|
+
source = primary.source
|
|
110
|
+
dialog = owner.__dict__.get("_plot_appearance_dialog")
|
|
111
|
+
show_dialog = dialog is not None and dialog.isVisible()
|
|
112
|
+
dialog_geometry = dialog.saveGeometry() if show_dialog else None
|
|
113
|
+
previous_windows = set(main.windows)
|
|
114
|
+
next_position = main._plot_tile_grid, main._next_plot_tile
|
|
115
|
+
replacement = None
|
|
116
|
+
try:
|
|
117
|
+
main.openWin(type(source), source._dataset_key, source.param,
|
|
118
|
+
refrate=refresh_interval_value(owner.spinBox), show=True)
|
|
119
|
+
replacement = next(win for win in main.windows if win not in previous_windows)
|
|
120
|
+
_copy_window_layout(owner, replacement)
|
|
121
|
+
_copy_operations(source.oper_widget, replacement.oper_widget)
|
|
122
|
+
order = primary.plan.axis_order
|
|
123
|
+
for dimension, source_axis in zip(("x", "y"), order, strict=True):
|
|
124
|
+
combo = replacement.axis_dropdown[dimension]
|
|
125
|
+
blocked = combo.blockSignals(True)
|
|
126
|
+
combo.setCurrentText(source.axis_options[source_axis])
|
|
127
|
+
combo.blockSignals(blocked)
|
|
128
|
+
replacement._axis_selection = replacement.axis_options
|
|
129
|
+
remapped_order: list[tuple[str, Any]] = []
|
|
130
|
+
for dataset in reversed(datasets):
|
|
131
|
+
if dataset is primary:
|
|
132
|
+
key = replacement.label if dataset.kind == "trace" else replacement._primary_heatmap_key
|
|
133
|
+
item = replacement.line if dataset.kind == "trace" else replacement
|
|
134
|
+
else:
|
|
135
|
+
key = dataset.key
|
|
136
|
+
if dataset.kind == "trace":
|
|
137
|
+
replacement.add_preview_trace(dataset.source, dataset.plan)
|
|
138
|
+
item = replacement.lines[key]
|
|
139
|
+
else:
|
|
140
|
+
replacement.add_heatmap(dataset.source, axis_plan=dataset.plan)
|
|
141
|
+
item = replacement.heatmaps[key]
|
|
142
|
+
if dataset.kind == "trace":
|
|
143
|
+
replacement._ensure_trace_styles()[key] = dataset.style
|
|
144
|
+
replacement._apply_trace_style(key, item)
|
|
145
|
+
else:
|
|
146
|
+
replacement._set_layer_axes(item, dataset.plan.x_side, dataset.plan.y_side)
|
|
147
|
+
replacement._set_layer_opacity(item, dataset.opacity)
|
|
148
|
+
replacement._set_layer_visibility(item, dataset.visible)
|
|
149
|
+
remapped_order.insert(0, (dataset.kind, key))
|
|
150
|
+
replacement._plot_item_order = remapped_order
|
|
151
|
+
replacement._sync_plot_item_order()
|
|
152
|
+
for name in ("subtractMeanCheck", "phasemapCheck", "fftCheck", "derivativeCheck"):
|
|
153
|
+
getattr(replacement.plot.ctrl, name).setChecked(getattr(owner.plot.ctrl, name).isChecked())
|
|
154
|
+
if "_colorbar_colormap_name" in owner.__dict__:
|
|
155
|
+
replacement._colorbar_colormap_name = owner._colorbar_colormap_name
|
|
156
|
+
bar = replacement.__dict__.get("bar")
|
|
157
|
+
if bar is not None:
|
|
158
|
+
bar.setColorMap(replacement._colorbar_colormap())
|
|
159
|
+
replacement._colorbar_manual_levels = owner.__dict__.get("_colorbar_manual_levels")
|
|
160
|
+
replacement._sync_heatmap_colorbar_items(rescale=True)
|
|
161
|
+
_copy_view(owner, replacement)
|
|
162
|
+
replacement.refreshWindow(force=True)
|
|
163
|
+
except Exception:
|
|
164
|
+
for win in list(main.windows):
|
|
165
|
+
if win not in previous_windows:
|
|
166
|
+
win.close()
|
|
167
|
+
win.deleteLater()
|
|
168
|
+
raise
|
|
169
|
+
finally:
|
|
170
|
+
main._plot_tile_grid, main._next_plot_tile = next_position
|
|
171
|
+
owner.close()
|
|
172
|
+
if owner._merged_trace_users <= 0:
|
|
173
|
+
owner.deleteLater()
|
|
174
|
+
replacement.show()
|
|
175
|
+
if show_dialog:
|
|
176
|
+
replacement.open_plot_appearance_dialog()
|
|
177
|
+
replacement._plot_appearance_dialog.restoreGeometry(dialog_geometry)
|
|
178
|
+
return replacement
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from time import perf_counter
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from PyQt6 import QtCore
|
|
5
|
+
from PyQt6 import QtWidgets as qtw
|
|
6
|
+
|
|
7
|
+
from qplot.datahandling.plot_progress import PlotProgress
|
|
8
|
+
|
|
9
|
+
from ._plot_status import PlotStatusWidget
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlotStateOverlay(QtCore.QObject):
|
|
13
|
+
"""
|
|
14
|
+
Keep empty/error explanations in the plot and loading progress in its toolbar.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_styles: dict[str, str] = {
|
|
18
|
+
"info": (
|
|
19
|
+
"background-color: rgba(250, 252, 255, 235);"
|
|
20
|
+
"border: 1px solid rgba(119, 135, 153, 190);"
|
|
21
|
+
"color: #1f2933;"
|
|
22
|
+
),
|
|
23
|
+
"loading": (
|
|
24
|
+
"background-color: rgba(246, 251, 255, 238);"
|
|
25
|
+
"border: 1px solid rgba(70, 130, 180, 190);"
|
|
26
|
+
"color: #102a43;"
|
|
27
|
+
),
|
|
28
|
+
"empty": (
|
|
29
|
+
"background-color: rgba(255, 251, 235, 238);"
|
|
30
|
+
"border: 1px solid rgba(180, 132, 35, 190);"
|
|
31
|
+
"color: #3b2f12;"
|
|
32
|
+
),
|
|
33
|
+
"error": (
|
|
34
|
+
"background-color: rgba(255, 245, 245, 240);"
|
|
35
|
+
"border: 1px solid rgba(190, 72, 72, 200);"
|
|
36
|
+
"color: #3b0d0c;"
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def __init__(self, target: qtw.QWidget, feedback: PlotStatusWidget) -> None:
|
|
41
|
+
super().__init__(target)
|
|
42
|
+
self.feedback = feedback
|
|
43
|
+
self.owner: qtw.QWidget = target
|
|
44
|
+
target_obj: Any = target
|
|
45
|
+
viewport = target_obj.viewport() if hasattr(target_obj, "viewport") else None
|
|
46
|
+
self.target: qtw.QWidget = viewport or target
|
|
47
|
+
|
|
48
|
+
self.frame = qtw.QFrame(self.target)
|
|
49
|
+
self.frame.setObjectName("plotStateOverlay")
|
|
50
|
+
self.frame.setAttribute(QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
|
51
|
+
self.frame.setAutoFillBackground(False)
|
|
52
|
+
|
|
53
|
+
layout = qtw.QVBoxLayout(self.frame)
|
|
54
|
+
layout.setContentsMargins(14, 10, 14, 10)
|
|
55
|
+
layout.setSpacing(4)
|
|
56
|
+
|
|
57
|
+
self.title_label = qtw.QLabel(self.frame)
|
|
58
|
+
self.title_label.setObjectName("plotStateOverlayTitle")
|
|
59
|
+
title_font = self.title_label.font()
|
|
60
|
+
title_font.setBold(True)
|
|
61
|
+
self.title_label.setFont(title_font)
|
|
62
|
+
self.title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
63
|
+
layout.addWidget(self.title_label)
|
|
64
|
+
|
|
65
|
+
self.detail_label = qtw.QLabel(self.frame)
|
|
66
|
+
self.detail_label.setObjectName("plotStateOverlayDetail")
|
|
67
|
+
self.detail_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
68
|
+
self.detail_label.setWordWrap(True)
|
|
69
|
+
layout.addWidget(self.detail_label)
|
|
70
|
+
|
|
71
|
+
self.progress_bar = feedback.progress_bar
|
|
72
|
+
self.elapsed_label = feedback.elapsed_label
|
|
73
|
+
self._worker: Any = None
|
|
74
|
+
self._last_progress: PlotProgress | None = None
|
|
75
|
+
self._started_at: float | None = None
|
|
76
|
+
self._rendering = False
|
|
77
|
+
self.progress_timer = QtCore.QTimer(self)
|
|
78
|
+
self.progress_timer.setInterval(100)
|
|
79
|
+
self.progress_timer.timeout.connect(self._poll_progress)
|
|
80
|
+
|
|
81
|
+
self.target.installEventFilter(self)
|
|
82
|
+
if self.owner is not self.target:
|
|
83
|
+
self.owner.installEventFilter(self)
|
|
84
|
+
self.hide()
|
|
85
|
+
|
|
86
|
+
def show(
|
|
87
|
+
self,
|
|
88
|
+
title: object,
|
|
89
|
+
detail: object | None = None,
|
|
90
|
+
kind: str = "info",
|
|
91
|
+
) -> None:
|
|
92
|
+
if kind != "loading":
|
|
93
|
+
self._stop_progress()
|
|
94
|
+
self.title_label.setText(str(title or ""))
|
|
95
|
+
self.detail_label.setText(str(detail or ""))
|
|
96
|
+
self.detail_label.setVisible(bool(detail))
|
|
97
|
+
if kind == "loading":
|
|
98
|
+
self.frame.hide()
|
|
99
|
+
return
|
|
100
|
+
self.feedback.show_message(" — ".join(str(value) for value in (title, detail) if value))
|
|
101
|
+
self.frame.setStyleSheet(self._stylesheet(kind))
|
|
102
|
+
self._sync_geometry()
|
|
103
|
+
self.frame.show()
|
|
104
|
+
self.frame.raise_()
|
|
105
|
+
|
|
106
|
+
def hide(self) -> None:
|
|
107
|
+
self._stop_progress()
|
|
108
|
+
self.frame.hide()
|
|
109
|
+
|
|
110
|
+
def track(self, worker: Any) -> None:
|
|
111
|
+
"""Poll one latest value, never queue an event per reader chunk."""
|
|
112
|
+
self._worker = worker
|
|
113
|
+
self._last_progress = None
|
|
114
|
+
self._started_at = getattr(worker, "started_at", perf_counter())
|
|
115
|
+
self._rendering = False
|
|
116
|
+
self.feedback.start_progress()
|
|
117
|
+
self._poll_progress()
|
|
118
|
+
if self._worker is worker:
|
|
119
|
+
self.progress_timer.start()
|
|
120
|
+
|
|
121
|
+
def _stop_progress(self) -> None:
|
|
122
|
+
self.progress_timer.stop()
|
|
123
|
+
self._worker = None
|
|
124
|
+
self._last_progress = None
|
|
125
|
+
self._started_at = None
|
|
126
|
+
self._rendering = False
|
|
127
|
+
self.progress_bar.hide()
|
|
128
|
+
self.elapsed_label.hide()
|
|
129
|
+
self.feedback.finish_progress()
|
|
130
|
+
|
|
131
|
+
def _update_elapsed(self) -> None:
|
|
132
|
+
if self._started_at is None:
|
|
133
|
+
return
|
|
134
|
+
seconds = max(0, int(perf_counter() - self._started_at))
|
|
135
|
+
minutes, seconds = divmod(seconds, 60)
|
|
136
|
+
hours, minutes = divmod(minutes, 60)
|
|
137
|
+
duration = (f"{hours}h {minutes:02d}m {seconds:02d}s" if hours else
|
|
138
|
+
f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s")
|
|
139
|
+
text = duration
|
|
140
|
+
if self.elapsed_label.text() != text:
|
|
141
|
+
self.elapsed_label.setText(text)
|
|
142
|
+
self.elapsed_label.show()
|
|
143
|
+
|
|
144
|
+
def _poll_progress(self) -> None:
|
|
145
|
+
worker = self._worker
|
|
146
|
+
if worker is None:
|
|
147
|
+
return
|
|
148
|
+
if worker.is_cancelled():
|
|
149
|
+
self.show("Plot load cancelled")
|
|
150
|
+
return
|
|
151
|
+
self._update_elapsed()
|
|
152
|
+
self.feedback.show_progress()
|
|
153
|
+
if self._rendering:
|
|
154
|
+
return
|
|
155
|
+
progress = worker.progress
|
|
156
|
+
if progress == self._last_progress:
|
|
157
|
+
return
|
|
158
|
+
self._last_progress = progress
|
|
159
|
+
self.feedback.set_progress_details(progress, self.title_label.text())
|
|
160
|
+
self.detail_label.setText(progress.title)
|
|
161
|
+
self.detail_label.show()
|
|
162
|
+
if progress.total is None or progress.total <= 0:
|
|
163
|
+
self.progress_bar.setRange(0, 0)
|
|
164
|
+
self.progress_bar.setFormat("")
|
|
165
|
+
else:
|
|
166
|
+
self.progress_bar.setRange(0, 1000)
|
|
167
|
+
self.progress_bar.setValue(min(1000, max(0, progress.completed * 1000 // progress.total)))
|
|
168
|
+
self.progress_bar.setFormat("%p%")
|
|
169
|
+
self.progress_bar.show()
|
|
170
|
+
|
|
171
|
+
def rendering(self, worker: Any) -> None:
|
|
172
|
+
if worker is not self._worker:
|
|
173
|
+
return
|
|
174
|
+
self._rendering = True
|
|
175
|
+
self._update_elapsed()
|
|
176
|
+
progress = PlotProgress("Rendering plot", stage=2)
|
|
177
|
+
self.detail_label.setText(progress.title)
|
|
178
|
+
self.feedback.set_progress_details(progress, self.title_label.text())
|
|
179
|
+
self.feedback.show_progress()
|
|
180
|
+
self.progress_bar.setRange(0, 0)
|
|
181
|
+
self.progress_bar.setFormat("")
|
|
182
|
+
|
|
183
|
+
def finish(self, worker: Any) -> None:
|
|
184
|
+
if worker is self._worker:
|
|
185
|
+
# Only a loading state is removed. Empty/error explanations stay.
|
|
186
|
+
self.hide()
|
|
187
|
+
|
|
188
|
+
def eventFilter(
|
|
189
|
+
self,
|
|
190
|
+
source: QtCore.QObject | None,
|
|
191
|
+
event: QtCore.QEvent | None,
|
|
192
|
+
) -> bool:
|
|
193
|
+
if (
|
|
194
|
+
event is not None
|
|
195
|
+
and (source is self.owner or source is self.target)
|
|
196
|
+
and event.type() in (QtCore.QEvent.Type.Resize, QtCore.QEvent.Type.Show)
|
|
197
|
+
):
|
|
198
|
+
self._sync_geometry()
|
|
199
|
+
return super().eventFilter(source, event)
|
|
200
|
+
|
|
201
|
+
def _stylesheet(self, kind: str) -> str:
|
|
202
|
+
panel_style = self._styles.get(kind, self._styles["info"])
|
|
203
|
+
return (
|
|
204
|
+
"QFrame#plotStateOverlay {"
|
|
205
|
+
f"{panel_style}"
|
|
206
|
+
"border-radius: 6px;"
|
|
207
|
+
"}"
|
|
208
|
+
"QLabel#plotStateOverlayTitle {"
|
|
209
|
+
"background: transparent;"
|
|
210
|
+
"font-size: 10pt;"
|
|
211
|
+
"}"
|
|
212
|
+
"QLabel#plotStateOverlayDetail {"
|
|
213
|
+
"background: transparent;"
|
|
214
|
+
"font-size: 8pt;"
|
|
215
|
+
"}"
|
|
216
|
+
"QLabel#plotStateOverlayElapsed {"
|
|
217
|
+
"background: transparent;"
|
|
218
|
+
"font-size: 8pt;"
|
|
219
|
+
"}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _sync_geometry(self) -> None:
|
|
223
|
+
if not self.frame.isVisible() and not self.title_label.text():
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
target_rect = self.target.rect()
|
|
227
|
+
margin = 24
|
|
228
|
+
max_width = max(160, min(420, target_rect.width() - (2 * margin)))
|
|
229
|
+
self.frame.setMaximumWidth(max_width)
|
|
230
|
+
self.frame.adjustSize()
|
|
231
|
+
|
|
232
|
+
size = self.frame.sizeHint()
|
|
233
|
+
width = min(max_width, max(160, size.width()))
|
|
234
|
+
height = size.height()
|
|
235
|
+
x = target_rect.left() + max(margin, (target_rect.width() - width) // 2)
|
|
236
|
+
y = target_rect.top() + max(margin, (target_rect.height() - height) // 2)
|
|
237
|
+
self.frame.setGeometry(x, y, width, height)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Compact, persistent feedback for the refresh toolbar."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from PyQt6 import QtCore, QtGui
|
|
6
|
+
from PyQt6 import QtWidgets as qtw
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _StatusLabel(qtw.QLabel):
|
|
10
|
+
def minimumSizeHint(self):
|
|
11
|
+
return QtCore.QSize(0, super().minimumSizeHint().height())
|
|
12
|
+
|
|
13
|
+
def paintEvent(self, event):
|
|
14
|
+
painter = QtGui.QPainter(self)
|
|
15
|
+
self.style().drawItemText(
|
|
16
|
+
painter, self.contentsRect(),
|
|
17
|
+
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter,
|
|
18
|
+
self.palette(), self.isEnabled(),
|
|
19
|
+
self.fontMetrics().elidedText(
|
|
20
|
+
self.text(), QtCore.Qt.TextElideMode.ElideRight, self.contentsRect().width(),
|
|
21
|
+
),
|
|
22
|
+
QtGui.QPalette.ColorRole.WindowText,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlotStatusWidget(qtw.QWidget):
|
|
27
|
+
"""Use the available toolbar space for a message or current-stage progress."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, parent=None):
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self.setObjectName("plotToolbarStatus")
|
|
32
|
+
self.setSizePolicy(qtw.QSizePolicy.Policy.Expanding, qtw.QSizePolicy.Policy.Preferred)
|
|
33
|
+
self.message = ""
|
|
34
|
+
self._progress_active = False
|
|
35
|
+
|
|
36
|
+
self.status_label = _StatusLabel(self)
|
|
37
|
+
self.status_label.setObjectName("plotStatusMessage")
|
|
38
|
+
self.status_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
|
|
39
|
+
self.status_label.setAccessibleName("Plot status")
|
|
40
|
+
self.status_label.setSizePolicy(qtw.QSizePolicy.Policy.Ignored, qtw.QSizePolicy.Policy.Preferred)
|
|
41
|
+
|
|
42
|
+
self.progress_panel = qtw.QWidget(self)
|
|
43
|
+
row = qtw.QHBoxLayout(self.progress_panel)
|
|
44
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
45
|
+
row.setSpacing(8)
|
|
46
|
+
self.progress_bar = qtw.QProgressBar(self.progress_panel)
|
|
47
|
+
self.progress_bar.setAccessibleName("Plot loading progress for the current stage")
|
|
48
|
+
self.progress_bar.setMinimumWidth(40)
|
|
49
|
+
self.progress_bar.setMaximumWidth(240)
|
|
50
|
+
self.progress_bar.setFormat("%p%")
|
|
51
|
+
self.elapsed_label = qtw.QLabel(self.progress_panel)
|
|
52
|
+
self.elapsed_label.setAccessibleName("Elapsed plot loading time")
|
|
53
|
+
self.stage_label = qtw.QLabel(self.progress_panel)
|
|
54
|
+
row.addStretch()
|
|
55
|
+
row.addWidget(self.progress_bar, 1)
|
|
56
|
+
row.addWidget(self.elapsed_label)
|
|
57
|
+
row.addWidget(self.stage_label)
|
|
58
|
+
|
|
59
|
+
self.stack = qtw.QStackedLayout()
|
|
60
|
+
# QStackedLayout fills its geometry without applying content margins.
|
|
61
|
+
# An outer layout reserves real space beside both status views.
|
|
62
|
+
layout = qtw.QHBoxLayout(self)
|
|
63
|
+
layout.setContentsMargins(12, 0, 16, 0)
|
|
64
|
+
layout.addLayout(self.stack)
|
|
65
|
+
# Long status text must not enlarge the toolbar or hide its controls.
|
|
66
|
+
self.stack.setSizeConstraint(qtw.QLayout.SizeConstraint.SetNoConstraint)
|
|
67
|
+
layout.setSizeConstraint(qtw.QLayout.SizeConstraint.SetNoConstraint)
|
|
68
|
+
self.stack.addWidget(self.status_label)
|
|
69
|
+
self.stack.addWidget(self.progress_panel)
|
|
70
|
+
|
|
71
|
+
def minimumSizeHint(self):
|
|
72
|
+
return QtCore.QSize(0, self.stack.minimumSize().height())
|
|
73
|
+
|
|
74
|
+
def sizeHint(self):
|
|
75
|
+
return QtCore.QSize(320, self.stack.sizeHint().height())
|
|
76
|
+
|
|
77
|
+
def show_message(self, message: str) -> None:
|
|
78
|
+
self.message = message
|
|
79
|
+
self.status_label.setText(message)
|
|
80
|
+
if message:
|
|
81
|
+
created_at = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
82
|
+
self.status_label.setToolTip(f"{message}\nCreated: {created_at}")
|
|
83
|
+
else:
|
|
84
|
+
self.status_label.setToolTip("")
|
|
85
|
+
self.stack.setCurrentWidget(self.status_label)
|
|
86
|
+
|
|
87
|
+
def start_progress(self) -> None:
|
|
88
|
+
# Never restore an earlier operation's message when this load ends.
|
|
89
|
+
self.message = ""
|
|
90
|
+
self._progress_active = True
|
|
91
|
+
self.show_progress()
|
|
92
|
+
|
|
93
|
+
def show_progress(self) -> None:
|
|
94
|
+
self.stack.setCurrentWidget(self.progress_panel)
|
|
95
|
+
|
|
96
|
+
def finish_progress(self) -> None:
|
|
97
|
+
if self._progress_active:
|
|
98
|
+
self._progress_active = False
|
|
99
|
+
if self.message:
|
|
100
|
+
# Restore the message with its original creation timestamp.
|
|
101
|
+
self.stack.setCurrentWidget(self.status_label)
|
|
102
|
+
else:
|
|
103
|
+
self.show_message("Plot load complete.")
|
|
104
|
+
|
|
105
|
+
def set_progress_details(self, progress, context: str) -> None:
|
|
106
|
+
self.stage_label.setText(f"Stage {progress.stage}/2")
|
|
107
|
+
details = f"{context}\n{progress.title}\n"
|
|
108
|
+
if progress.total is not None and progress.total > 0:
|
|
109
|
+
details += f"{progress.completed:,} / {progress.total:,} ({progress.unit})\n"
|
|
110
|
+
details += (
|
|
111
|
+
"Progress is for the current stage. Scan progress covers the captured "
|
|
112
|
+
"source range, including partial array records."
|
|
113
|
+
)
|
|
114
|
+
self.progress_panel.setToolTip(details)
|
|
115
|
+
self.progress_bar.setAccessibleDescription(details)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared preferences and geometry for newly opened plot windows."""
|
|
2
|
+
|
|
3
|
+
PLOT_TILE_ROWS_KEY = "user_preference.plot_tile_rows"
|
|
4
|
+
PLOT_TILE_COLUMNS_KEY = "user_preference.plot_tile_columns"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def plot_tile_grid(config):
|
|
8
|
+
"""Return the configured (rows, columns) grid."""
|
|
9
|
+
return config.get(PLOT_TILE_ROWS_KEY), config.get(PLOT_TILE_COLUMNS_KEY)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def plot_tile_size(config, screenrect):
|
|
13
|
+
"""Size plots to their grid cells, retaining the configured frame margin."""
|
|
14
|
+
rows, columns = plot_tile_grid(config)
|
|
15
|
+
fraction = min(1.0, 2 * config.get("GUI.plot_frame_fraction"))
|
|
16
|
+
return (
|
|
17
|
+
max(1, int(fraction * screenrect.width() / columns)),
|
|
18
|
+
max(1, int(fraction * screenrect.height() / rows)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def place_plot_window(owner, window):
|
|
23
|
+
"""Fill grid cells in row order and wrap; restart when the grid changes."""
|
|
24
|
+
grid = plot_tile_grid(owner.config)
|
|
25
|
+
if getattr(owner, "_plot_tile_grid", None) != grid:
|
|
26
|
+
owner._plot_tile_grid = grid
|
|
27
|
+
owner._next_plot_tile = 0
|
|
28
|
+
rows, columns = grid
|
|
29
|
+
row, column = divmod(owner._next_plot_tile, columns)
|
|
30
|
+
rect = owner.screenrect
|
|
31
|
+
window.move(
|
|
32
|
+
rect.left() + column * rect.width() // columns,
|
|
33
|
+
rect.top() + row * rect.height() // rows,
|
|
34
|
+
)
|
|
35
|
+
owner._next_plot_tile = (owner._next_plot_tile + 1) % (rows * columns)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Display quantities for PyQtGraph's native PlotDataItem mappings.
|
|
2
|
+
|
|
3
|
+
Input parameters describe samples after qPlot operations. These immutable
|
|
4
|
+
descriptions never replace measurement or operation metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AxisQuantity:
|
|
13
|
+
label: str
|
|
14
|
+
unit: str
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_parameter(cls, parameter: Any) -> "AxisQuantity":
|
|
18
|
+
return cls(
|
|
19
|
+
str(getattr(parameter, "label", None) or getattr(parameter, "name", "")),
|
|
20
|
+
str(getattr(parameter, "unit", "") or ""),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unit_operand(unit: str) -> str:
|
|
25
|
+
return f"({unit})" if any(char in unit for char in "/ *") else unit
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _quotient_unit(numerator: str, denominator: str) -> str:
|
|
29
|
+
if not denominator:
|
|
30
|
+
return numerator
|
|
31
|
+
return f"{_unit_operand(numerator) if numerator else '1'}/{_unit_operand(denominator)}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def native_axis_quantities(
|
|
35
|
+
x_parameter: Any, y_parameter: Any, options: dict[str, Any],
|
|
36
|
+
) -> dict[str, AxisQuantity]:
|
|
37
|
+
"""Mirror native mapping order, including mappings that reset to input.
|
|
38
|
+
|
|
39
|
+
PyQtGraph 0.14 subtracts the mean, then computes abs(rfft(y)/N).
|
|
40
|
+
Derivative mode replaces Y with diff(input Y)/diff(input X), retaining
|
|
41
|
+
the current X. Phase-map mode replaces both coordinates with input Y
|
|
42
|
+
and its derivative. Log ticking still describes these physical quantities.
|
|
43
|
+
"""
|
|
44
|
+
input_x = AxisQuantity.from_parameter(x_parameter)
|
|
45
|
+
input_y = AxisQuantity.from_parameter(y_parameter)
|
|
46
|
+
x, y = input_x, input_y
|
|
47
|
+
if options.get("subtractMeanMode"):
|
|
48
|
+
y = AxisQuantity(f"{y.label} - mean({y.label})", y.unit)
|
|
49
|
+
if options.get("fftMode"):
|
|
50
|
+
x = AxisQuantity(f"Frequency of {input_x.label}", _quotient_unit("", input_x.unit))
|
|
51
|
+
fft_input = f"({y.label})" if options.get("subtractMeanMode") else y.label
|
|
52
|
+
y = AxisQuantity(f"FFT magnitude of {fft_input}", y.unit)
|
|
53
|
+
if options.get("derivativeMode") or options.get("phasemapMode"):
|
|
54
|
+
y = AxisQuantity(
|
|
55
|
+
f"d({input_y.label})/d({input_x.label})",
|
|
56
|
+
_quotient_unit(input_y.unit, input_x.unit),
|
|
57
|
+
)
|
|
58
|
+
if options.get("phasemapMode"):
|
|
59
|
+
x = input_y
|
|
60
|
+
return {"x": x, "y": y}
|