Share waveform data and plot navigation and retain processing results

This commit is contained in:
2026-09-27 02:47:24 +03:00
parent 513e79b127
commit caf30f2ed6
5 changed files with 534 additions and 31 deletions

View File

@@ -0,0 +1,112 @@
"""Common keyboard and wheel gestures for all plot canvases (Qt 5/6)."""
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QScrollArea
except ImportError:
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QScrollArea
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. '
'ЛКМ на свободном поле — перемещение; ←/→ — перемещение по X; Home/End — начало/конец.')
class PlotNavigation:
def setup_navigation(self):
self.setFocusPolicy(Qt.StrongFocus)
self.setToolTip(NAVIGATION_HINT)
def keyPressEvent(self, event):
key = event.key()
modifiers = event.modifiers()
if modifiers & (Qt.AltModifier | Qt.MetaModifier):
return super().keyPressEvent(event)
if key in (Qt.Key_Plus, Qt.Key_Equal, Qt.Key_Minus):
self._navigation_zoom(1.25 if key != Qt.Key_Minus else .8,
bool(modifiers & Qt.ControlModifier))
elif key in (Qt.Key_Left, Qt.Key_Right) and not (modifiers & Qt.ControlModifier):
self._navigation_pan(-.1 if key == Qt.Key_Left else .1)
elif key in (Qt.Key_Home, Qt.Key_End):
self._navigation_edge(key == Qt.Key_End)
else:
return super().keyPressEvent(event)
event.accept()
def wheelEvent(self, event):
position = event.position() if hasattr(event, 'position') else event.posF()
rect = self.plot_rect() if hasattr(self, 'plot_rect') else self.rect()
if not rect.contains(position.toPoint()):
event.ignore()
return
delta = event.angleDelta()
ticks = delta.y() or delta.x()
if not ticks:
pixels = event.pixelDelta()
ticks = (pixels.y() or pixels.x()) * 2
if ticks:
self.setFocus(Qt.MouseFocusReason)
self._navigation_zoom(1.25 ** max(-8, min(8, ticks / 120)),
bool(event.modifiers() & Qt.ControlModifier), position)
event.accept()
class ScrollPlotNavigation(PlotNavigation):
"""Canvas navigation for waveforms whose viewport is a QScrollArea."""
def scroll_area(self):
parent = self.parentWidget()
while parent is not None and not isinstance(parent, QScrollArea):
parent = parent.parentWidget()
return parent
def _navigation_zoom(self, factor, vertical, position=None):
scroll = self.scroll_area()
size = self.height() if vertical else self.width()
if position is not None:
anchor = position.y() if vertical else position.x()
elif scroll is not None:
bar = scroll.verticalScrollBar() if vertical else scroll.horizontalScrollBar()
anchor = bar.value() + bar.pageStep() / 2
else:
anchor = size / 2
self.zoom_dragged.emit('y' if vertical else 'x', factor, anchor / max(1, size))
def _navigation_pan(self, fraction):
scroll = self.scroll_area()
if scroll is not None:
bar = scroll.horizontalScrollBar()
bar.setValue(round(bar.value() + fraction * bar.pageStep()))
self.navigation_panned()
def _navigation_edge(self, end):
scroll = self.scroll_area()
if scroll is not None:
bar = scroll.horizontalScrollBar()
bar.setValue(bar.maximum() if end else bar.minimum())
self.navigation_panned()
def navigation_panned(self):
pass
def begin_pan(self, event):
self.setFocus(Qt.MouseFocusReason)
self._pan_position = event.globalPosition() if hasattr(event, 'globalPosition') else event.globalPos()
self.setCursor(Qt.ClosedHandCursor)
def move_pan(self, event):
previous = getattr(self, '_pan_position', None)
if previous is None:
return
position = event.globalPosition() if hasattr(event, 'globalPosition') else event.globalPos()
delta = position - previous
self._pan_position = position
scroll = self.scroll_area()
if scroll is not None:
for bar, amount in ((scroll.horizontalScrollBar(), delta.x()), (scroll.verticalScrollBar(), delta.y())):
bar.setValue(round(bar.value() - amount))
if delta.x():
self.navigation_panned()
def end_pan(self, event):
self.move_pan(event)
self._pan_position = None
self.unsetCursor()

View File

