"""Компактные параметры эталона для наложения на существующий график.""" # Порт не создаёт 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 завершает собственный жизненный цикл.