Files
templates/python/set_devices/qt_ports/reference_overlay.py
T
Andrey 41e24629ce Расширить DEIF, загрузку CAN и общую обработку сигналов
Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt.
Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor.
Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов.
Обновить справочники устройств, проекты CCS и регрессионные проверки.
2026-10-24 15:40:51 +03:00

300 lines
45 KiB
Python

"""Компактные параметры эталона для наложения на существующий график.""" # Порт не создаёт canvas и не меняет компоновку исходного графика.
import json # Показатели можно сохранить независимо от используемого рендерера.
from pathlib import Path # Файл эталона и отчёт используют пути, предоставленные потребителем.
import re # Явно разные единицы каналов нельзя накладывать на общую шкалу.
try: # Поддерживаем обе настольные версии Qt.
from PySide6.QtCore import QSignalBlocker, QTimer, Signal # Таймер объединяет последовательные изменения сдвига в один расчёт.
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget # Панель содержит только параметры, без второго графика.
except ImportError: # Qt5 работает непосредственно без слоя совместимости приложения.
from PySide2.QtCore import QSignalBlocker, QTimer, Signal # Контракт сигналов и отложенного пересчёта совпадает с Qt6.
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget # Используем тот же компактный набор элементов.
from set_devices.signal_reference import Trace, compare, read_reference, trace_from_pairs # Чтение, преобразование амплитуд и показатели остаются общими.
from set_devices.qt_ports.reference_comparison import ComparisonWorker, _model_channels, _stop_owned_worker # Рабочий поток и подготовка физических каналов имеют общую реализацию.
from set_devices.qt_ports.circuit_parameters import CircuitParameters # Известные параметры последовательного эквивалента задаются независимо от выбранного графика.
from set_devices.qt_ports.reference_results import ComparisonAnalysis, ComparisonResults # Общая строка анализа и таблица используют один результат сравнения.
class TracePoints: # Представление кривой парами не копирует полные массивы в список кортежей.
def __init__(self, trace): # Временная шкала уже совмещена с исходной записью потребителя.
self.timestamps, self.values = trace.times, trace.values # Сохраняем прямые ссылки на подготовленные столбцы.
def __len__(self): # Рендереру и легенде нужно знать количество исходных точек.
return len(self.timestamps) # Оба столбца имеют одинаковую длину.
def __iter__(self): # Обычный график может последовательно читать пары отсчётов.
return iter(zip(self.timestamps, self.values)) # Пары создаются только по мере обхода.
def __getitem__(self, index): # В частности, легенда читает последнюю амплитуду по индексу -1.
return self.timestamps[index], self.values[index] # Возвращаем время и значение одной и той же точки.
class ReferenceOverlayPanel(QWidget): # Этот компонент добавляет только параметры к уже существующему графику.
changed = Signal() # Владелец добавляет или обновляет одну эталонную кривую в своём canvas.
remove_requested = Signal() # Удаление эталона не затрагивает исходную запись и её график.
results_requested = Signal() # Владелец показывает подробные показатели в прежней боковой области.
RESULT_KEY = '__reference_overlay__' # Стабильный ключ отделяет эталон от измерений и вычисленных интерполяций.
DIFFERENCE_KEY = '__reference_overlay__difference' # Остаток имеет отдельный ключ, поэтому его можно показывать вместе с оригиналом и эталоном.
def __init__(self, sources, parent=None, source_path='', path_provider=None): # Источники содержат имя, преобразование амплитуды и полную историю.
super().__init__(parent) # Панель принадлежит той же странице, что исходный график.
self.sources, self.source_path = sources, source_path # Сохраняем происхождение измерения для расчёта и отчёта.
self._path = path_provider or (lambda name: name) # Правила папок остаются политикой приложения.
self.references, self.reference_path = {}, '' # Эталон появляется после успешного чтения CSV.
self.trace, self.comparison, self.difference = None, None, None # Полные кривые эталона и остатка отделены от исходного измерения владельца.
self._source_cache = {} # Множитель измерения применяется один раз к неизменяемой истории.
self._worker, self._worker_state = None, [None] # Независимый держатель позволяет остановить поток при уничтожении родителя.
self._closing, self._pending = False, False # Не применяем поздние результаты и объединяем изменения параметров.
self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # QObject останавливает поток до удаления C++-детей.
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) # Панель занимает только высоту своих строк и не отнимает свободное место у графика.
root = QVBoxLayout(self) # Две основные строки располагаются непосредственно над исходным canvas.
root.setContentsMargins(0, 0, 0, 0) # Используем уже имеющиеся отступы страницы графика.
row = QHBoxLayout() # Первая строка содержит файл, каналы и совмещение времени.
self.load_button = QPushButton('Эталон CSV…') # Повторный выбор файла меняет только эталонную кривую.
self.load_button.clicked.connect(self._open_reference) # Запускаем штатный выбор файла.
row.addWidget(self.load_button) # Кнопка находится рядом с каналами сравнения.
self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Измерение и столбец эталона выбираются независимо.
self.source_combo.addItems([source.name for source, _ in sources]) # Имена соответствуют исходным каналам графика.
for title, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Компактные подписи поясняют обе стороны наложения.
row.addWidget(QLabel(title)) # Подпись располагается непосредственно перед списком.
control.setMinimumWidth(110) # Минимум 110 логических пикселей оставляет читаемое имя канала.
row.addWidget(control) # Списки не растягиваются на ширину всего окна.
self.align_start = QCheckBox('Совместить начала') # Опция сдвигает эталон к первому отсчёту выбранного измерения.
self.align_start.setChecked(True) # По умолчанию накладываем форму от начала каждой записи.
row.addWidget(self.align_start) # Совмещение времени доступно без дополнительного диалога.
row.addWidget(QLabel('Сдвиг')) # Единица задаётся суффиксом следующего поля.
self.shift = self._number(-1e12, 1e12, ' мкс') # ±10^12 мкс сохраняет прежний диапазон временного сдвига.
row.addWidget(self.shift) # Положительный сдвиг перемещает эталон вправо.
self.details_button = QPushButton('Допуски и отчёт') # Второстепенные настройки не занимают высоту графика постоянно.
self.details_button.setCheckable(True) # Повторное нажатие сворачивает ту же строку параметров.
row.addWidget(self.details_button) # Детали находятся рядом с основными действиями.
self.results_button = QPushButton('Расчёты') # Полная таблица доступна явно, независимо от краткой строки RMSE.
self.results_button.clicked.connect(self.results_requested.emit) # Потребитель выбирает вкладку результатов рядом с исходным canvas.
row.addWidget(self.results_button) # Не прячем подробные расчёты внутри настроек допуска.
self.remove_button = QPushButton('Убрать эталон') # Удаление относится только к дополнительной кривой.
self.remove_button.clicked.connect(self.remove_requested.emit) # Владелец удаляет панель и обновляет существующий график.
row.addWidget(self.remove_button) # Исходная запись остаётся на месте после нажатия.
row.addStretch(1) # Свободная ширина остаётся справа, не раздвигая параметры.
root.addLayout(row) # Основная строка не содержит второго canvas или отдельной таблицы.
self.details = QWidget(self) # Дополнительная строка скрыта до явного запроса пользователя.
row = QHBoxLayout(self.details) # Допуски и экспорт располагаются в одной компактной строке.
row.setContentsMargins(0, 0, 0, 0) # Не создаём лишние поля вокруг скрываемой строки.
self.absolute, self.relative = self._number(0., 1e12), self._number(0., 10000., ' %') # Допуск состоит из абсолютной части и процента модуля эталона.
for title, control in (('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Формула подписей соответствует общему численному ядру.
row.addWidget(QLabel(title)) # Подпись определяет смысл следующего поля.
row.addWidget(control) # Оба допуска неотрицательны.
self.report_button = QPushButton('Отчёт JSON…') # Отчёт доступен после получения показателей.
self.report_button.clicked.connect(self._save_report) # Сохраняем показатели без отдельного рабочего окна.
self.report_button.setEnabled(False) # До сравнения экспортировать нечего.
row.addWidget(self.report_button) # Экспорт относится к той же паре каналов и допускам.
row.addStretch(1) # Дополнительная строка также остаётся компактной.
root.addWidget(self.details) # Детали добавляются без изменения положения исходного графика в splitter.
self.details.hide() # По умолчанию графику доступны все свободные пиксели.
self.details_button.toggled.connect(self.details.setVisible) # Пользователь сам раскрывает подробные параметры.
self.status = QLabel('Выберите эталон: он будет наложен на исходный график.') # Измерение продолжает отображаться уже до чтения CSV.
self.status.setWordWrap(True) # Длинное имя или ошибка файла не расширяет окно.
root.addWidget(self.status) # Краткие показатели остаются над canvas, полный отчёт размещается в боковой области.
self.circuit = CircuitParameters(self) # Поля доступны вместе с подробными параметрами сравнения.
self.circuit.changed.connect(self._parameters_changed) # Новый известный элемент автоматически обновляет физический эквивалент и JSON.
root.addWidget(self.circuit) # Отдельная строка предотвращает чрезмерную минимальную ширину основных контролов.
self.circuit.hide() # При обычном просмотре необязательные элементы не отнимают высоту графика.
self.details_button.toggled.connect(self.circuit.setVisible) # Кнопка «Допуски и отчёт» раскрывает также настройки математических моделей.
self.analysis = ComparisonAnalysis(self) # Строка возможной неисправности видна непосредственно над исходным графиком.
root.addWidget(self.analysis) # Android-подобный анализ не требует перехода на другую вкладку.
self.results = ComparisonResults(self) # Таблица принадлежит тому же результату, но может быть размещена в боковой области владельца.
self.results.hide() # До встраивания не показываем дочерний виджет поверх компактных параметров.
self.results.export_requested.connect(self._save_report) # Боковая таблица и кнопка над графиком сохраняют один и тот же JSON.
self.results.delta_curve.toggled.connect(lambda _checked: self.changed.emit()) # Отбрасываем булево значение флажка: сигнал обновления слоя не принимает аргументов.
self._timer = QTimer(self) # Изменения числовых полей объединяются перед фоновым расчётом.
self._timer.setSingleShot(True) # Одно завершённое редактирование запускает одно задание.
self._timer.setInterval(120) # Пауза 120 мс не запускает проход по всей записи на каждый шаг ввода.
self._timer.timeout.connect(self._calculate) # Готовые параметры применяются автоматически.
self.source_combo.currentIndexChanged.connect(self._match_reference) # Одноимённый эталон выбирается при смене измерения.
self.reference_combo.currentIndexChanged.connect(self._parameters_changed) # Смена канала обновляет наложение автоматически.
self.align_start.toggled.connect(self._parameters_changed) # Совмещение начал влияет на временные метки эталона.
for control in (self.shift, self.absolute, self.relative): # Изменение сдвига и допусков не требует отдельного нажатия «Сравнить».
control.valueChanged.connect(self._parameters_changed) # Исходная кривая остаётся видимой во время пересчёта.
QApplication.instance().aboutToQuit.connect(self._shutdown) # Выход приложения отменяет фоновые операции до удаления Qt-объектов.
@staticmethod # Фабрика не зависит от состояния панели.
def _number(low, high, suffix=''): # Границы и единицы задаются для конкретного параметра.
box = QDoubleSpinBox() # Используем штатную проверку числового ввода Qt.
box.setDecimals(6) # Шесть десятичных знаков сохраняют точность прежних параметров сравнения.
box.setRange(low, high) # Запрещаем значения вне диапазона параметра.
box.setSuffix(suffix) # Время вводится в мкс, относительный допуск — в процентах.
box.setMaximumWidth(145) # Не растягиваем поле на ширину рабочей области.
return box # Обработчик изменений подключается после создания всех элементов.
def _parameters_changed(self, *_): # Параметры могут меняться сигналом Qt или программно.
self.comparison = None # Старые показатели больше не соответствуют текущему вводу.
self.analysis.set_result(None) # Смена канала, файла, сдвига или допуска сразу удаляет прежнюю гипотезу.
self.difference = None # Кривая старого остатка не относится к изменённым параметрам.
self.results.set_result(None, 'Параметры изменены. Выполняется пересчёт…') # Полная таблица сразу очищает устаревшие числа.
self.report_button.setEnabled(False) # Не разрешаем экспорт устаревшего отчёта.
if self.references and not self._closing: # До загрузки эталона пересчитывать нечего.
self.status.setText('Обновление эталона…') # Прежние показатели не должны выглядеть актуальными после изменения источника или сдвига.
self._pending = True # Изменение не теряется, даже если worker ещё читает файл.
self._timer.start() # Объединяем последовательные изменения одного поля.
def _match_reference(self, *_): # Имя измерения помогает автоматически выбрать подходящий столбец CSV.
index = self.reference_combo.findText(self.source_combo.currentText()) # Сопоставляем полное имя, включая единицы.
if index >= 0: # При отсутствии совпадения сохраняем явно выбранный канал эталона.
self.reference_combo.setCurrentIndex(index) # Одноимённые каналы не требуют второго ручного выбора.
self._parameters_changed() # Новое измерение требует новых показателей и временного совмещения.
def set_sources(self, sources): # Прибор может обновить исходный кадр, сохранив тот же файл эталона.
if len(sources) == len(self.sources) and all(old_signal == signal and old_points is points for (old_signal, old_points), (signal, points) in zip(self.sources, sources)): # Настройки и идентичность историй однозначно определяют прежний снимок.
return # Навигация по тому же графику не требует повторной подготовки измерения.
selected = self.source_combo.currentText() # Сохраняем выбранное пользователем имя при замене истории.
self.sources, self._source_cache = sources, {} # Новому снимку соответствует отдельный кэш преобразованных амплитуд.
blocker = QSignalBlocker(self.source_combo) # Промежуточный пустой список не запускает расчёт с индексом -1.
self.source_combo.clear() # Удаляем состав каналов старого кадра.
self.source_combo.addItems([signal.name for signal, _ in sources]) # Новые имена берутся из фактической записи владельца.
self.source_combo.setCurrentIndex(max(0, self.source_combo.findText(selected))) # При наличии прежнего имени продолжаем сравнивать тот же канал.
del blocker # Возвращаем обработку пользовательского выбора после целостного обновления.
self._parameters_changed() # Эталон сохраняется и пересчитывается относительно нового оригинала.
def _start_work(self, operation, receive): # Загрузка и расчёт используют один кооперативно отменяемый поток.
worker = ComparisonWorker(operation, self) # Владелец сохраняет worker до штатного завершения.
self._worker = self._worker_state[0] = worker # Независимый держатель обеспечивает безопасное уничтожение родителя.
for control in (self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, *self.circuit.controls): # Полный снимок включает также известные элементы контура.
control.setEnabled(False) # Исключаем параллельную загрузку и изменение активного ввода.
worker.result.connect(receive) # Результат применяется в главном потоке Qt.
worker.failed.connect(self._failed) # Ошибка отображается и в кратком статусе, и в подробной таблице.
worker.finished.connect(self._finished) # Завершение запускает отложенный пересчёт при необходимости.
worker.start() # Файловые операции и полная статистика не выполняются в обработчике кнопки.
def _finished(self): # Обрабатываем также завершение после запроса отмены.
worker, self._worker = self._worker, None # Поток больше не считается активным.
self._worker_state[0] = None # Обработчик destroyed не обращается к удаляемому QThread повторно.
if worker is None or self._closing: # Закрытие могло синхронно очистить задачу до доставки сигнала.
return # Не обновляем элементы уничтожаемой панели.
worker.deleteLater() # Завершённый поток удаляется очередью событий.
for control in (self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, *self.circuit.controls): # После подбора снова доступны каналы, допуски и электрические ограничения.
control.setEnabled(True) # Исходный график всё время оставался доступным.
self.report_button.setEnabled(self.comparison is not None) # Экспорт относится только к актуальной статистике.
if self._pending: # Успешное чтение и изменения параметров автоматически обновляют эталон.
self._calculate() # Отдельная кнопка запуска сравнения не требуется.
def _failed(self, message): # Недоступный файл или ошибка вычислений не должны оставлять прежние показатели.
if self._closing: # Поздний сигнал не обновляет закрытую панель.
return # Ресурсы исходного графика не принадлежат этому worker.
self.comparison, self.difference = None, None # Удаляем только устаревший численный результат и его остаток.
self.status.setText(message) # Причина остаётся видимой над исходным графиком.
self.results.set_result(None, message) # Та же причина поясняет отсутствие строк в подробной таблице.
self.analysis.set_result(None) # Ошибка чтения или расчёта удаляет также прежнюю гипотезу над графиком.
self.report_button.setEnabled(False) # Ошибку нельзя экспортировать как успешный отчёт.
self.changed.emit() # Снимаем прежнюю кривую остатка, сохраняя оригинал и последний загруженный эталон.
def _open_reference(self): # Потребитель может вызвать выбор файла из своей кнопки рядом с графиком.
path, _ = QFileDialog.getOpenFileName(self, 'Наложить эталон CSV', self._path(''), 'CSV (*.csv)') # Начальная папка задаётся приложением.
if path: # Отмена выбора не удаляет прежний эталон и измерение.
self.load_reference(path) # После чтения кривая добавляется автоматически.
def load_reference(self, path): # Программный вход также сохраняет исходный график на время загрузки.
if self._worker is not None or self._closing: # Не заменяем ссылку на ещё работающий QThread.
return # Новое чтение доступно после завершения текущего.
self.status.setText('Загрузка эталона…') # Строка состояния показывает текущую фоновую операцию.
self.comparison = None # Предыдущий отчёт не относится к выбираемому файлу.
self.difference = None # Остаток предыдущего эталона не должен выглядеть актуальным.
self.results.set_result(None, 'Загрузка эталона…') # Подробная таблица объясняет временное отсутствие показателей.
self.analysis.set_result(None) # Во время чтения другого файла анализ старого эталона не показывается.
self.report_button.setEnabled(False) # Блокируем экспорт до нового расчёта.
def receive(traces): # Qt доставляет полностью прочитанный CSV в интерфейс.
if self._closing: # Уничтожение панели могло опередить доставку результата.
return # Не меняем состояние после закрытия.
self.references, self.reference_path = traces, str(path) # Принимаем файл вместе с его каналами.
blocker = QSignalBlocker(self.reference_combo) # Наполнение списка не запускает промежуточный расчёт.
self.reference_combo.clear() # Удаляем имена предыдущего эталона.
self.reference_combo.addItems(list(traces)) # Сохраняем порядок каналов CSV.
index = self.reference_combo.findText(self.source_combo.currentText()) # Предпочитаем одноимённый канал.
self.reference_combo.setCurrentIndex(max(0, index)) # При отсутствии совпадения предлагаем первый столбец.
del blocker # Восстанавливаем обработку ручного выбора после целостного обновления списка.
self._pending = True # После завершения чтения автоматически подготавливаем кривую и показатели.
self._start_work(lambda cancel: read_reference(path, cancel), receive) # Чтение поддерживает отмену на больших файлах.
def _calculate(self): # Формируем эталон в той же временной шкале, что исходные данные графика.
if self._worker is not None or self._closing or not self.references or not self.sources: # Расчёт ждёт окончания чтения и полного набора входа.
return # Флаг pending сохраняет необходимость следующего задания.
self._timer.stop() # Запланированный таймер не должен повторить уже начатый расчёт.
self._pending = False # Текущее задание соответствует последнему снимку параметров.
source_index = self.source_combo.currentIndex() # Индекс связывает выбор с полной исходной историей.
signal, points = self.sources[source_index] # Преобразование амплитуды предоставляется потребителем.
reference = self.references[self.reference_combo.currentText()] # Выбранный столбец не копируется повторно.
units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Сверяем явные единицы общей амплитудной оси.
if all(units) and units[0].group(1) != units[1].group(1): # Разные единицы нельзя представить как количественное наложение.
self.trace = None # Предыдущий эталон не должен выглядеть результатом нового несовместимого выбора.
self.results.set_result(None, 'Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Полная таблица не сохраняет показатели другой пары каналов.
self.analysis.set_result(None) # Разные единицы не должны сохранять вывод предыдущего корректного сравнения.
self.changed.emit() # Убираем только дополнительную кривую, сохраняя оригинальное измерение.
self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Измерение при этом продолжает отображаться.
return # Не добавляем кривую с несовместимыми амплитудами.
options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value()) # Переводим микросекунды UI в миллисекунды ядра.
options['circuit'] = self.circuit.values() # Рабочий поток получает значения элементов в СИ, не обращаясь к виджетам.
cache = self._source_cache # Активный worker не должен записать старый снимок в кэш уже обновлённого кадра.
sources, references = self.sources, self.references # Смена кадра не должна смешивать новые связанные каналы со старым измерением.
self.status.setText('Подготовка наложения эталона…') # Исходная кривая не очищается во время обработки.
def operation(cancel): # Подготовка полной записи выполняется в рабочем потоке.
measured = cache.get(source_index) # Повторный сдвиг не требует повторного применения множителя.
if measured is None: # Первый расчёт готовит неизменяемый снимок амплитуд.
measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Используем все исходные точки с теми же единицами, что оригинал.
cache[source_index] = measured # Кэш принадлежит конкретной истории, переданной при создании панели.
origin = reference.times[0] if options['align_start'] else measured.times[0] # Совмещение начал не обнуляет координаты исходного графика.
trace = Trace(reference.name, [stamp - origin + options['shift_ms'] + measured.times[0] for stamp in reference.times], reference.values) # Добавляем эталон непосредственно в исходной временной шкале измерения.
try: # Отсутствие перекрытия мешает статистике, но не показу двух исходных кривых.
channels = _model_channels(sources, references, cache, cancel) # Подготавливаем совместный снимок Uce/Uge/Ic без дополнительных экранных графиков.
result = compare(measured, reference, cancel=cancel, channels=channels, **options) # Полная статистика и электрические модели используют одно совмещение, допуски и параметры RLC.
difference = Trace(result.difference.name, [stamp + measured.times[0] for stamp in result.difference.times], result.difference.values) # Подготавливаем остаток в той же исходной временной шкале без большого прохода в UI.
return trace, result, '', difference # Эталон, остаток и показатели относятся к одному снимку параметров.
except ValueError as error: # Например, эталон сдвинут за пределы времени измерения.
return trace, None, str(error), None # Показываем эталон и честно сообщаем отсутствие статистики и остатка.
self._start_work(operation, self._received) # Готовая кривая попадёт в существующий canvas через сигнал changed.
def _received(self, payload): # Получатель не создаёт новый график и не меняет исходные серии.
if self._closing or self._pending: # Поздний результат уже не соответствует состоянию панели.
return # Следующее задание применит актуальные параметры.
self.trace, self.comparison, error, self.difference = payload # Дополнительные кривые не заменяют исходное измерение владельца.
self.results.set_result(self.comparison, error) # Подробная таблица автоматически показывает все показатели завершённого расчёта.
self.analysis.set_result(self.comparison) # Краткий анализ и подробности относятся к той же паре кривых, что таблица.
if self.comparison is None: # Кривую можно показать даже при отсутствии пересечения по времени.
self.status.setText(f'Эталон: {Path(self.reference_path).name}. {error}') # Ошибка статистики не скрывает ни оригинал, ни эталон.
else: # Краткая строка дополняет полный набор показателей в боковой области.
m = self.comparison.metrics # Все значения относятся к полным исходным отсчётам.
self.status.setText(f'Эталон: {Path(self.reference_path).name} · RMSE {m["rmse"]:.7g} · max |Δ| {m["max_abs"]:.7g} · за допуском {m["outside_tolerance"]}/{m["count"]}') # Единицы ошибки совпадают с выбранным измерением.
self.changed.emit() # Владелец добавляет эталон и при выбранном флажке кривую остатка к исходным рядам.
def overlay(self): # Потребитель объединяет результат с собственными кривыми и легендой.
if self.trace is None: # До загрузки эталона исходный график не меняется.
return [], {} # Пустой дополнительный слой не означает очистку измерений.
label = f'Эталон: {self.trace.name}' # Подпись отличает эталон от одноимённого оригинального канала.
specs, data = [(self.RESULT_KEY, label, False, '#ffae42', True)], {self.RESULT_KEY: TracePoints(self.trace)} # Эталон сохраняет оранжевый цвет на общих осях исходного canvas.
if self.results.delta_curve.isChecked() and self.difference is not None: # Остаток показывается только по явному выбору пользователя и для актуального результата.
specs.append((self.DIFFERENCE_KEY, 'Δ: измерение − эталон', False, '#ff7b7b', True)) # Красноватая подпись отличает разность от двух исходных кривых.
data[self.DIFFERENCE_KEY] = TracePoints(self.difference) # Значения остатка не пересчитываются и используют исходную временную шкалу графика.
return specs, data # Потребитель отображает все выбранные дополнительные кривые вместе с оригиналом.
def report(self): # Программный экспорт использует тот же формат, что отдельный диалог библиотеки.
if self.comparison is None: # Отсутствующая или устаревшая статистика не экспортируется.
return None # Явно обозначаем отсутствие актуального отчёта.
return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, reference_file=self.reference_path, reference_channel=self.comparison.reference.name, time_unit='ms from first measured sample', interpolation='linear; no extrapolation; gaps excluded', tolerance='absolute + relative_pct / 100 * abs(reference)', metrics=self.comparison.metrics, analysis=self.comparison.analysis) # JSON сохраняет численные показатели, наблюдаемые признаки и возможные причины неисправности.
def _save_report(self): # Экспорт не открывает отдельный рабочий график.
if self.comparison is None: # Защищаемся от программного вызова до получения статистики.
return # Не записываем пустой отчёт.
path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._path('reference_comparison.json'), 'JSON (*.json)') # Начальный каталог предоставляет приложение.
if path: # Отмена выбора не влияет на показ кривых.
try: # Ошибка доступа к файлу не должна удалять готовое наложение.
Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 сохраняет русские имена каналов в читаемом виде.
except OSError as error: # Пользователь может выбрать другое место для отчёта.
self.status.setText(str(error)) # Объясняем причину неуспешного сохранения.
def _shutdown(self): # Перед удалением панели прекращаем обращение worker к её источникам.
self._closing = True # Поздние queued-сигналы больше не применяются.
self._timer.stop() # Отложенный пересчёт не запускается после закрытия.
if self._worker is not None: # Синхронное ожидание необходимо только при активной задаче.
worker = self._worker # Сохраняем поток до очистки держателя.
worker.requestInterruption() # Чтение и сравнение проверяют штатный запрос отмены.
worker.wait() # Родитель может безопасно удалить панель сразу после close.
self._worker = self._worker_state[0] = None # Повторное уничтожение не обращается к старому QThread.
worker.deleteLater() # Завершённый поток освобождается очередью Qt.
def closeEvent(self, event): # Закрытие удаляет только параметры и слой эталона у владельца.
self._shutdown() # Исходный canvas не принадлежит панели и не закрывается здесь.
super().closeEvent(event) # QWidget завершает собственный жизненный цикл.