Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
156 lines
29 KiB
Python
156 lines
29 KiB
Python
"""Общая таблица отклонений и показателей сравнения без собственного графика.""" # Потребитель размещает её рядом с исходным canvas.
|
|
import re # Извлекаем обозначение единицы из конечных скобок имени канала по тому же правилу, что проверка совместимости каналов.
|
|
try: # Поддерживаем современные и совместимые настольные сборки.
|
|
from PySide6.QtCore import Qt, Signal # Сигналы и режим обычного текста поддерживают локальный отчёт без внешних сервисов.
|
|
from PySide6.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QPushButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget # Таблица и подробный текст используют штатные элементы Qt6.
|
|
except ImportError: # Qt5 использует тот же контракт таблицы и сигналов.
|
|
from PySide2.QtCore import Qt, Signal # Qt5 сохраняет те же сигналы и режим обычного текста.
|
|
from PySide2.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QPushButton, QSizePolicy, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget # Набор элементов анализа совпадает с современным портом.
|
|
|
|
|
|
class ComparisonAnalysis(QWidget): # Как в Android, краткий анализ дополняется кнопкой открытия подробного отчёта.
|
|
def __init__(self, parent=None): # Компонент одинаково работает над графиком и рядом с таблицей.
|
|
super().__init__(parent) # Владелец сравнения управляет размещением и уничтожением строки.
|
|
self._analysis, self._dialog = {}, None # До завершения сравнения подробности отсутствуют.
|
|
row = QHBoxLayout(self) # Одна строка не отнимает значительную высоту у графика.
|
|
row.setContentsMargins(0, 0, 0, 0) # Используем отступы содержащей панели.
|
|
self.summary = QLabel() # Краткий вывод остаётся виден без открытия полной таблицы.
|
|
self.summary.setTextFormat(Qt.TextFormat.PlainText) # Диагностическая строка всегда является обычным текстом.
|
|
self.summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) # Длинная гипотеза не увеличивает минимальную ширину окна.
|
|
row.addWidget(self.summary, 1) # Подпись получает свободную ширину, сохраняя кнопку доступной.
|
|
self.report_button = QPushButton('Отчёт анализа…') # Название отличает подробности гипотезы от сохранения JSON.
|
|
self.report_button.clicked.connect(self._show_report) # Открываем текст только по явному действию пользователя.
|
|
row.addWidget(self.report_button) # Кнопка находится справа, как в панели Android.
|
|
self.set_result(None) # Начальное состояние не содержит ложного диагноза.
|
|
|
|
def set_result(self, result): # Любое изменение параметров удаляет прежнюю гипотезу вместе с численными показателями.
|
|
self._analysis = result.analysis if result is not None else {} # Принимаем только анализ актуального сравнения.
|
|
text = 'Анализ: ' + self._analysis.get('summary', 'нет актуального сравнения с эталоном.') # Общая подпись объясняет пустое состояние.
|
|
self.summary.setText(text) # Строка обновляется одновременно с результатом worker.
|
|
self.summary.setToolTip(text) # Полный вывод доступен даже при узком окне и обрезанной подписи.
|
|
self.summary.setStyleSheet('color: #ef9a73;' if self._analysis.get('attention') else '') # Цвет замечания различает требующий проверки результат, не меняя общую тему.
|
|
self.report_button.setEnabled(bool(self._analysis)) # Нельзя открыть пустые или устаревшие подробности.
|
|
if self._dialog is not None: # Открытый отчёт не должен оставаться текущим после нового расчёта.
|
|
self._dialog.close() # Смена результата закрывает окно со старым снимком.
|
|
self._dialog.deleteLater() # Освобождаем скрытые текстовые окна при многократных пересчётах.
|
|
self._dialog = None # Следующее открытие создаст отчёт по актуальным данным.
|
|
|
|
def _show_report(self): # Подробности не запускают повторного анализа и не требуют подключения к сети.
|
|
if not self._analysis: # Защищаем программный вызов при отсутствии результата.
|
|
return # Не открываем пустое окно.
|
|
if self._dialog is not None: # Повторное нажатие возвращает существующее окно текущего отчёта.
|
|
self._dialog.show() # Повторно показываем скрытое окно с тем же результатом.
|
|
self._dialog.raise_() # Поднимаем готовое окно без дублирования виджетов.
|
|
return # Текст относится к тому же актуальному сравнению.
|
|
dialog = QDialog(self) # Родитель уничтожает подробности вместе со строкой анализа.
|
|
dialog.setWindowTitle('Анализ сравнения с эталоном') # Заголовок обозначает источник диагностических гипотез.
|
|
dialog.resize(1020, 740) # Размер в логических пикселях оставляет место таблице конкурирующих моделей и подробным параметрам.
|
|
root = QVBoxLayout(dialog) # Текст занимает свободную высоту над кнопкой закрытия.
|
|
report = self._analysis.get('model_report', {}) # Старые или неполные результаты могут не содержать идентифицированных моделей.
|
|
models = report.get('models', []) # В одну таблицу входят только конкуренты на общей сетке; отдельные RLC-окна описаны ниже.
|
|
if models: # Не создаём пустую таблицу при ошибке выбора каналов или недостатке точек.
|
|
caption = QLabel(f'Сравнение моделей: {report.get("sampled_points", 0)} точек. RMSE — в единицах канала; большее ΔBIC означает преимущество над эталоном. * — выполнены критерии поддержки модели.') # Подпись объясняет величины и отметку подтверждения без зависимости от шрифтов специальных символов.
|
|
caption.setWordWrap(True) # Пояснение не расширяет окно при небольшом экране.
|
|
root.addWidget(caption) # Критерии видны перед подробным техническим текстом.
|
|
table = QTableWidget(len(models), 5, dialog) # Каждый кандидат имеет собственные показатели ошибки и подтверждения.
|
|
table.setHorizontalHeaderLabels(['Модель', 'RMSE', 'Контроль RMSE', 'ΔBIC', 'Ошибка описана, %']) # Критерий сложности отделён от амплитудной погрешности.
|
|
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Пользователь не изменяет результаты численного подбора вручную.
|
|
table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) # Колонки используют всю доступную ширину окна отчёта.
|
|
table.setMaximumHeight(260) # До 260 логических пикселей оставляют место формулам и ограничениям ниже.
|
|
names = dict(reference='Исходный эталон', offset='Смещение', gain='Масштаб и ноль', drift='Линейный дрейф', timing='Задержка', slow_switching='Растяжение фронтов', constant='Постоянный уровень', contact_bounce='Прерывания контакта') # Русские названия сохраняют стабильные машинные коды в JSON.
|
|
for row, model in enumerate(models): # Порядок уже соответствует возрастанию BIC на общей обучающей выборке.
|
|
name = names.get(model['code'], model['code']) + (' *' if model['supported'] else '') # Обычная звёздочка означает выполнение критериев модели и корректно отображается в системных шрифтах Qt5 и Qt6.
|
|
values = [name, f'{model["rmse"]:.6g}', f'{model["validation_rmse"]:.6g}', f'{model["delta_bic"]:.6g}', f'{model["explained_error_pct"]:.4g}'] # Числа сохраняют достаточно значащих цифр для сравнения близких кандидатов.
|
|
for column, value in enumerate(values): # Каждая ячейка относится к тому же снимку параметров worker.
|
|
item = QTableWidgetItem(value) # Текстовая ячейка поддерживает штатное выделение таблицы Qt.
|
|
item.setToolTip(f'{model["formula"]}\n{model["parameters"]}') # Формула и оценённые параметры доступны без прокрутки полного текста.
|
|
table.setItem(row, column, item) # Устанавливаем готовое значение без повторного расчёта модели.
|
|
root.addWidget(table) # Таблица дополняет существующий подробный отчёт, не подменяя основной график.
|
|
details = QPlainTextEdit(self._analysis['details'], dialog) # Обычный текст поддерживает выделение и копирование без HTML.
|
|
details.setReadOnly(True) # Пользователь не меняет автоматически рассчитанный отчёт.
|
|
root.addWidget(details, 1) # Длинный отчёт прокручивается внутри окна.
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) # Стандартная кнопка сохраняет поведение Qt5 и Qt6.
|
|
buttons.button(QDialogButtonBox.StandardButton.Close).setText('Закрыть') # Русская подпись не зависит от наличия переводчика стандартных строк Qt.
|
|
buttons.rejected.connect(dialog.reject) # Кнопка закрывает только подробный отчёт.
|
|
root.addWidget(buttons) # Действие доступно под текстом независимо от прокрутки.
|
|
self._dialog = dialog # Сохраняем окно для повторного открытия и сброса устаревших подробностей.
|
|
dialog.show() # Немодальный отчёт позволяет продолжать просмотр кривых.
|
|
|
|
|
|
def comparison_rows(metrics, *channel_names): # Имена измерения и эталона позволяют обеим таблицам показывать фактическую единицу амплитудных ошибок.
|
|
units = [re.search(r'\(([^()]*)\)\s*$', name) for name in channel_names] # Проверяем конечные скобки сначала у измерения, затем у эталона, если измерение не содержит единицы.
|
|
unit = next((match.group(1).strip() for match in units if match and match.group(1).strip()), 'ед. канала') # При отсутствии явной единицы сохраняем честное общее обозначение без догадки о физической величине.
|
|
fmt = lambda value: '—' if value is None else f'{value:.7g}' # Семь значащих цифр сохраняют читаемость; None означает отсутствие определённого значения.
|
|
return [('Сравнено отсчётов', str(metrics['count'])), ('Покрытие интервала, %', fmt(metrics['coverage_pct'])), # Показываем число корректных сравнений и долю покрытия записи.
|
|
('Вне времени эталона, отсчётов', str(metrics['outside_reference'])), ('Пропуски, отсчётов', str(metrics['invalid_points'])), # Единица количества отличает отсутствие покрытия и пропуски от длительности в микросекундах.
|
|
(f'Среднее Δ (смещение), {unit}', fmt(metrics['bias'])), (f'Среднее |Δ| (MAE), {unit}', fmt(metrics['mae'])), # Среднее смещение и средний модуль ошибки имеют ту же единицу амплитуды, что выбранный канал.
|
|
(f'СКЗ Δ (RMSE), {unit}', fmt(metrics['rmse'])), (f'СКО Δ без смещения, {unit}', fmt(metrics['error_std'])), # Корень среднего квадрата и стандартное отклонение выражаются в единицах канала, а не в квадрате этих единиц.
|
|
(f'Максимум |Δ|, {unit}', fmt(metrics['max_abs'])), ('Время max |Δ|, мкс', fmt(metrics['max_abs_time_ms'] * 1000)), # Максимальная ошибка сохраняет единицу амплитуды; множитель 1000 переводит время из миллисекунд ядра в микросекунды интерфейса.
|
|
('RMSE / размах эталона, %', fmt(metrics['nrmse_span_pct'])), # Постоянный эталон не имеет размаха, поэтому нормированная ошибка может отсутствовать.
|
|
('За допуском, отсчётов', str(metrics['outside_tolerance'])), ('За допуском, %', fmt(metrics['outside_tolerance_pct'])), # Количество и процент превышений относятся к корректным сравнениям.
|
|
('Первый выход, мкс', fmt(None if metrics['first_outside_time_ms'] is None else metrics['first_outside_time_ms'] * 1000))] # None означает отсутствие превышения, иначе время выводится в микросекундах.
|
|
|
|
|
|
def populate_comparison_table(table, result): # Обе формы сравнения получают одинаковые единицы и подсказки для названия и значения каждого показателя.
|
|
descriptions = ( # Порядок пояснений соответствует строкам comparison_rows и сохраняет привычное расположение показателей.
|
|
'Число отсчётов выбранного интервала, для которых корректны и измерение, и эталон.', # Счётчик включает только точки, вошедшие в расчёт ошибок.
|
|
'Доля отсчётов выбранного интервала, которые удалось сравнить с эталоном. Пропуски и точки вне времени эталона уменьшают покрытие.', # Знаменатель включает все выбранные точки измерения.
|
|
'Число отсчётов выбранного интервала вне временных границ эталона после совмещения и сдвига. Эти точки не сравниваются.', # Отличаем отсутствие эталона по времени от некорректной амплитуды.
|
|
'Число точек в общей области времени с отсутствующим или некорректным измерением либо эталоном. Они исключены из расчёта ошибок.', # Разрыв интерполяции также считается пропуском.
|
|
'Средняя разность «измерение − эталон» со знаком. Положительное значение означает, что измерение в среднем выше эталона.', # Среднее отклонение оценивает постоянное смещение.
|
|
'Среднее абсолютное отклонение от эталона (MAE). Показывает среднюю величину ошибки без учёта её знака.', # Модули не позволяют положительным и отрицательным ошибкам взаимно сократиться.
|
|
'Корень из среднего квадрата отклонения (RMSE). Крупные ошибки влияют на него сильнее, чем на среднее абсолютное отклонение.', # После извлечения корня единица совпадает с единицей канала.
|
|
'Стандартное отклонение ошибки относительно её среднего. Показывает разброс после исключения постоянного смещения.', # Расчёт характеризует всю сравниваемую запись, без выборочной поправки.
|
|
'Наибольший модуль разности между измерением и эталоном среди всех корректно сравнённых отсчётов.', # Экстремум относится к полной записи, а не к экранной выборке.
|
|
'Время наибольшей абсолютной ошибки от начала измерения, в микросекундах. При повторении максимума указано первое время.', # Ноль времени не переносится на начало выбранного интервала.
|
|
'RMSE в процентах от разности максимума и минимума эталона на сравниваемом участке. Прочерк означает нулевой размах эталона.', # Постоянный эталон не позволяет определить нормированную ошибку.
|
|
'Число отсчётов, где |измерение − эталон| строго больше допуска: абсолютный допуск + относительный допуск / 100 × |эталон|.', # Равенство границе коридора считается допустимым.
|
|
'Доля отсчётов за допуском в процентах от числа корректно сравнённых отсчётов.', # Пропуски и отсутствие временного покрытия не входят в знаменатель.
|
|
'Время первого превышения допуска от начала измерения, в микросекундах. Прочерк означает, что превышений не было.', # Отсутствие события не подменяется нулём времени.
|
|
) # Все четырнадцать показателей имеют отдельное краткое пояснение.
|
|
rows = comparison_rows(result.metrics, result.measured.name, result.reference.name) if result is not None else [] # Единицы относятся к актуальной паре каналов; сброс результата удаляет прежние строки и подсказки.
|
|
table.setRowCount(len(rows)) # Размер обеих таблиц точно соответствует доступному набору показателей.
|
|
for row, (label, value) in enumerate(rows): # Сохраняем единый порядок показателей и пояснений во всех формах.
|
|
for column, text in enumerate((label, value)): # Одинаковая подсказка доступна при наведении как на название, так и на число.
|
|
item = QTableWidgetItem(text) # Каждая ячейка хранит свой текст и стандартную подсказку Qt.
|
|
item.setToolTip(descriptions[row]) # Краткое объяснение появляется при наведении без открытия отдельного окна.
|
|
table.setItem(row, column, item) # Таблица принимает владение ячейкой и заменяет прежнюю подсказку при пересчёте.
|
|
|
|
|
|
class ComparisonResults(QWidget): # Панель сохраняет все подробные расчёты рядом с прежним графиком.
|
|
export_requested = Signal() # Запись отчёта использует пути и актуальное сравнение владельца.
|
|
|
|
def __init__(self, parent=None): # Компонент не требует фабрики canvas и не меняет состав исходных кривых.
|
|
super().__init__(parent) # Родитель управляет размещением таблицы в боковой области.
|
|
root = QVBoxLayout(self) # Короткая строка действий располагается над полной таблицей.
|
|
root.setContentsMargins(0, 0, 0, 0) # Используем пространство прежней правой панели без лишних внешних полей.
|
|
self.description = QLabel('Загрузите эталон для расчёта отклонений.') # До чтения файла таблица не содержит устаревших чисел.
|
|
self.description.setWordWrap(True) # Имена каналов и ошибки не расширяют исходное окно.
|
|
root.addWidget(self.description) # Подпись объясняет знак ошибки и единицы результата.
|
|
self.analysis = ComparisonAnalysis(self) # Возможная причина доступна и на вкладке полных расчётов.
|
|
root.addWidget(self.analysis) # Строка гипотезы предшествует численным показателям.
|
|
row = QHBoxLayout() # График отклонения и экспорт доступны без раскрытия параметров над canvas.
|
|
self.delta_curve = QCheckBox('Показать кривую Δ') # Флажок добавляет остаток к оригиналу и эталону на существующих осях.
|
|
self.delta_curve.setEnabled(False) # Остаток отсутствует до завершения корректного сравнения.
|
|
row.addWidget(self.delta_curve) # Управление кривой располагается рядом с подробными показателями.
|
|
self.export_button = QPushButton('Отчёт JSON…') # Сохраняем полный набор расчётов вместе с параметрами сравнения.
|
|
self.export_button.clicked.connect(self.export_requested.emit) # Файловый диалог открывает владелец численного результата.
|
|
self.export_button.setEnabled(False) # Нельзя экспортировать отсутствующую или устаревшую статистику.
|
|
row.addWidget(self.export_button) # Кнопка остаётся видимой рядом с таблицей.
|
|
row.addStretch(1) # Не растягиваем действия на всю ширину боковой области.
|
|
root.addLayout(row) # Действия занимают одну строку независимо от числа показателей.
|
|
self.table = QTableWidget(0, 2) # Каждый показатель имеет отдельную строку с численным значением.
|
|
self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Две колонки сохраняют привычный формат полного отчёта.
|
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Пользователь не должен вручную менять вычисленные значения.
|
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Названия показателей остаются читаемыми.
|
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Колонка значений использует оставшуюся ширину.
|
|
self.table.verticalHeader().setDefaultSectionSize(22) # Высота 22 логических пикселя позволяет показать полный набор в обычной боковой области.
|
|
root.addWidget(self.table, 1) # Таблица получает свободную высоту и при необходимости прокручивается.
|
|
|
|
def set_result(self, result, message=''): # Все строки обновляются из одного результата или очищаются при его утрате.
|
|
self.analysis.set_result(result) # Гипотезы очищаются при тех же условиях, что таблица и экспорт.
|
|
populate_comparison_table(self.table, result) # Общий заполнитель обновляет единицы и подсказки обеих колонок либо полностью очищает устаревший результат.
|
|
self.delta_curve.setEnabled(result is not None) # Кривую отклонения можно включить только при наличии общих корректных точек.
|
|
self.export_button.setEnabled(result is not None) # Экспорт относится только к актуальному численному результату.
|
|
self.description.setText(f'Δ = измерение − эталон: {result.measured.name} ↔ {result.reference.name}. Ошибки — в единицах канала; время — в мкс.' if result is not None else message or 'Нет актуального результата сравнения.') # Явно различаем роли одноимённых каналов и показываем причину отсутствия результата.
|