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

376 lines
52 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.
"""Переиспользуемые Qt-панель и диалог сравнения с CSV-эталоном.""" # График, контейнер кривой и папки передаются потребителем.
import json # Отчёт сохраняет показатели и параметры сравнения в переносимом формате.
from pathlib import Path # Имя файла используется в статусе, полный путь — при записи отчёта.
import re # Единицы в скобках проверяются до сравнения каналов.
try: # Один порт поддерживает современные и совместимые Windows-сборки.
from PySide6.QtCore import QThread, Qt, Signal # Qt6 доставляет результаты рабочего потока в интерфейс.
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Используем стандартные элементы Qt6; создание ячеек с единицами и подсказками выполняет общий заполнитель таблицы.
except ImportError: # Qt5 доступен на системах, где Qt6 не поддерживается.
from PySide2.QtCore import QThread, Qt, Signal # Сохраняем тот же контракт сигналов и отмены на Qt5.
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Qt5 использует тот же заполнитель ячеек с единицами и подсказками, что современный порт.
from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Единственное вычислительное ядро работает по всем исходным отсчётам.
from set_devices.qt_ports.circuit_parameters import CircuitParameters # Необязательные известные R/L/C ограничивают идентификацию электрического эквивалента.
from protocan.plot import Viewport # Общий контейнер области просмотра не зависит от конкретного виджета графика.
from set_devices.qt_ports.reference_results import ComparisonAnalysis, populate_comparison_table # Анализ, единицы показателей и подсказки одинаковы в отдельном и встроенном виде.
def _model_channels(sources, references, cache, cancel): # Подготовка связанных физических каналов выполняется только в рабочем потоке.
measured = {} # Сохраняем исходные временные метки для совместной проверки Uce/Uge/Ic.
for index, (signal, points) in enumerate(sources): # Истории и преобразования соответствуют одному снимку измерительной записи.
if re.match(r'^\s*(uce|uge|ic)\b', signal.name, re.IGNORECASE): # Не копируем произвольные дополнительные каналы, не участвующие в электрической модели.
if not points: # Отсутствующий связанный канал не должен ломать корректное сравнение выбранного измерения.
continue # Совместный анализ сообщит недостаточный набор физических наблюдений.
trace = cache.get(index) # Выбранный канал уже подготовлен основным сравнением.
if trace is None: # Остальные физические каналы преобразуются только при первом использовании.
trace = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Множители применяются в точности как на исходном графике.
cache[index] = trace # Следующая настройка RLC не требует повторной копии полной истории.
measured[str(index)] = trace # Индекс сохраняет неоднозначные одинаковые имена для проверки, не затирая их словарём.
return dict(measured=measured, reference=references) # Сдвиг и общие начала координат добавляет независимое вычислительное ядро.
def _stop_owned_worker(state): # Уничтожение родителя QWidget может обойти closeEvent вложенной панели.
worker = state[0] # Состояние не обращается к уже уничтожаемой Python-обёртке виджета.
if worker is not None: # Завершённые задания удаляются из держателя заранее.
worker.requestInterruption() # Операция увидит отмену через штатный callback QThread.
worker.wait() # QObject удаляет дочерний QThread только после выхода его run.
class ComparisonWorker(QThread): # Чтение CSV и численный проход выполняются вне главного потока Qt.
result = Signal(object) # Готовый результат передаётся интерфейсу без изменения исходных данных.
failed = Signal(str) # Ошибка передаётся текстом и не оставляет частичный отчёт.
def __init__(self, operation, parent): # Операция принимает callback проверки отмены.
super().__init__(parent) # Диалог владеет временем жизни рабочего потока.
self.operation = operation # Одинаковый worker обслуживает загрузку и расчёт.
def run(self): # Qt вызывает этот метод в отдельном потоке.
try: # Любая ошибка должна вернуться в UI, а не прервать главный цикл событий.
self.result.emit(self.operation(self.isInterruptionRequested)) # Передаём операции штатный запрос отмены QThread.
except (ValueError, OSError, InterruptedError) as error: # Ошибки данных, файлов и отмена уже содержат понятную причину.
self.failed.emit(str(error)) # Показываем исходную причину без дополнительной обёртки.
except Exception as error: # Неожиданная ошибка также должна завершить состояние занятости.
self.failed.emit(f'Ошибка сравнения: {error}') # Уточняем источник неизвестной ошибки для пользователя.
class _ReferenceComparisonView: # Панель и отдельный диалог используют одну реализацию параметров, worker и результата.
def __init__(self, sources, parent=None, source_path='', *, plot_factory, series_factory, path_provider=None): # sources содержит пары объекта с name/display_value и неизменяемой полной истории.
super().__init__(parent) # Родитель определяет окно и владение диалогом.
self._series_factory = series_factory # Фабрика адаптирует общие Trace к кривым графика потребителя.
self._dialog_path = path_provider or (lambda filename: filename) # Без callback используем стандартные относительные имена файлов.
self.setWindowTitle('Сравнение с эталоном') # Заголовок одинаков для всех приложений, использующих порт.
self.sources = sources # История фиксируется вызывающей стороной на время жизни диалога.
self.source_path = source_path # Происхождение измерения включается в JSON-отчёт.
self.references = {} # Каналы эталона появятся после успешного чтения CSV.
self.reference_path = '' # Путь изменяется только после принятия загруженного эталона.
self._source_cache = {} # Повторный расчёт не должен повторно применять множители к измерению.
self._worker = None # Одновременно допускается только одна загрузка или вычисление.
self._worker_state = [None] # Независимый держатель доступен обработчику destroyed без обращения к self.
self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # Останавливаем поток до удаления C++-детей даже при deleteLater родителя.
self._closing = False # Флаг откладывает закрытие до завершения отменённого worker.
self.comparison = None # До расчёта отчёт и график результата отсутствуют.
root = QVBoxLayout(self) # Вертикальный макет объединяет параметры, результат и экспорт.
row = QHBoxLayout() # Первая строка отвечает за выбор файлов и каналов.
self.load_button = QPushButton('Открыть эталон CSV…') # Эталон загружается отдельно от исходного измерения.
self.load_button.clicked.connect(self._open_reference) # Выбор файла запускает фоновое чтение.
self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Каналы измерения и эталона выбираются независимо.
for signal, _ in sources: # Показываем все переданные каналы с непустой историей.
self.source_combo.addItem(signal.name) # Имя берётся из общего контракта источника.
row.addWidget(self.load_button) # Кнопка открытия предшествует выбору каналов.
for label, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Подписи явно различают обе стороны сравнения.
row.addWidget(QLabel(label)) # Подпись относится к следующему списку каналов.
row.addWidget(control, 1) # Оба списка делят свободное место поровну.
root.addLayout(row) # Завершаем строку выбора данных.
row = QHBoxLayout() # Вторая строка задаёт временное совмещение и допуски.
self.align_start = QCheckBox('Совместить начала записей') # Позволяет сравнивать записи с разными абсолютными началами.
self.align_start.setChecked(True) # По умолчанию сравниваем форму от начала каждой записи.
self.align_start.setToolTip('Совмещает первые отсчёты. Задержка самого переключения сохраняется.') # Объясняем отличие совмещения начала от поиска фронта.
self.shift = self._number(-1e12, 1e12, 6) # Диапазон ±10^12 мкс и шесть знаков сохраняет прежнюю точность ввода сдвига.
self.shift.setSuffix(' мкс') # В интерфейсе время вводится в микросекундах.
self.shift.setToolTip('Положительное значение сдвигает эталон вправо') # Явно задаём знак временного сдвига.
self.absolute = self._number(0., 1e12, 6) # Абсолютный допуск неотрицателен и измеряется в единицах канала.
self.relative = self._number(0., 10000., 4) # Относительная часть допуска задаётся в процентах с четырьмя знаками.
self.relative.setSuffix(' %') # Единица отделяет проценты от абсолютной амплитуды.
row.addWidget(self.align_start) # Опция совмещения находится рядом со сдвигом.
for label, control in (('Сдвиг эталона', self.shift), ('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Подписи отражают сумму абсолютной и процентной частей.
row.addWidget(QLabel(label)) # Связываем пояснение с соответствующим полем.
row.addWidget(control) # Ограниченные по ширине поля оставляют место всем параметрам.
root.addLayout(row) # Добавляем настройки совмещения в общий макет.
row = QHBoxLayout() # Третья строка управляет интервалом, расчётом и видом результата.
self.limit_interval = QCheckBox('Только интервал, мкс') # Флажок включает ограничение расчёта по времени.
self.start, self.stop = self._number(-1e12, 1e12, 6), self._number(-1e12, 1e12, 6) # Границы используют ту же шкалу мкс и точность, что сдвиг.
self.stop.setValue(300.) # Начальный интервал 0–300 мкс сохраняет прежнее поведение диалога.
self.visible_button = QPushButton('Из видимого участка') # Текущий масштаб графика можно использовать как интервал расчёта.
self.visible_button.clicked.connect(self._visible_interval) # Переводим границы графика из мс в мкс.
self.calculate_button = QPushButton('Сравнить') # Расчёт запускается явно после выбора параметров.
self.calculate_button.clicked.connect(self._calculate) # Параметры фиксируются перед запуском worker.
self.cancel_button = QPushButton('Отмена расчёта') # Длинную загрузку или расчёт можно прервать.
self.cancel_button.clicked.connect(self._cancel) # Используем кооперативную отмену без остановки потока извне.
self.mode = QComboBox() # Режим отображения не меняет уже вычисленные показатели.
self.mode.addItems(['Наложение', 'Отклонение: измерение − эталон']) # Сначала показываем две кривые в общем масштабе.
self.mode.currentIndexChanged.connect(self._show_plot) # Переключаем кривые без повторного численного прохода.
for control in (self.limit_interval, self.start, QLabel('…'), self.stop, self.visible_button, self.calculate_button, self.cancel_button, self.mode): # Сохраняем порядок интервал → расчёт → вид.
row.addWidget(control) # Каждый элемент относится к одной строке управления результатом.
root.addLayout(row) # Завершаем область параметров.
self.status = QLabel('Откройте эталон. Сравниваются все отсчёты выбранного канала после преобразования амплитуды.') # Общий порт не предполагает конкретный формат преобразования источника.
self.status.setWordWrap(True) # Длинные ошибки CSV не расширяют окно.
root.addWidget(self.status) # Состояние видно над графиком.
self.circuit = CircuitParameters(self) # Неизвестные элементы по умолчанию не подменяются учебными номиналами.
self.circuit.changed.connect(self._invalidate) # Изменение электрического ограничения делает прежние параметры модели неактуальными.
root.addWidget(self.circuit) # Опциональные R/L/C доступны перед строкой анализа и результатами.
self.analysis = ComparisonAnalysis(self) # Строка возможной причины и подробный отчёт повторяют подход Android GUI.
root.addWidget(self.analysis) # Анализ располагается рядом с результатом, не изменяя исходные кривые.
self.legend = QLabel() # Цветная легенда поясняет текущие кривые.
root.addWidget(self.legend) # Легенда отделена от численной таблицы.
splitter = QSplitter(Qt.Orientation.Horizontal) # Пользователь меняет соотношение ширины графика и таблицы.
self.plot = plot_factory() # Конкретный canvas создаётся и настраивается адаптером приложения.
splitter.addWidget(self.plot) # QSplitter принимает любой совместимый QWidget графика.
self.table = QTableWidget(0, 2) # Каждому показателю соответствует название и значение.
self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Заголовки поясняют две колонки отчёта.
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Показатели расчёта недоступны для ручной правки.
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # Названия и числа видны полностью по содержимому.
self.table.setMinimumWidth(370) # Минимум 370 логических пикселей сохраняет читаемость показателей.
splitter.addWidget(self.table) # Таблица располагается справа от кривых.
splitter.setSizes([920, 380]) # Начальные ширины в логических пикселях отдают большую часть окна графику.
root.addWidget(splitter, 1) # Результат получает всё свободное место по высоте.
row = QHBoxLayout() # Нижняя строка содержит экспорт и пояснение алгоритма.
self.report_button = QPushButton('Сохранить отчёт JSON…') # Экспорт доступен только для актуального результата.
self.report_button.clicked.connect(self._save_report) # Имя файла предлагает внедрённый provider путей.
row.addWidget(self.report_button) # Кнопка экспорта расположена перед пояснением.
note = QLabel('Δ = измерение − эталон. Линейная интерполяция эталона; без экстраполяции и заполнения пропусков.') # Формула и ограничения относятся ко всем приложениям-потребителям.
note.setWordWrap(True) # Текст остаётся читаемым при изменении размера окна.
row.addWidget(note, 1) # Пояснение занимает оставшуюся ширину строки.
root.addLayout(row) # Завершаем макет экспорта.
self._inputs = [self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, self.limit_interval, self.start, self.stop, self.visible_button] # Во время расчёта входные данные не меняются.
self._inputs.extend(self.circuit.controls) # Электрические ограничения также фиксируются на время рабочего задания.
for control in (self.shift, self.absolute, self.relative, self.start, self.stop): # Любое числовое изменение делает результат устаревшим.
control.valueChanged.connect(self._invalidate) # Сбрасываем старые кривые и отчёт до следующего расчёта.
for control in (self.align_start, self.limit_interval): # Флажки также влияют на вычисляемую временную сетку.
control.toggled.connect(self._invalidate) # Показатели не должны относиться к прежним условиям.
self.source_combo.currentIndexChanged.connect(self._match_reference) # При смене измерения ищем одноимённый эталон.
self.reference_combo.currentIndexChanged.connect(self._invalidate) # Смена эталонного канала требует нового расчёта.
self._busy(False) # Настраиваем начальную доступность кнопок.
QApplication.instance().aboutToQuit.connect(self._shutdown) # Завершение приложения дожидается отмены рабочего потока.
@staticmethod # Фабрика числового поля не зависит от состояния диалога.
def _number(low, high, decimals): # Границы и число десятичных знаков задаются для конкретной величины.
box = QDoubleSpinBox() # Штатный элемент проверяет числовой ввод.
box.setDecimals(decimals) # Сохраняем требуемую точность времени или допуска.
box.setRange(low, high) # Ограничиваем значения допустимым диапазоном параметра.
box.setMaximumWidth(150) # Ширина 150 логических пикселей сохраняет компактную строку параметров.
return box # Вызывающий код задаёт единицы и обработчик изменений.
def _busy(self, busy): # Единый переход состояния используется загрузкой и расчётом.
for control in self._inputs: # Все параметры относятся к одному снимку задания.
control.setEnabled(not busy) # Исключаем изменение входа работающего worker.
self.calculate_button.setEnabled(not busy and bool(self.references) and bool(self.sources)) # Без измерения или эталона расчёт невозможен.
self.cancel_button.setEnabled(busy) # Отменять можно только существующее задание.
self.report_button.setEnabled(not busy and self.comparison is not None) # Отчёт относится исключительно к завершённому результату.
def _start_work(self, operation, receive): # Операция и получатель отделяют чтение файла от численного расчёта.
self._busy(True) # Блокируем параметры до запуска потока.
worker = ComparisonWorker(operation, self) # Диалог сохраняет владение worker до его завершения.
self._worker = worker # Ссылка используется для отмены и отложенного закрытия.
self._worker_state[0] = worker # Сигнал уничтожения владельца получает актуальную задачу.
worker.result.connect(receive) # Результат применяется в главном потоке Qt.
worker.failed.connect(self.status.setText) # Ошибка появляется в общей строке состояния.
worker.finished.connect(self._finished) # Освобождение worker восстанавливает доступность элементов.
worker.start() # Начинаем фоновое чтение или расчёт.
def _finished(self): # Вызывается после штатного завершения и после отмены.
worker, self._worker = self._worker, None # Снимаем состояние занятости до возможного закрытия диалога.
self._worker_state[0] = None # Уничтожение родителя больше не обращается к завершённому worker.
if worker is None: # Синхронное закрытие могло уже очистить поток до доставки queued-сигнала.
return # Не повторяем удаление и не обновляем закрытую панель.
worker.deleteLater() # Qt безопасно удаляет завершившийся поток через очередь событий.
self._busy(False) # Разрешаем новый расчёт либо экспорт принятого результата.
if self._closing: # Запрос закрытия во время работы должен завершиться автоматически.
self.close() # Повторное закрытие уже не встречает активный worker.
def _cancel(self): # Отмена кооперативна и проверяется вычислительным ядром.
if self._worker is not None: # Не обращаемся к уже завершённому потоку.
self._worker.requestInterruption() # Ядро увидит запрос в ближайшей контрольной точке.
self.status.setText('Остановка расчёта…') # Пользователь видит, что запрос принят.
def _shutdown(self): # Перед выходом процесса нельзя уничтожать работающий QThread.
self._closing = True # Поздние сигналы не должны публиковать результат в закрывающемся интерфейсе.
if self._worker is not None: # Ожидание требуется только при активном задании.
worker = self._worker # Сохраняем объект до очистки общего держателя.
worker.requestInterruption() # Просим ядро прервать чтение или расчёт.
worker.wait() # Дожидаемся остановки до уничтожения объектов Qt.
self._worker = None # Смена записи может немедленно удалить закрытую панель.
self._worker_state[0] = None # Сигнал destroyed не обращается к удалённому потоку повторно.
worker.deleteLater() # Завершённый QThread безопасно освобождается очередью Qt.
def _invalidate(self, *_): # Аргументы сигналов Qt не нужны для сброса результата.
self.comparison = None # Старые показатели больше не соответствуют выбранным параметрам.
self.analysis.set_result(None) # После изменения ввода прежняя гипотеза больше не является актуальной.
self.table.setRowCount(0) # Убираем устаревшие численные значения.
self.report_button.setEnabled(False) # Запрещаем экспорт прежнего результата.
self.plot._series = [] # Старые кривые также не должны выглядеть актуальными.
self.legend.clear() # Легенда очищается вместе с кривыми.
self.plot._sync_bounds() # Адаптер графика пересчитывает пустую область просмотра.
self.plot.update() # Запрашиваем перерисовку в главном потоке.
if self.references: # После загрузки эталона подсказываем следующий шаг.
self.status.setText('Параметры изменены — нажмите «Сравнить».') # Новый результат получается только явным расчётом.
def _match_reference(self, *_): # Смена канала измерения ищет одноимённый столбец эталона.
name = self.source_combo.currentText().strip().casefold() # Регистр и крайние пробелы не мешают совпадению имён.
for index in range(self.reference_combo.count()): # Проверяем доступные каналы текущего эталона.
if self.reference_combo.itemText(index).strip().casefold() == name: # Сопоставляем полное имя вместе с единицами.
self.reference_combo.setCurrentIndex(index) # Предлагаем канал, соответствующий измерению.
break # Уникальные имена не требуют дальнейшего поиска.
self._invalidate() # Даже без совпадения старые показатели должны быть удалены.
def _open_reference(self): # Правила начальной папки задаёт callback приложения.
path, _ = QFileDialog.getOpenFileName(self, 'Открыть эталон', self._dialog_path(''), 'CSV (*.csv)') # Общая библиотека не создаёт каталоги самостоятельно.
if path: # Отмена выбора файла сохраняет текущий эталон.
self.load_reference(path) # Чтение выполняется отдельным worker.
def load_reference(self, path): # Публичный вход позволяет потребителю открыть эталон программно.
self._invalidate() # Новая загрузка не должна показывать старый отчёт как актуальный.
self.status.setText('Загрузка эталона…') # Состояние обновляется до запуска чтения.
def receive(traces): # Этот обработчик выполняется в потоке интерфейса.
if self._closing: # Синхронное закрытие могло опередить доставку готового чтения.
return # Не обновляем элементы закрываемой панели поздним результатом.
self.references = traces # Принимаем только полностью прочитанный набор каналов.
self.reference_path = str(path) # Путь отчёта соответствует принятому файлу.
self.reference_combo.clear() # Удаляем имена каналов предыдущего эталона.
self.reference_combo.addItems(list(traces)) # Сохраняем порядок столбцов CSV.
self._match_reference() # Автоматически выбираем одноимённый канал при наличии.
self.status.setText(f'Эталон: {Path(path).name}. Выберите каналы и нажмите «Сравнить».') # Подтверждаем успешную загрузку файла.
self._start_work(lambda cancel: read_reference(path, cancel), receive) # Ядро получает callback отмены чтения.
def _visible_interval(self): # Видимая область графика задаёт границы расчёта.
bounds = self.plot.visible_bounds # Контракт графика возвращает время в миллисекундах.
left, right = bounds.left * 1000, bounds.right * 1000 # Множитель 1000 переводит миллисекунды в микросекунды UI.
self.limit_interval.setChecked(True) # Явно включаем использование выбранных границ.
self.start.setValue(left) # Левая граница отсчитывается от начала измерения.
self.stop.setValue(right) # Правая граница выражена в той же шкале.
def _calculate(self): # Фиксируем параметры до передачи задания в поток.
if not self.references or not self.sources or self._worker is not None: # Не запускаем неполное или параллельное сравнение.
return # Сохраняем существующее задание и состояние входов.
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._invalidate() # Предыдущий результат не относится к новому сочетанию каналов.
self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Пользователь выбирает корректную пару.
return # Не выполняем численный проход с несовместимыми единицами.
options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., # Деление на 1000 переводит микросекунды UI в миллисекунды ядра.
absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value(), # Допуски уже заданы в единицах ядра.
interval=(self.start.value() / 1000., self.stop.value() / 1000.) if self.limit_interval.isChecked() else None) # Ограничение времени также переводится из мкс в мс.
self._invalidate() # До расчёта удаляем старые кривые и показатели.
self.status.setText('Сравнение всех отсчётов…') # Явно указываем работу с полной записью.
options['circuit'] = self.circuit.values() # Значения GUI переводятся в СИ до перехода в рабочий поток.
sources, references = self.sources, self.references # Сохраняем единый снимок связанных каналов и выбранного CSV.
def operation(cancel): # Подготовка амплитуд и сравнение выполняются в worker.
measured = self._source_cache.get(source_index) # Кэш привязан к неизменяемому снимку канала.
if measured is None: # Преобразуем исходные значения только при первом использовании канала.
measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Применяем предоставленное источником преобразование амплитуд.
self._source_cache[source_index] = measured # Повторные настройки допуска не требуют новой подготовки измерения.
channels = _model_channels(sources, references, self._source_cache, cancel) # Синхронные Uce/Uge/Ic подготавливаются теми же преобразованиями амплитуд.
return compare(measured, reference, cancel=cancel, channels=channels, **options) # Общее ядро выполняет полную статистику и идентификацию электрических моделей.
self._start_work(operation, self._received) # Получатель публикует целый результат в главном потоке.
def _received(self, result): # Таблица и график обновляются из одного результата.
if self._closing: # Готовый результат мог стоять в очереди во время смены записи.
return # Закрытая панель не должна публиковать устаревшие метрики.
self.comparison = result # Сохраняем данные для переключения вида и экспорта.
self.analysis.set_result(result) # Краткий вывод обновляется вместе с полной таблицей сравнения.
populate_comparison_table(self.table, result) # Единицы и подсказки названий и чисел совпадают с боковой таблицей исходного графика.
self.status.setText(f'{result.measured.name} ↔ {Path(self.reference_path).name}: {result.reference.name}. ' # Статус указывает фактически сравниваемые каналы и файл.
'Время от начала измерения; абсолютные ошибки — в единицах выбранного канала.') # Поясняем шкалу и единицы результата.
self._show_plot() # Показываем текущий режим кривых без повторного расчёта.
def _show_plot(self, *_): # Сигнал выбора режима может передавать неиспользуемый индекс.
if self.comparison is None: # До успешного расчёта график результата отсутствует.
return # Не создаём кривые из устаревших данных.
result = self.comparison # Кривые и остаток принадлежат одному расчёту.
curves = [(result.measured, 'Измерение', '#2f91ff'), (result.reference, 'Эталон', '#ffae42')] # Синий и оранжевый различают стороны наложения.
self.legend.setText('<span style="color:#2f91ff">━━ Измерение</span> &nbsp; ' # Цвет легенды совпадает с измеренной кривой.
'<span style="color:#ffae42">━━ Эталон</span> — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу.
if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность.
curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых.
self.legend.setText('<span style="color:#ff7b7b">━━ Δ = измерение − эталон</span>') # Формула фиксирует знак ошибки.
self.plot._series = [self._series_factory(str(i), f'{label}: {trace.name}', color=color, timestamps=trace.times, values=trace.values) # Фабрика создаёт контейнер конкретного canvas без импорта приложения.
for i, (trace, label, color) in enumerate(curves)] # Передаём полные массивы без экранного сокращения.
self.plot.reset_interaction() # Новый вид начинает с нейтрального состояния навигации.
self.plot.axis_lock_x.setChecked(False) # Освобождаем X для корректного показа нового результата.
self.plot.axis_lock_y.setChecked(False) # Общий масштаб Y не наследует ручную фиксацию предыдущего режима.
self.plot.axis_limits = None # Удаляем прежние ручные границы осей.
self.plot._sync_bounds() # Адаптер рассчитывает общую область кривых.
interval = result.metrics['interval_ms'] # При ограниченном расчёте показываем выбранный интервал.
if interval is not None: # Полная запись остаётся видимой при отсутствии ограничения.
bounds = self.plot.visible_bounds # После сброса границы соответствуют полной области результата.
span = bounds.right - bounds.left # Нормируем миллисекунды в долю общей длительности.
self.plot.viewport = Viewport(x=(interval[0] - bounds.left) / span, width=(interval[1] - interval[0]) / span) # Масштаб X соответствует выбранному интервалу ядра.
self.plot.update() # Отложенная перерисовка выполняется главным потоком Qt.
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) # Отчёт сохраняет также гипотезы, признаки и ограничения анализа.
def _save_report(self): # Сохранение файла выполняется только по явному действию пользователя.
if self.comparison is None: # Защищаемся от программного вызова без расчёта.
return # Не создаём пустой отчёт.
path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._dialog_path('reference_comparison.json'), 'JSON (*.json)') # Provider определяет начальную папку приложения.
if path: # Отмена выбора не меняет состояние результата.
try: # Файловые ошибки должны отображаться без закрытия диалога.
Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 и отступ в два пробела сохраняют читаемость русских имён.
self.status.setText(f'Отчёт сохранён: {path}') # Подтверждаем фактический путь записанного отчёта.
except OSError as error: # Недоступный каталог или файл не уничтожает рассчитанные данные.
self.status.setText(str(error)) # Пользователь может выбрать другое место и повторить сохранение.
class ReferenceComparisonPanel(_ReferenceComparisonView, QWidget): # Встроенный вид занимает область существующего графика без отдельного окна.
back_requested = Signal() # Потребитель решает, какую страницу показать при возврате к записи.
def __init__(self, *args, **kwargs): # Контракт данных и фабрик совпадает с отдельным диалогом.
super().__init__(*args, **kwargs) # Создаём общие параметры, таблицу и график через QWidget.
self.back_button = QPushButton('К записи') # Возврат сохраняет загруженный эталон и параметры скрытой панели.
self.back_button.clicked.connect(self.back_requested.emit) # Не связываем общую библиотеку с компоновкой конкретной вкладки.
row = QHBoxLayout() # Отдельная короткая строка не сжимает параметры сравнения.
row.addWidget(self.back_button) # Кнопка размещается у начала панели.
row.addStretch(1) # Свободная ширина остаётся справа от кнопки возврата.
self.layout().insertLayout(0, row) # Навигация предшествует выбору файлов и каналов.
def closeEvent(self, event): # Родитель может удалить панель сразу после close при смене записи.
self._closing = True # Сигнал завершения worker больше не должен возобновлять использование панели.
self._shutdown() # Синхронно отменяем и дожидаемся потока перед возможным deleteLater родителя.
self.plot.close() # Освобождаем ресурсы графика после завершения вычислений.
super().closeEvent(event) # QWidget принимает закрытие встроенной панели.
class ReferenceComparisonDialog(_ReferenceComparisonView, QDialog): # Отдельное окно остаётся доступным другим потребителям общего модуля.
def __init__(self, *args, **kwargs): # Используем тот же контракт источников и внедряемых фабрик.
super().__init__(*args, **kwargs) # Общая реализация строит элементы внутри QDialog.
self.setWindowModality(Qt.WindowModality.WindowModal) # Модальность ограничивается родительским окном.
self.resize(1350, 880) # Размер в логических пикселях сохраняет прежний макет отдельного окна.
def reject(self): # Escape и программное закрытие откладываются до остановки worker.
if self._worker is not None: # Работающий поток должен закончиться раньше удаления диалога.
self._closing = True # Запоминаем запрос закрытия до сигнала finished.
self._cancel() # Кооперативно прерываем операцию ядра.
return # Слот _finished завершит закрытие после остановки.
self.plot.close() # Освобождаем ресурсы конкретного canvas.
super().reject() # Сохраняем стандартный API и сигналы QDialog.
def closeEvent(self, event): # Системная кнопка закрытия соблюдает тот же порядок отмены.
if self._worker is not None: # Не уничтожаем QThread во время операции.
self._closing = True # Запоминаем отложенное закрытие.
self._cancel() # Запрос будет замечен ядром в контрольной точке.
event.ignore() # Диалог существует до завершения worker.
else: # Завершённый расчёт не препятствует закрытию.
self.plot.close() # График завершает собственную фоновую работу.
super().closeEvent(event) # Передаём QDialog штатное закрытие.