@@ -20,16 +20,20 @@ except ImportError:
from set_devices.signal_reconstruction import METHODS
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
RESULT_COLORS = ("#FF70D0", "#50D5FF", "#FFD166", "#83E377", "#B99AFF", "#FF9870")
class _ResultSignals(QObject):
done = Signal(object, object, str)
class _Calculation(QRunnable):
def __init__(self, signature):
def __init__(self, signature, parent=None):
super().__init__()
self.signature = signature
self.signals = _ResultSignals()
# Qt owns the emitter on the GUI thread, independently of QRunnable's
# auto-deletion by the pool (important for PySide2 wrapper lifetimes).
self.signals = _ResultSignals(parent)
def run(self):
try:
@@ -49,10 +53,14 @@ class SignalProcessingPanel(QWidget):
self._snapshot = Snapshot()
self._result = None
self._result_signature = None
self._results = []
self._result_ids = {}
self._next_result_id = 0
self._job = None
self._revision = 0
self._job_revision = 0
self.preserve_on_view_change = False
self.export_path = lambda filename: filename
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
@@ -81,11 +89,12 @@ class SignalProcessingPanel(QWidget):
self.apply_button = QPushButton("Рассчитать")
self.apply_button.clicked.connect(self.calculate)
row.addWidget(self.apply_button)
self.clear_button = QPushButton("Убрать")
self.clear_button = QPushButton("Убрать все")
self.clear_button.clicked.connect(self.clear)
row.addWidget(self.clear_button)
self.export_button = QPushButton("Результат CSV…")
self.export_button.clicked.connect(self.export_csv)
self.export_button.setToolTip("Сохранить последний рассчитанный результат")
row.addWidget(self.export_button)
row.addStretch(1)
layout.addLayout(row)
@@ -97,9 +106,9 @@ class SignalProcessingPanel(QWidget):
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
for widget in (self.channel, self.method):
widget.currentIndexChanged.connect(self.clear)
widget.currentIndexChanged.connect(self._parameters_changed)
for widget in (self.degree, self.count):
widget.valueChanged.connect(self.clear)
widget.valueChanged.connect(self._parameters_changed)
self._update_actions()
def _signature(self):
@@ -115,8 +124,7 @@ class SignalProcessingPanel(QWidget):
self.count.value(), self.degree.value())
def set_snapshot(self, snapshot):
previous = ((self._result_signature or (self._job.signature if self._job is not None else None))
if self.preserve_on_view_change else None) or self._signature()
previous = self._job.signature if self._job is not None else self._signature()
was_blocked = bool(self._snapshot.blocked_reason)
choices = [(signal.key, signal.label) for signal in snapshot.analogs]
current = [(self.channel.itemData(i), self.channel.itemText(i)) for i in range(self.channel.count())]
@@ -133,9 +141,19 @@ class SignalProcessingPanel(QWidget):
self._snapshot = snapshot
if previous != self._current_request_for(previous):
self._revision += 1
if self._result is not None:
self._result = self._result_signature = None
self.status.setText("Данные или окно изменились — нажмите «Рассчитать» повторно.")
retained = []
for result in self._results:
request = result.request
source = replace(snapshot, x_range=request.x_range) if self.preserve_on_view_change else snapshot
if prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
retained.append(result)
if len(retained) != len(self._results):
self._results = retained
self._result_ids = {id(curve): self._result_ids[id(curve)] for curve in retained}
if not any(curve is self._result for curve in retained):
self._result = retained[-1] if retained else None
self._result_signature = self._result.request if self._result is not None else None
self.status.setText("Данные или окно изменились — нажмите «Рассчитать» повторно.")
if snapshot.blocked_reason:
self.status.setText(snapshot.blocked_reason)
elif not choices:
@@ -155,11 +173,18 @@ class SignalProcessingPanel(QWidget):
def clear(self, *_):
self._revision += 1
self._results.clear()
self._result_ids.clear()
self._result = self._result_signature = None
self.status.setText("Расчёт по текущему окну. Исходные измерения сохраняются; кривая — оценка между точками.")
self._update_actions()
self.changed.emit()
def _parameters_changed(self, *_):
self._revision += 1
self.status.setText("Нажмите «Рассчитать», чтобы добавить кривую. Предыдущие результаты сохраняются.")
self._update_actions()
def calculate(self):
if self._job is not None or self._snapshot.blocked_reason:
return
@@ -167,15 +192,17 @@ class SignalProcessingPanel(QWidget):
if signature is None or len(signature.series.points) < 2:
self.status.setText("Нужны минимум две точки аналогового канала.")
return
self._job = _Calculation(signature)
self._job = _Calculation(signature, self)
self._job_revision = self._revision
self._job.signals.done.connect(self._finished)
self._job.signals.done.connect(self._finished, Qt.QueuedConnection)
self.status.setText("Расчёт…")
self._update_actions()
QThreadPool.globalInstance().start(self._job)
@Slot(object, object, str)
def _finished(self, signature, result, error):
if self._job is not None:
self._job.signals.deleteLater()
self._job = None
if signature != self._current_request_for(signature) or self._job_revision != self._revision:
self.status.setText("Данные изменились во время расчёта. Остановите сбор и повторите расчёт.")
@@ -183,19 +210,38 @@ class SignalProcessingPanel(QWidget):
self.status.setText("Расчёт не выполнен: " + error)
else:
self._result, self._result_signature = result, signature
# Repeating exactly the same request replaces its identical overlay.
existing = next((curve for curve in self._results if curve.request == signature), None)
if existing is not None:
self._result_ids[id(result)] = self._result_ids.pop(id(existing))
self._results[self._results.index(existing)] = result
else:
self._result_ids[id(result)] = self._next_result_id
self._next_result_id += 1
self._results.append(result)
duplicates = result.input_count - result.unique_count
self.status.setText(
f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; "
f"СКО на измерениях: {result.rmse:.6g}. "
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
+ "Розовая линия — расчётная оценка; исходные данные сохранены.")
+ f"Расчётных кривых: {len(self._results)}. Исходные данные сохранены.")
self._update_actions()
self.changed.emit()
def overlay(self):
if self._result is None or self._snapshot.blocked_reason:
return [], {}
return [(self.RESULT_KEY, self._result.label, False, "#FF70D0", True)], {self.RESULT_KEY: self._result.points}
specs, data = [], {}
for curve in self.curves:
index = self._result_ids[id(curve)]
key = self.RESULT_KEY if index == 0 else f"{self.RESULT_KEY}_{index}"
specs.append((key, curve.label, False, RESULT_COLORS[index % len(RESULT_COLORS)], True))
data[key] = curve.points
return specs, data
@property
def curves(self):
return tuple(self._results) if not self._snapshot.blocked_reason else ()
@property
def curve(self):
@@ -204,7 +250,8 @@ class SignalProcessingPanel(QWidget):
def export_csv(self):
if self._result is None:
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую", "calculated-signal.csv", "CSV (*.csv)")
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую",
self.export_path("calculated-signal.csv"), "CSV (*.csv)")
if not path:
return
try:
@@ -221,10 +268,11 @@ class PlotProcessingAttachment(QObject):
Paint after raw analog curves with the same rect/projection. No channel or
history mutation, no dependency on the caller's renderer or acquisition API.
"""
def __init__(self, parent, snapshot, repaint):
def __init__(self, parent, snapshot, repaint, export_path=None):
super().__init__(parent)
self.snapshot = snapshot
self.repaint = repaint
self.export_path = export_path
self.dialog = None
self.panel = None
self._external_curve = None
@@ -250,6 +298,12 @@ class PlotProcessingAttachment(QObject):
self._external_offset = x_offset
self.repaint()
@property
def curves(self):
if self._external_curve is not None:
return (self._external_curve,)
return self.panel.curves if self.panel is not None and not self._dirty else ()
def source_changed(self):
if self.panel is not None:
self._dirty = True
@@ -267,6 +321,8 @@ class PlotProcessingAttachment(QObject):
self.dialog.setWindowTitle("Обработка графика")
layout = QVBoxLayout(self.dialog)
self.panel = SignalProcessingPanel(self.dialog)
if self.export_path is not None:
self.panel.export_path = self.export_path
self.panel.changed.connect(self.repaint)
layout.addWidget(self.panel)
self.dialog.resize(760, 240)
@@ -276,18 +332,22 @@ class PlotProcessingAttachment(QObject):
self.dialog.activateWindow()
def paint(self, painter, rect, project):
curve = self.curve
if curve is None:
curves = self.curves
if not curves:
return
painter.save()
painter.setClipRect(rect)
path = QPainterPath()
for index, (x, y) in enumerate(curve.points):
point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect)
path.moveTo(point) if index == 0 else path.lineTo(point)
painter.setPen(QPen(QColor("#FF70D0"), 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
painter.drawPath(path)
painter.setPen(QColor("#FF70D0"))
painter.drawText(QPointF(rect.left() + 6, rect.bottom() - 8), curve.label)
for curve_index, curve in enumerate(curves):
path = QPainterPath()
for index, (x, y) in enumerate(curve.points):
point = project(x - (self._external_offset if self._external_curve is not None else 0), y, rect)
path.moveTo(point) if index == 0 else path.lineTo(point)
color_index = self.panel._result_ids[id(curve)] if self._external_curve is None else 0
color = QColor(RESULT_COLORS[color_index % len(RESULT_COLORS)])
painter.setPen(QPen(color, 2, Qt.DashLine))
painter.setBrush(Qt.NoBrush)
painter.drawPath(path)
painter.setPen(color)
painter.drawText(QPointF(rect.left() + 6,
rect.bottom() - 8 - curve_index * painter.fontMetrics().height()), curve.label)
painter.restore()