Files
templates/python/set_devices/qt_ports/plot_processing.py

294 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Reusable Qt processing panel and plot attachment; no SETGUI dependency."""
from __future__ import annotations
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
from PySide6.QtGui import QColor, QPainterPath, QPen
from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
QVBoxLayout, QWidget,
)
except ImportError:
from PySide2.QtCore import QObject, QPointF, QRunnable, QThreadPool, QSignalBlocker, QTimer, Qt, Signal, Slot
from PySide2.QtGui import QColor, QPainterPath, QPen
from PySide2.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox,
QVBoxLayout, QWidget,
)
from set_devices.signal_reconstruction import METHODS
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
class _ResultSignals(QObject):
done = Signal(object, object, str)
class _Calculation(QRunnable):
def __init__(self, signature):
super().__init__()
self.signature = signature
self.signals = _ResultSignals()
def run(self):
try:
result = process(self.signature)
except Exception as error:
self.signals.done.emit(self.signature, None, str(error))
else:
self.signals.done.emit(self.signature, result, "")
class SignalProcessingPanel(QWidget):
changed = Signal()
RESULT_KEY = "__calculated_trend__"
def __init__(self, parent=None):
super().__init__(parent)
self._snapshot = Snapshot()
self._result = None
self._result_signature = None
self._job = None
self._revision = 0
self._job_revision = 0
self.preserve_on_view_change = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
row.addWidget(QLabel("Обработка"))
self.channel = QComboBox()
self.channel.setMinimumWidth(140)
row.addWidget(self.channel)
self.method = QComboBox()
for key, title in METHODS.items():
self.method.addItem(title, key)
self.method.setCurrentIndex(2)
row.addWidget(self.method)
row.addStretch(1)
layout.addLayout(row)
row = QHBoxLayout()
row.addWidget(QLabel("Степень"))
self.degree = QSpinBox()
self.degree.setRange(1, 5)
self.degree.setValue(2)
row.addWidget(self.degree)
row.addWidget(QLabel("Точек результата"))
self.count = QSpinBox()
self.count.setRange(2, 10000)
self.count.setValue(1000)
row.addWidget(self.count)
self.apply_button = QPushButton("Рассчитать")
self.apply_button.clicked.connect(self.calculate)
row.addWidget(self.apply_button)
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)
row.addWidget(self.export_button)
row.addStretch(1)
layout.addLayout(row)
self.status = QLabel("Выберите аналоговый канал и метод. Расчёт по переданным точкам, без экстраполяции.")
self.status.setWordWrap(True)
layout.addWidget(self.status)
self.method.setToolTip(
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
for widget in (self.channel, self.method):
widget.currentIndexChanged.connect(self.clear)
for widget in (self.degree, self.count):
widget.valueChanged.connect(self.clear)
self._update_actions()
def _signature(self):
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
self.count.value(), self.degree.value())
def _current_request_for(self, signature):
if not self.preserve_on_view_change or signature is None:
return self._signature()
# Validate the original calculation domain, independently of navigation.
return prepare(replace(self._snapshot, x_range=signature.x_range),
self.channel.currentData(), self.method.currentData(),
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()
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())]
if choices != current:
selected = self.channel.currentData()
blocker = QSignalBlocker(self.channel)
self.channel.clear()
for key, title in choices:
self.channel.addItem(title, key)
index = self.channel.findData(selected)
if index >= 0:
self.channel.setCurrentIndex(index)
del blocker
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("Данные или окно изменились — нажмите «Рассчитать» повторно.")
if snapshot.blocked_reason:
self.status.setText(snapshot.blocked_reason)
elif not choices:
self.status.setText("Нет видимых аналоговых каналов для обработки.")
elif was_blocked:
self.status.setText("Выберите канал и метод, затем нажмите «Рассчитать».")
self._update_actions()
def _update_actions(self):
self.degree.setEnabled(self.method.currentData() == "polynomial")
request = self._signature()
self.apply_button.setEnabled(self._job is None and request is not None and
len(request.series.points) >= 2)
self.export_button.setEnabled(self._result is not None and not self._snapshot.blocked_reason)
self.clear_button.setEnabled(self._result is not None or self._job is not None)
self.apply_button.setToolTip("Обработка выбранной кривой в её единицах X/Y. Для живого потока остановите сбор.")
def clear(self, *_):
self._revision += 1
self._result = self._result_signature = None
self.status.setText("Расчёт по текущему окну. Исходные измерения сохраняются; кривая — оценка между точками.")
self._update_actions()
self.changed.emit()
def calculate(self):
if self._job is not None or self._snapshot.blocked_reason:
return
signature = self._signature()
if signature is None or len(signature.series.points) < 2:
self.status.setText("Нужны минимум две точки аналогового канала.")
return
self._job = _Calculation(signature)
self._job_revision = self._revision
self._job.signals.done.connect(self._finished)
self.status.setText("Расчёт…")
self._update_actions()
QThreadPool.globalInstance().start(self._job)
@Slot(object, object, str)
def _finished(self, signature, result, error):
self._job = None
if signature != self._current_request_for(signature) or self._job_revision != self._revision:
self.status.setText("Данные изменились во время расчёта. Остановите сбор и повторите расчёт.")
elif error:
self.status.setText("Расчёт не выполнен: " + error)
else:
self._result, self._result_signature = result, signature
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 "")
+ "Розовая линия — расчётная оценка; исходные данные сохранены.")
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}
@property
def curve(self):
return self._result
def export_csv(self):
if self._result is None:
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить расчётную кривую", "calculated-signal.csv", "CSV (*.csv)")
if not path:
return
try:
with open(path, "w", newline="", encoding="utf-8-sig") as stream:
write_csv(self._result, stream)
except (OSError, ValueError, OverflowError) as error:
self.status.setText("Не удалось сохранить CSV: " + str(error))
class PlotProcessingAttachment(QObject):
"""Attach to any Qt plot via snapshot/project/repaint callbacks.
Call source_changed when raw data, units or visible channel selection change.
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):
super().__init__(parent)
self.snapshot = snapshot
self.repaint = repaint
self.dialog = None
self.panel = None
self._external_curve = None
self._external_offset = 0.0
self.button = QPushButton("Обработка…", parent)
self.button.setToolTip("Аппроксимация, интерполяция и восстановление аналоговой кривой")
self.button.clicked.connect(self.open)
self._refresh_timer = QTimer(self)
self._refresh_timer.setSingleShot(True)
self._refresh_timer.timeout.connect(self.refresh)
self._dirty = False
@property
def curve(self):
# Hide stale geometry immediately, before coalesced refresh runs.
if self._external_curve is not None:
return self._external_curve
return self.panel.curve if self.panel is not None and not self._dirty else None
def set_external_curve(self, curve, x_offset=0.0):
"""Embedded panels own snapshot invalidation; display rebasing is explicit."""
self._external_curve = curve
self._external_offset = x_offset
self.repaint()
def source_changed(self):
if self.panel is not None:
self._dirty = True
self._refresh_timer.start(0)
def refresh(self):
if self.panel is not None:
self.panel.set_snapshot(self.snapshot())
self._dirty = False
self.repaint()
def open(self):
if self.dialog is None:
self.dialog = QDialog(self.parent())
self.dialog.setWindowTitle("Обработка графика")
layout = QVBoxLayout(self.dialog)
self.panel = SignalProcessingPanel(self.dialog)
self.panel.changed.connect(self.repaint)
layout.addWidget(self.panel)
self.dialog.resize(760, 240)
self.refresh()
self.dialog.show()
self.dialog.raise_()
self.dialog.activateWindow()
def paint(self, painter, rect, project):
curve = self.curve
if curve is None:
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)
painter.restore()