Add shared waveform processing, generation and firmware image support

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent 2ad29e7ffd
commit cc22c803d1
36 changed files with 2591 additions and 61 deletions

View File

@@ -0,0 +1,293 @@
"""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()