Расширить DEIF, загрузку CAN и общую обработку сигналов

Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt.
Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor.
Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов.
Обновить справочники устройств, проекты CCS и регрессионные проверки.
This commit is contained in:
2026-10-24 15:40:51 +03:00
parent a5e46d4627
commit 41e24629ce
170 changed files with 43762 additions and 281 deletions
+7
View File
@@ -15,6 +15,10 @@
| `set_devices.qt_ports` | UART/mock, SLCAN, Candle/WinUSB, STM/TMS boot, STM settings, UMP CAN |
| `set_devices.plot_processing` | Общий контракт графиков: аппроксимация, интерполяция, восстановление, единицы и CSV через C99 |
| `set_devices.qt_ports.plot_processing` | Переиспользуемая панель всех методов, фоновый расчёт и отдельный слой кривой |
| `set_devices.signal_reference` | Сравнение полной записи с CSV-эталоном: совмещение времени, допуски, остаток и показатели ошибки без Qt |
| `set_devices.qt_ports.reference_comparison` | Общие встроенная панель и диалог сравнения, фоновое чтение/расчёт, внедряемые график и папки, JSON-отчёт |
| `set_devices.qt_ports.reference_overlay` | Компактные параметры и автоматически подготовленный эталон для наложения на существующий canvas без создания второго графика |
| `set_devices.qt_ports.reference_results` | Полная таблица отклонений, единое форматирование 14 показателей, флажок кривой Δ и экспорт |
| `protocan`, `setprotocol` | Существующие обёртки общего C99-ядра и SET v2 |
Импорт `logic_analyzer` / `set_devices` не загружает Qt и не зависит от GUI.
@@ -24,6 +28,9 @@ Saleae SDK подключается при обращении к устройс
в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`:
[контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md).
Сравнение с эталоном подключается через фабрики canvas и кривой без
зависимости от приложения: [API ядра, контракт Qt-порта и пример](../docs/reference-comparison.md).
Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий
компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт
одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет
+37 -7
View File
@@ -20,6 +20,7 @@ class CanBootTarget:
hardware_min: int = 0
hardware_max: int = 255
session_id: int = 1
memory: str = "eeprom" # Назначение выбирается явно; старые вызовы сохраняют EEPROM A/B и прежний wire-протокол.
def __post_init__(self):
if not 0 <= self.device_type <= 7 or not 0 <= self.device <= 15:
@@ -30,6 +31,10 @@ class CanBootTarget:
raise ValueError("Неверный диапазон аппаратных ревизий")
if not 1 <= self.session_id <= 255:
raise ValueError("Session ID должен быть 1…255")
if self.memory not in ("eeprom", "flash", "stm32") or self.memory == "flash" and self.product_type != 0x2833: # F28335 требует отдельного подтверждения выбора Flash; STM32 использует штатный односекционный поток.
raise ValueError("Внутренняя Flash по CAN доступна только для F28335 / Product Type 0x2833") # Ошибка назначения обнаруживается до первого CAN-кадра.
if self.memory == "stm32" and self.product_type != 0: # BOOT CANdle G474VET6 публикует Product Type 0 в своём аппаратном профиле.
raise ValueError("CANdle G474VET6 требует Product Type 0") # Не допускает случайную передачу образа с ранее выбранным типом платы Бальзам.
class CanBootTransfer:
@@ -41,6 +46,19 @@ class CanBootTransfer:
raise ValueError("F28335 M95M02 A/B: образ не должен превышать 130560 байт")
if not 0 <= image.version <= 0xFFFFFFFF:
raise ValueError("Версия образа вне диапазона u32")
if target.memory == "stm32": # Односекционная Flash G474 проверяется до первой команды ENTER_BOOT.
from set_devices.mcu_profiles import CANDLE_G474_BOOT # Размер приложения и адрес векторов берутся из общего профиля платы.
profile = CANDLE_G474_BOOT # Границы соответствуют 32 КиБ BOOT и 480 КиБ приложения STM32G474VET6.
if not 8 <= len(image.data) <= profile["app_size"]: # Первые восемь байтов содержат SP и Reset_Handler; запись не должна выходить за Flash.
raise ValueError("CANdle G474VET6: приложение должно занимать 8…491520 байт") # Ошибка обнаруживается до стирания целевого приложения.
sp, pc = struct.unpack_from("<II", image.data) # Таблица векторов ARM Cortex-M хранит два 32-битных little-endian адреса.
if sp & 7 or not profile["stack_base"] < sp <= profile["stack_end"]: # MSP выровнен на восемь байтов и входит в те же 80 КиБ SRAM, которые разрешает валидатор штатного BOOT CANdle.
raise ValueError("CANdle G474VET6: неверный начальный указатель стека") # Случайный BIN не рассматривается как приложение ARM.
if not pc & 1 or not profile["app_base"] <= (pc & ~1) < profile["app_base"] + len(image.data): # Thumb-вектор должен указывать внутрь приложения, слинкованного для 0x08008000.
raise ValueError("CANdle G474VET6: нужен образ приложения для 0x08008000") # BOOT-образ 0x08000000 и приложение USB BOOT RB не принимаются.
if target.memory == "flash": # Flash принимает тот же подписанный SCI8, что существующий RS Flash, с защищённым сектором A.
from .f28335_flash import parse_sci8 # Повторно использует общий валидатор FAB1, адресов и CRC без отдельной реализации в клиенте.
parse_sci8(image.data) # Обычный RAM BIN и образ с аппаратным boot отвергаются до входа в загрузчик.
self.image, self.target = image, target
self.stage = "enter"
self.next_block = 0
@@ -50,6 +68,8 @@ class CanBootTransfer:
@property
def response_timeout_ms(self):
if self.target.memory == "flash" and self.stage == "commit": # TI erase/program/verify выполняются синхронно после полной проверки staging.
return 180000 # Даёт до 180 секунд на штатную запись и итоговую CRC, как действующий RS Flash-клиент.
return 15000 if self.stage in ("enter", "verify", "commit") else 1500
@property
@@ -80,7 +100,7 @@ class CanBootTransfer:
# но метаданные образа сохраняют исходную длину без этого дополнения.
def _window(self):
total = (len(self.image.data) + 7) // 8
self._window_end = min(total, self.next_block + 16)
self._window_end = min(total, (self.next_block // 16 + 1) * 16) # Ядро подтверждает абсолютные границы по 16 блоков; после потери повторяется только хвост текущего окна, чтобы его ACK не застал в очереди кадры следующего окна.
return [self._frame(10 + self.slot, i, self.image.data[i * 8:(i + 1) * 8].ljust(8, b"\xff"))
for i in range(self.next_block, self._window_end)]
@@ -88,7 +108,7 @@ class CanBootTransfer:
if not self.accepts(frame):
return [], ""
command = ProtoCanId.parse(frame.can_id).body & 255
expected_command = {"enter": 2, "image": 3, "compat": 4, "erase": 5,
expected_command = {"enter": 2, "memory": 16, "image": 3, "compat": 4, "erase": 5, # Ответ выбора назначения принимается только на собственном этапе.
"data": 0, "verify": 6, "commit": 7, "reboot": 9}.get(self.stage)
# Позднее подтверждение предыдущего этапа нельзя применять к новому:
# оно не продвигает автомат и не считается ошибкой текущего запроса.
@@ -99,7 +119,10 @@ class CanBootTransfer:
return [], "Загрузчик занят"
if status not in (0, 8) or (status == 8 and self.stage != "data"):
raise RuntimeError(f"ProtoCAN Boot: ошибка 0x{status:02X}")
if self.stage == "enter":
if self.stage == "enter" and self.target.memory == "flash": # Старый загрузчик должен явно подтвердить поддержку новой цели до передачи образа.
self.stage = "memory" # Сначала требует ACK SELECT_MEMORY, затем начнёт обычные метаданные.
return self._control(16, b"\x01"), "Выбор внутренней Flash F28335" # DLC=1 и значение 1 означают внутреннюю Flash; значение 0 зарезервировано за штатной EEPROM.
if self.stage in ("enter", "memory"): # EEPROM сохраняет прежний переход, Flash переходит только после подтверждённого выбора памяти.
self.stage = "image"
return self._control(3, struct.pack("<II", len(self.image.data), self.image.crc32)), "Передача метаданных"
if self.stage == "image":
@@ -110,7 +133,7 @@ class CanBootTransfer:
if slot not in (0, 1):
raise RuntimeError("ProtoCAN Boot: неверный слот")
self.slot, self.stage = slot, "erase"
return self._control(5), "Стирание неактивного слота"
return self._control(5), ("Подготовка временного слота" if self.target.memory == "flash" else "Стирание приложения STM32" if self.target.memory == "stm32" else "Стирание неактивного слота") # F28335 готовит staging, STM32 стирает единственный слот, EEPROM сохраняет штатное поведение A/B.
if self.stage in ("erase", "data"):
total = (len(self.image.data) + 7) // 8
# The final index of a 512 KiB image wraps the u16 status field.
@@ -119,8 +142,11 @@ class CanBootTransfer:
limit = total if self.stage == "erase" else self._window_end
if expected > limit:
raise RuntimeError("ProtoCAN Boot: подтверждён непереданный блок")
if self.stage == "data" and status == 0 and expected < self.next_block:
return [], ""
# Один и тот же ACK или SEQUENCE_ERROR может прийти от нескольких оставшихся кадров старого окна.
# Повторная отправка на каждый такой ответ переполняет очередь MCU; при отсутствии продвижения
# последнее окно повторяет транспорт по тайм-ауту, а подтверждённые ранее блоки не отправляются заново.
if self.stage == "data" and expected <= self.next_block: # Сравнивает next_block для обоих статусов: OK и SEQUENCE_ERROR не должны повторно открывать одно окно.
return [], "" # Оставляет текущие кадры и срок ожидания транспорта без изменений до нового прогресса или тайм-аута.
self.next_block = expected
if expected == total:
self.stage = "verify"
@@ -129,11 +155,15 @@ class CanBootTransfer:
return self._window(), "Передача блоков ProtoCAN Boot"
if self.stage == "verify":
self.stage = "commit"
return self._control(7), "Активация образа"
return self._control(7), "Запись и проверка внутренней Flash" if self.target.memory == "flash" else "Активация образа" # Для Flash COMMIT означает реальный вызов TI API и публикацию FAB1 последней стадией.
if self.stage == "commit":
self.stage = "reboot"
return self._control(9), "Перезапуск прибора"
self.stage, self.finished = "finished", True
if self.target.memory == "stm32": # У односекционного STM32 нет EEPROM-кандидата и механизма пробного запуска A/B.
return [], "Flash STM32 записана и проверена; прибор перезапускается" # Успех сообщается после ACK VERIFY, COMMIT и REBOOT обычного протокола.
if self.target.memory == "flash": # Flash не выдаётся за EEPROM-кандидата, которому требуется trial/CONFIRM.
return [], "Внутренняя Flash записана и проверена; прибор перезапускается" # Успех возможен только после итогового ACK COMMIT и ACK REBOOT.
if self.target.product_type == 0x2833:
return [], "Образ принят; после перезапуска приложение подтвердит пробный запуск"
return [], "Прошивка ProtoCAN Boot завершена"
+99
View File
@@ -0,0 +1,99 @@
import base64 # Декодирует загружаемые секции из официального XML TI OFD, сохраняя адреса LOAD и RUN.
from pathlib import Path # Сохраняет изменения только в явно переданном результате текущей сборки.
import struct # Упаковывает адресуемые 16-битные слова C28x младшей октетой вперёд.
import subprocess # Вызывает локальный инспектор объектных файлов без доступа к JTAG.
import xml.etree.ElementTree as ET # Разбирает фактические секции COFF независимо от соседней карты линкера.
import zlib # CRC32 IEEE совпадает с проверкой постоянного загрузчика и учитывает незаписанные слова 0xFFFF.
APP_START = 0x300000 # Сектор H начинает область приложения; аппаратный boot остаётся в секторе A.
HEADER_WORDS = 16 # Заголовок версии 1 занимает 16 слов C28x, то есть 32 физических октета.
APP_DATA = APP_START + HEADER_WORDS # Исполняемые секции следуют после служебного заголовка приложения.
APP_STOP = 0x338000 # Исключённая верхняя граница совпадает с началом защищённого сектора A.
def descriptor(entry, loaded): # Создаёт проверяемое описание Flash-приложения без сектора постоянного загрузчика.
if not loaded or any(not APP_START <= address < APP_STOP for address in loaded): # Любое слово в секторе A, CSM, OTP или RAM делает образ непригодным для обновления приложения.
raise ValueError('Flash-приложение пересекает защищённый загрузчик или выходит за область 0x300000–0x337FFF') # Отказ происходит до стирания физической памяти.
end = max(loaded) + 1 # Верхняя граница включает все Flash LOAD-секции, в том числе копируемый в RAM код API.
if not APP_DATA <= entry < end or entry not in loaded: # Точка запуска обязана указывать на действительно присутствующее слово кода после заголовка.
raise ValueError('Точка входа Flash-приложения отсутствует в загружаемом коде') # Исключает пустой заголовок и устаревшую RAM-сборку.
payload = struct.pack('<' + 'H' * (end - APP_DATA), *(loaded.get(address, 0xFFFF) for address in range(APP_DATA, end))) # Пробелы между секциями должны оставаться стёртыми; порядок октетов соответствует C28x.
crc = zlib.crc32(payload) # Контрольная сумма защищает весь непрерывный диапазон приложения, включая промежутки.
values = (0x4146, 0x3142, 1, HEADER_WORDS, entry & 65535, entry >> 16, APP_DATA & 65535, APP_DATA >> 16, end & 65535, end >> 16, crc & 65535, crc >> 16, (~entry) & 65535, ((~entry) >> 16) & 65535, 0x5AA5, 0xA55A) # Первые четыре физических октета образуют ASCII FAB1; далее следуют версия, размер, адреса, CRC, инверсия entry и маркеры завершения.
return struct.pack('<16H', *values) # Передаёт ровно 32 октета без платформенного выравнивания структуры.
def validate_words(entry, loaded): # Одинаково проверяет содержимое JTAG COFF и транспортной таблицы RS Flash.
if any(APP_START + index not in loaded for index in range(HEADER_WORDS)): # Все слова заголовка должны присутствовать явно, а не получаться заполнением 0xFFFF.
raise ValueError('Нет полного заголовка Flash-приложения FAB1; требуется новая сборка под постоянный загрузчик') # Старый образ с codestart по 0x33FFF6 не допускается.
actual = struct.pack('<16H', *(loaded[APP_START + index] for index in range(HEADER_WORDS))) # Получает описание именно из передаваемых данных.
if actual != descriptor(entry, loaded): # Проверяет CRC, границы, обе половины entry и маркеры одной независимой реконструкцией.
raise ValueError('Заголовок или CRC32 Flash-приложения FAB1 не соответствует данным') # Повреждение нельзя скрыть корректным именем файла или внешним паспортом.
def parse_sci8(data): # Разбирает только приложение ниже сектора A, не разрешая аппаратный переход ROM.
if len(data) < 24 or len(data) % 2 or data[:2] != b'\xaa\x08': # SCI8 имеет ключ 0x08AA, 22-октетный заголовок и чётный терминатор.
raise ValueError('Неверный заголовок или размер Flash SCI8') # Обычный бинарный дамп и COFF не являются таблицей адресных блоков TI.
def address(offset): # Адрес TI состоит из старшего 16-битного слова, затем младшего; каждое слово little endian.
high, low = struct.unpack_from('<HH', data, offset) # Читает две половины без зависимости от архитектуры Python.
return (high << 16) | low # Возвращает адрес в словах C28x, а не байтовое смещение.
entry, offset, loaded, blocks = address(18), 22, {}, [] # Хранит точку запуска, позицию таблицы и неперекрывающиеся диапазоны.
while offset + 2 <= len(data): # Длина блока должна присутствовать полностью даже для завершающего нуля.
count, = struct.unpack_from('<H', data, offset) # Размер полезных данных выражен 16-битными словами.
offset += 2 # Переходит к адресу блока либо к концу таблицы.
if not count: # Нулевой размер завершает адресную таблицу без последующего поля адреса.
if offset != len(data): # Дополнительные байты после терминатора недопустимы даже при правильной CRC полезной части.
raise ValueError('Хвост после терминатора Flash SCI8') # Не принимает склеенные или частично заменённые файлы.
validate_words(entry, loaded) # До передачи проверяет CRC и отсутствие слов постоянного загрузчика.
return entry, loaded, blocks # Вызывающий использует проверенные блоки для записи либо сравнения с COFF.
if offset + 4 + 2 * count > len(data): # Адрес и все слова должны помещаться в текущем файле.
raise ValueError('Обрезанный блок Flash SCI8') # Не читает случайные байты за границей изображения.
start = address(offset) # Получает абсолютный адрес назначения очередного блока.
offset += 4 # Пропускает две половины абсолютного адреса.
if not APP_START <= start < start + count <= APP_STOP: # Сектор A и весь аппаратный boot исключены независимо от имени секции.
raise ValueError(f'Запрещённый Flash-диапазон 0x{start:06X}, {count} слов: загрузчик защищён') # Показывает точное пересечение до начала обновления.
values = struct.unpack_from('<' + 'H' * count, data, offset) # Читает только полностью проверенный блок.
if any(start + index in loaded for index in range(count)): # Дублирование адреса могло бы скрыть подмену заголовка поздним блоком.
raise ValueError('Перекрывающиеся блоки Flash SCI8') # Запрещает неоднозначный порядок программирования.
loaded.update((start + index, value) for index, value in enumerate(values)) # Сохраняет каждое слово по его физическому LOAD-адресу.
blocks.append((start, data[offset:offset + count * 2])) # Сохраняет исходные октеты без изменения порядка секций.
offset += count * 2 # Переходит к размеру следующего блока.
raise ValueError('Нет терминатора Flash SCI8') # Обрезанный файл не считается полным приложением.
def coff_sections(document): # Извлекает адреса и точные октеты из результата TI ofd2000 -x.
coff = document.find('.//ti_coff') # Рабочая прошивка F28335 использует COFF ABI.
if coff is None or coff.findtext('file_header/target_id') != '0x9d' or coff.findtext('file_header/exec') != 'true': # Проверяет процессор и признак связанного исполняемого файла.
raise ValueError('Требуется исполняемый COFF C2000') # Объектные модули и файлы другой архитектуры не программируются.
entry = int(coff.findtext('optional_file_header/entry_point', '0'), 0) # Entry задаёт запуск приложения через общий Flash boot.
sections, loaded = {}, {} # Отдельно хранит XML полей секций и адресное пространство программируемых слов.
for section in coff.findall('section'): # Рассматривает все секции, включая LOAD для функций, исполняемых из RAM.
sections[section.findtext('name')] = section # Служебный заголовок находится по стабильному имени .flash_app.
raw, start = section.findtext('raw_data'), int(section.findtext('virtual_addr', '0'), 0) # OFD называет virtual адресом LOAD, physical — RUN.
if raw is None or section.findtext('copy') == 'true': # Только явно незагружаемые COPY/отладочные секции и секции без данных исключаются из программирования.
continue # Их наличие не расширяет область разрешённого программирования.
payload = base64.b64decode(raw) # Получает точные октеты вместо доверия к соседней MAP.
if len(payload) != int(section.findtext('raw_data_size', '0'), 0) or len(payload) % 2 or not APP_START <= start < start + len(payload) // 2 <= APP_STOP: # Любая загружаемая секция обязана целиком находиться ниже A; RAM, OTP, CSM и несогласованный размер запрещены.
raise ValueError('COFF содержит запрещённую Flash-секцию: ' + str(section.findtext('name'))) # Старый самостоятельный codestart будет отклонён.
for index, value in enumerate(struct.unpack('<' + 'H' * (len(payload) // 2), payload)): # Восстанавливает 16-битное представление каждого слова C28x.
if start + index in loaded: # Две секции не должны программировать одно физическое слово.
raise ValueError('Перекрывающиеся LOAD-секции COFF') # Сохраняет единственность заголовка и однозначную контрольную сумму.
loaded[start + index] = value # Собирает ожидаемый Flash-образ для проверки CRC и конверсии SCI8.
return entry, loaded, sections # Метаданные нужны как подписанию, так и независимой проверке готового OUT.
def seal_coff(image, inspector): # Заполняет только заранее выделенный linker заголовок после успешной линковки.
image = Path(image) # Принимает путь текущей сборки без зависимости от рабочего каталога.
document = ET.fromstring(subprocess.check_output([str(inspector), '-x', str(image)])) # Доверяет размещению секций, подтверждённому официальным TI-инспектором.
entry, loaded, sections = coff_sections(document) # Отсекает любой LOAD в сектор A до изменения двоичного файла.
section = sections.get('.flash_app') # Секция размером 16 слов резервируется linker и не меняет адресов остальной программы.
if section is None or int(section.findtext('virtual_addr', '0'), 0) != APP_START or int(section.findtext('raw_data_size', '0'), 0) != HEADER_WORDS * 2: # Нельзя добавлять заголовок поверх машинного кода.
raise ValueError('Linker должен выделить .flash_app из 16 слов по адресу 0x300000') # Ошибка требует пересборки вместо небезопасного смещения программы.
offset = int(section.findtext('file_offsets/raw_data_ptr', '0'), 0) # OFD указывает точное файловое смещение содержимого секции COFF.
raw, signed = bytearray(image.read_bytes()), descriptor(entry, loaded) # Считает CRC уже окончательно связанных Flash LOAD-секций.
if offset <= 0 or offset + len(signed) > len(raw): # Проверяет размер файла до адресной записи заголовка.
raise ValueError('Неверное смещение заголовка COFF') # Повреждённые метаданные не могут изменить постороннюю часть OUT.
raw[offset:offset + len(signed)] = signed # Меняет только зарезервированные 32 октета, не трогая символы, адреса и отладочную информацию.
image.write_bytes(raw) # Сохраняет подписанный результат до конверсии в SCI8 и создания внешнего SHA-256.
loaded.update((APP_START + index, value) for index, value in enumerate(struct.unpack('<16H', signed))) # Приводит адресную модель к записанному заголовку.
validate_words(entry, loaded) # Независимо проверяет только что сформированный результат.
+11
View File
@@ -136,14 +136,25 @@ MSGTYPE_RU = {
#: но не присваивает приборам имён.
DEVICE_TYPE_NAMES = {
0x0: "Верхний уровень",
0x1: "PERIPH 0–15", # Excel «Интерфейс протокола», C6/F6: первый тип содержит младшие 16 номеров периферии.
0x2: "PERIPH 16–31", # Excel C7/F7: второй тип продолжает нумерацию, сохраняя четырёхбитный Device ID.
0x6: "Логические анализаторы",
}
DEVICE_ADDRESS_NAMES = {
(0x1, 0x5): "УМП1", # Excel B50/C50/F50 закрепляет отдельный адрес первого модуля нагрузки.
(0x1, 0x6): "УМП2", # Excel B51/C51/F51 отделяет второй модуль от первого и от задатчика.
(0x1, 0x8): "Задатчик", # Excel B53/C53/F53 задаёт адрес задатчика без сдвига старого CAN Mode на единицу.
(0x6, 0xE): "Altera Logic",
(0x7, 0xD): "configurator",
}
def periph_address(periph_id: int) -> tuple[int, int]: # Возвращает адрес ProtoCAN для номера из 32 строк обновлённой таблицы PERIPH.
if not 0 <= periph_id < 32: # Число 32 задаёт полный диапазон двух типов по 16 экземпляров; за его пределами адрес не назначается.
raise ValueError("ID PERIPH должен быть в диапазоне 0…31") # Запрещает незаметное оборачивание ошибочного номера на другую плату.
return 1 + periph_id // 16, periph_id % 16 # Старшая группа выбирает Type 1/2, младшие четыре бита — Device ID 0…15.
SETTINGS_RESULT_RU = {
SettingsResult.OK: "успех",
SettingsResult.INVALID_DLC: "неверный DLC",
+28
View File
@@ -0,0 +1,28 @@
import ctypes # Привязка передаёт готовые слова общему C-кодеку без повторения wire-формата на Python.
from .native import _load_library # Стандартный поиск сохраняет существующий механизм доставки SETProtocol.
class StmInvertorCodec: # Один экземпляр владеет только сигнатурами функций общей библиотеки.
def __init__(self, library=None): # Явная DLL позволяет новой панели не заменять библиотеку работающего приложения.
self.lib = library if library is not None else _load_library() # Нет Python fallback при отсутствии нового C-кодека.
self.lib.set_invertor_gas_limit.argtypes = [ctypes.c_uint] # Тип сообщения передаётся без вычисления геометрии карты в Python.
self.lib.set_invertor_gas_limit.restype = ctypes.c_uint # Границы определяются профилем C из Templates.
self.lib.set_invertor_gas_build.argtypes = [ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint16), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint8)] # Типы совпадают с публичным C ABI общего адаптера.
self.lib.set_invertor_gas_build.restype = ctypes.c_int # C возвращает фактический DLC либо отрицательную ошибку.
self.lib.set_invertor_gas_decode.argtypes = [ctypes.c_uint32, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint8), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint16)] # Декодер проверяет ID, тип, направление, длину и маску.
self.lib.set_invertor_gas_decode.restype = ctypes.c_int # Число принятых wire-слов приходит только из C.
def limit(self, message_type): # Интерфейс не хранит отдельную копию константы 250 или 750.
return int(self.lib.set_invertor_gas_limit(message_type)) # Передаём актуальную границу виджету адреса.
def build(self, message_type, address, words=()): # Пустой набор слов означает чтение DLC0 в обоих протоколах.
if not 0 <= address <= 0xFFFF or len(words) > 4 or any(not 0 <= word <= 0xFFFF for word in words): # Проверяем представимость Python-чисел перед сужением до типов ABI.
raise ValueError('Адрес или значение вне диапазона ABI') # Не допускаем тихого усечения и циклического переноса ctypes.
source = (ctypes.c_uint16 * len(words))(*words) if words else None # Значения остаются словами, endian выбирает общий кодек.
identifier = ctypes.c_uint32(); data = (ctypes.c_uint8 * 8)() # Локальные буферы имеют размеры Classic CAN и не переживают вызов.
length = self.lib.set_invertor_gas_build(message_type, address, source, len(words), ctypes.byref(identifier), data) # Ни CAN ID, ни маска не строятся здесь вручную.
if length < 0: # C отклоняет неверную геометрию, длину блока и маску.
raise ValueError('GAS: неверный адрес, длина блока или маска Word0') # Пользователь видит причину отказа до передачи на шину.
return identifier.value, bytes(data[:length]) # Транспорт получает полностью готовые ID и октеты.
def decode(self, identifier, flags, payload): # Входной кадр проверяется до публикации значений интерфейсу.
if not 0 <= identifier <= 0x1FFFFFFF or len(payload) > 8: # Не допускаем сужения большого CAN ID и переполнения временного буфера.
return None # Кадры другого транспорта не считаются ответом инвертора.
data = (ctypes.c_uint8 * 8)(*payload); metadata = (ctypes.c_uint * 2)(); words = (ctypes.c_uint16 * 4)() # ABI работает с фиксированными массивами без указателей на объекты Python.
count = self.lib.set_invertor_gas_decode(identifier, flags, data, len(payload), metadata, words) # Wire-разбор остаётся единственной C-реализацией.
return (metadata[0], metadata[1], tuple(words[:count])) if count > 0 else None # Недопустимый кадр не изменяет отображённые значения.
+52
View File
@@ -0,0 +1,52 @@
"""Готовые операции DEIF для GUI без знания wire-разметки и адресных масок.""" # Все числовые правила реализованы единственным C99-ядром templates.
import ctypes as C # Преобразование фиксированных ABI-структур не является вторым кодеком.
from dataclasses import dataclass # Именованные поля исключают индексы метаданных в GUI.
from .deif_native import get_deif_native, Frame # Общий декодер и builders используются независимо от приложения.
from .can_transport import build_frame # Транспорт получает уже готовые CAN-кадры.
@dataclass(frozen=True) # Неизменяемые описания безопасно используются диагностикой и редакторами.
class SelectorInfo: # Это скалярный результат C, а не wire-структура.
node: int; index: int; subindex: int; dam: bool; legacy_key: str # Последний элемент сохраняет только формат прежних ключей настроек Desktop.
@dataclass(frozen=True) # Описание входного кадра отделено от его транспортного контейнера.
class PacketInfo: # GUI не обращается к смещениям payload.
kind: int; node: int; index: int; subindex: int; raw: int; status: int; dam: bool # Все значения получены из C-декодера.
@property # Диагностический ключ независим от адресного байта SAM/DAM.
def object_key(self): # Для TX-эхо достаточно индекса и подиндекса готового объекта.
return self.index, self.subindex # Это идентичность объекта, а не повторная распаковка CAN-data.
def selector_info(selector): # Редактор получает имена полей вместо ручных масок.
raw = bytes.fromhex(selector) if isinstance(selector, str) else bytes(selector) # HEX — только формат хранения пользовательской настройки.
if len(raw) != 4: return None # Буфер неправильного размера не передаётся C.
metadata = (C.c_int32 * 4)() # Node, index, subindex и DAM определены публичным ABI.
if not get_deif_native().selector_info(raw, metadata): return None # Некорректная привязка не становится источником на рисунке.
return SelectorInfo(metadata[0], metadata[1], metadata[2], bool(metadata[3]), raw[1:].hex()) # Срез используется только для совместимости непрозрачного старого ключа настроек.
def readdress_selector(selector, node, dam=None): # Меняет адрес пользовательской привязки общим алгоритмом C.
if not selector: return "" # Явно отключённое поле остаётся отключённым.
raw = bytes.fromhex(selector) if isinstance(selector, str) else bytes(selector) # Порт преобразует текст настройки в буфер ABI.
output = (C.c_uint8 * 4)() # Готовый селектор создаёт C-код.
if len(raw) != 4 or not get_deif_native().readdress(raw, node, 2 if dam is None else int(bool(dam)), output): raise ValueError("Неверный селектор или Node-ID DEIF") # Индекс не пересобирается в Python.
return bytes(output).hex(" ").upper() # Формат вывода совместим с прежним редактором HEX.
def route_parameters(node, mode, identifier, operation=0): # Согласует параметры редактора с C-правилами адресации.
output = (C.c_uint32 * 3)() # COB-ID, Node и адресный байт возвращаются одним вызовом.
if not get_deif_native().route(node, mode == "dam", identifier, operation, output): return None # Неверный диапазон не вызывает произвольную перенастройку привязок.
return tuple(output) # GUI хранит и отображает готовые значения.
def data_node(identifier, fallback=1): # Получает узел из CAN ID без повторения маски в GUI.
result = route_parameters(fallback or 1, "sam", identifier, 2) # Legacy SAM сохраняет явно выбранный источник.
return result[1] if result else fallback # Вне диапазона данных остаётся пользовательское исходное значение.
def packet_info(frame): # Метаданные для подтверждения команд и TX-эхо.
code, metadata = get_deif_native().inspect_frame(frame.can_id, frame.data, frame.ide, frame.rtr) # Wire-разбор находится в C.
return PacketInfo(metadata[0], metadata[1], metadata[2], metadata[3], metadata[4], metadata[7], bool(metadata[8])) if code > 0 else None # Некорректный кадр не получает выдуманных полей.
def heartbeat_description(identifier, data, extended=False, rtr=False): # Приложение не содержит собственного классификатора CANopen error control.
code, _ = get_deif_native().inspect_frame(identifier, data, extended, rtr) # Проверку COB-ID, DLC и состояния выполняет C.
if code not in (2, -3): return None # Обычные MPDO не попадают в поле heartbeat.
from .deif_xdi import decode # Общий локализованный formatter используется обоими CAN-мониторами и прибором.
return decode(build_frame(identifier, bytes(data), ide=extended, rtr=rtr)).summary # Готовая подпись не подтверждает получение конкретного измерения.
def _frame(output): # Общий адаптер переводит структуру C в существующий транспортный контейнер.
return build_frame(output.can_id, bytes(output.data[:output.dlc]), ide=False) # Идентификатор, длина и данные уже вычислены ядром.
def service_frame(node, value, nmt=False): # Строит heartbeat либо локальную NMT-команду через C.
output = Frame() # Память результата принадлежит Python-порту.
if not get_deif_native().service(bool(nmt), node, value, C.byref(output)): raise ValueError("Неверный Node-ID или CANopen-сервис") # Ни COB-ID, ни служебные байты не собираются в GUI.
return _frame(output) # Физическую отправку выполняет выбранный приложением транспорт.
def angle_setpoint_frame(actual): # Формирует отдельный кадр задания с тем же значением фактического азимута.
source, output = Frame(), Frame() # Оба контейнера имеют стабильный layout ABI.
source.can_id, source.dlc, source.data[:] = actual.can_id, len(actual.data), bytes(actual.data) # Это копирование готового CAN-кадра, не декодирование его полей.
if not get_deif_native().angle_command(C.byref(source), C.byref(output)): raise ValueError("Кадр не содержит поддерживаемый азимут DEIF") # Проверку семейства и сборку 0x07 выполняет C.
return _frame(output) # Штатный передатчик получает второй независимый MPDO.
+129
View File
@@ -0,0 +1,129 @@
"""Независимая от GUI Python-модель индикатора поверх общего C99-ядра DEIF.""" # Настройки и транспортные имена остаются в порту, wire-правила находятся в C.
import ctypes as C # Обмен с ABI использует фиксированные массивы и caller-owned память.
from dataclasses import dataclass # Привязка является простой моделью пользовательской настройки.
from time import monotonic # Порт передаёт часы ядру явно в миллисекундах.
from .deif_native import get_deif_native, Frame # Единственная реализация кодека и автомата загружается без Qt.
FIELDS = ("power", "rpm", "angle", "power_percent", "rpm_percent", "angle_setpoint") # Порядок полей публичного ABI C; это имена модели, не wire-селекторы.
STATUS = {1: "ниже диапазона", 2: "выше диапазона", 3: "ошибка данных"} # Локализованные подписи готовых статусов C.
@dataclass # Привязки можно сохранять в существующих пользовательских профилях JSON.
class Binding: # Четыре HEX-байта и коэффициент поступают из настроек либо C-каталога.
selector: str = "" # Пустая строка означает явно отключённое поле.
scale: float = 1.0 # Коэффициент применяется только в общем C-ядре.
def _milliseconds(now): # Перевод единиц системных часов относится к платформенному порту.
return max(0, round((monotonic() if now is None else now) * 1000)) # ABI использует целые монотонные миллисекунды для одинаковых границ на всех платформах.
def validate_deif_transmit(frame): # Сохраняет прежний публичный Python API ограниченного маршрута MASTER.
if not get_deif_native().validate(frame.can_id, bytes(frame.data), len(frame.data), int(bool(frame.ide)) | (int(bool(frame.rtr)) << 1), 0): # Проверка адреса, объектов и размеров полностью выполняется в C.
raise ValueError("DEIF: разрешены только данные, яркость, Color и heartbeat; команды настройки через Node-ID запрещены") # Текст остаётся локализованной ответственностью приложения.
def brightness_frame(node_id, group, percent, can_id=None, mode="sam"): # Готовит один кадр без отправки или изменения прибора.
return get_deif_native().make_frame(6, node_id, percent, 0 if can_id is None else can_id, mode, group) # Поле ABI 6 обозначает яркость, исходный COB-ID и U8 определяет C.
def colour_palette_frame(node_id, group, palette, can_id=None, mode="sam"): # Готовит независимый от яркости кадр палитры.
return get_deif_native().make_frame(7, node_id, palette, 0 if can_id is None else can_id, mode, group) # Поле ABI 7 обозначает Color; исходный COB-ID и кодирование Day/Night/Dusk находятся в C.
class DeifDisplay: # Пассивная модель пригодна для Desktop, CLI и тестов без Qt.
services = False # Наблюдатель не объявляет себя узлом CAN и никогда не выдаёт heartbeat.
expiry_ms = 5000 # Прежний режим наблюдения скрывает показания после пяти секунд без обновления.
def __init__(self, source="DevBoard", can_id=0x201, power_unit="RAW", device_node=None, bindings=None, xdi_net=True, self_start=True, dam_pdo=2, dimmer_group=1, source_node=1): # Дополнительные параметры передаются серверным портом, прежние профили остаются совместимыми.
self.source, self.can_id, self.power_unit, self.device_node = source, can_id, power_unit, device_node # Только имя транспорта и UI-единица остаются вне C.
self.xdi_net, self.self_start, self.dam_pdo, self.dimmer_group, self._source_node = xdi_net, self_start, dam_pdo, dimmer_group, source_node # Параметры профиля передаются C без копии правил протокола.
self._api, self._context, self._route, self._bindings_signature = get_deif_native(), None, None, None # Один контекст на прибор исключает смешивание показаний MASTER и SLAVE.
self.bindings = {} # Контейнер поддерживает прямое редактирование существующим диалогом привязок.
self._ensure_context() # Исходные селекторы и коэффициенты создаются единственным C-каталогом.
if bindings is None: # Новый профиль получает штатные привязки, включая азимут.
for index, key in enumerate(FIELDS[:5]): # Отдельное задание угла автоматически производно от привязки азимута в C.
selector, factor = (C.c_uint8 * 4)(), C.c_double() # Порт принимает готовые значения без разбора endian.
self._api.get_binding(self._context, index, selector, C.byref(factor)) # Настройки по умолчанию читаются из C-профиля.
self.bindings[key] = Binding(bytes(selector).hex(" ").upper(), factor.value) # HEX нужен только редактору настроек.
else: # Существующая библиотека сохраняет пользовательские объекты и масштабы.
self.bindings = {key: Binding(item.selector, item.scale) for key, item in bindings.items()} # Словари двух приборов не должны делить изменяемые Binding.
def _ensure_context(self): # Изменение адреса требует нового C-контекста, а смена имени транспорта — нет.
route = (self.can_id, self.device_node, self.xdi_net, self.self_start, self.dam_pdo, self.dimmer_group, self.services, self._source_node) # Изменение источника также требует согласованной переинициализации.
if route == self._route: # Повторное чтение снимка не переинициализирует данные.
return # Сохраняет сроки и очередь работающего узла.
size = self._api.state_size() # Размер private C-структуры сообщает библиотека.
self._context = (C.c_uint64 * ((size + 7) // 8))() # uint64 обеспечивает необходимое выравнивание на Windows и ARM.
source = self._source_node # Источник профиля не вычисляется из имени GUI или физического Node-ID SLAVE.
if not self._api.init(self._context, size, self.device_node or 0, source, bool(self.xdi_net), bool(self.self_start), self.dam_pdo, self.dimmer_group, bool(self.services), self.can_id): # C проверяет reserved Node, RPDO и сетевые группы.
raise ValueError("DEIF: неверный профиль SLAVE, Node-ID или RPDO") # Частично инициализированный узел не используется.
self._route, self._bindings_signature = route, None # Привязки повторно синхронизируются после изменения профиля.
def _sync_bindings(self): # Прямые изменения словаря UI передаются в C только при фактическом изменении настройки.
self._ensure_context() # Проверяет, не был ли изменён физический адрес либо CAN ID наблюдения.
signature = tuple((key, binding.selector, binding.scale) for key, binding in self.bindings.items()) # Это сравнение настроек, а не интерпретация wire-формата.
if signature == self._bindings_signature: # Частые тики и приём кадров не должны сбрасывать значения.
return # Состояние C остаётся прежним.
for index, key in enumerate(FIELDS): # Порядок соответствует публичным field IDs ядра.
if index == 5 and key not in self.bindings: # Производное задание азимута настраивает C при изменении поля angle.
continue # Не отключает автоматически созданную независимую привязку 0x07.
binding = self.bindings.get(key, Binding()) # Отсутствующее поле явно отключается.
selector = bytes.fromhex(binding.selector) if binding.selector else None # HEX является форматом хранения настроек, а не CAN-декодером.
if selector is not None and len(selector) != 4: # Порт проверяет размер буфера до передачи указателя C.
raise ValueError("Селектор DEIF должен содержать четыре байта") # Некорректная пользовательская настройка не создаёт чтение за пределами буфера.
if not self._api.bind(self._context, index, selector, binding.scale): # Интерпретация селектора и применение масштаба находятся в C.
raise ValueError("DEIF: неверная привязка или масштаб") # Нечисловой коэффициент не скрывается как отсутствие пакета.
self._bindings_signature = signature # Последующая обработка кадра использует уже настроенный контекст.
def bind_default_angle(self): # Миграция старого профиля с пустым азимутом не требует знания адресов в GUI.
self._sync_bindings() # Источник и режим берутся из уже настроенных остальных полей.
selector, factor = (C.c_uint8 * 4)(), C.c_double() # C возвращает готовую библиотечную привязку.
if not self._api.default_angle(self._context, selector, C.byref(factor)): # Ни SAM/DAM, ни индекс не вычисляются в Python.
raise ValueError("Не удалось восстановить привязку азимута DEIF") # Ошибка миграции остаётся видимой.
self.bindings["angle"] = Binding(bytes(selector).hex(" ").upper(), factor.value) # Обновляется только отсутствующая настройка азимута.
self._sync_bindings() # Новая привязка применяется сразу в текущем сеансе.
def reset(self): # Сохраняет API очистки показаний при смене пользовательского канала.
self._sync_bindings() # Настройки должны соответствовать очищаемому контексту.
self._api.reset_values(self._context) # Очистка выполняется общим ядром.
def accept(self, source, identifier, data, extended=False, rtr=False, now=None): # Порт связывает транспортное имя с независимой моделью прибора.
if source != self.source or not 0 <= identifier <= 0x1FFFFFFF or len(data) > 8: # Имя канала относится к приложению, границы — к безопасному ABI.
return False # Кадры другого транспорта не обновляют C-контекст.
self._sync_bindings() # Диалог мог изменить привязки с момента предыдущего кадра.
accepted = bool(self._api.accept(self._context, identifier, bytes(data), len(data), int(bool(extended)) | (int(bool(rtr)) << 1), _milliseconds(now))) # Единственное место протокольной обработки находится в C.
return accepted and (self.services or not self._api.inspect_frame(identifier, data, extended, rtr)[1][7]) # Пассивный API сохраняет прежний признак отказа только для текущего ошибочного I16, не для других полей.
def _snapshots(self, now=None): # Снимок переводит готовые C-поля в контейнеры Python без повторения алгоритма.
self._sync_bindings() # Требуется также после прямого редактирования масштаба в настройках.
result, moment = {}, _milliseconds(now) # Все поля одного снимка читаются в один момент времени.
for index, key in enumerate(FIELDS): # Порядок ABI не зависит от порядка ключей пользовательского словаря.
value, metadata = C.c_double(), (C.c_int64 * 4)() # C выдаёт масштабированное значение и флаги seen/valid/stale.
self._api.read(self._context, index, moment, self.expiry_ms, C.byref(value), metadata) # Свежесть и служебные статусы вычисляются ядром.
result[key] = (value.value, metadata[0] / 1000, metadata[2], metadata[3]) # Деление переводит только единицы системных часов порта.
return result # Снимок не раскрывает внутренний layout C.
@property # Совместимый read-only снимок прежнего словаря показаний.
def values(self): # Значения остаются в C, Python не хранит вторую копию состояния.
return {key: item[0] for key, item in self._snapshots().items() if item[3] & 2} # Готовый valid-флаг исключает служебные I16.
@property # Временные отметки нужны существующей отрисовке и диагностике.
def received(self): # Даже invalid является принятым обновлением объекта, а не отсутствием связи.
return {key: item[1] for key, item in self._snapshots().items() if item[3] & 1} # Решение о наличии сообщения уже принято C-ядром.
def value(self, key, now=None): # Сохраняет интерфейс получения одного свежего показания.
item = self._snapshots(now).get(key) # Масштаб и свежесть получены из общего C-снимка.
return item[0] if item and item[3] & 2 and not item[3] & 4 else None # Python лишь отображает valid/stale флаги ABI.
def angle_selection(self, now=None): # Одно правило азимута используется и пассивным монитором, и активным SLAVE.
self._sync_bindings() # Профиль мог быть загружен из старого пользовательского файла.
value = C.c_double() # Владение выходным скаляром остаётся у порта.
kind = self._api.angle(self._context, _milliseconds(now), self.expiry_ms, C.byref(value)) # C выбирает фактический угол либо принятое сетевое задание.
return (value.value if kind else None, kind == 2) # Второй элемент нужен только для подписи «задание» в GUI.
@property # Список источников нужен только заголовку прибора, не алгоритму приёма.
def sam_sources(self): # UI получает готовые адреса без масок и смещений селектора.
from .deif_controls import selector_info # Разбор адреса селектора делегируется общему C-порту.
descriptors = [selector_info(binding.selector) for binding in self.bindings.values() if binding.selector] # Сохраняет индивидуальные привязки каждого поля.
return sorted({item.node for item in descriptors if item is not None and not item.dam}) # DAM-получатель не выдаётся за источник SAM.
+36
View File
@@ -0,0 +1,36 @@
"""Регистровый контракт аппаратного эмулятора DEIF из set_emu_deif.h.""" # Один контракт используется всеми MCU и настольными клиентами.
from dataclasses import dataclass # Неизменяемый снимок отделяет подтверждённое состояние платы от редактируемой формы.
DEIF_BASE = 0x1600 # Начало окна управления DEIF в set_emu_server.c, адрес измеряется в 16-битных словах.
DEIF_WORDS = 52 # SET_DEIF_WORDS версии 1 включает профиль, RAW, привязки OD и диагностику.
DEIF_FIELDS = ("Угол, RAW", "Обороты, RAW", "Обороты %, RAW", "Мощность, RAW", "Мощность %, RAW", "Задание угла, RAW") # Порядок соответствует raw[6] переносимой C-модели; физические единицы зависят от словаря прибора.
DEIF_INDICES = (0x3001, 0x3081, 0x3091, 0x3421, 0x3431, 0x3001) # Начальные OD экземпляра 1 взяты из set_deif_init; последний объект имеет подиндекс 7.
@dataclass(frozen=True) # Клиент публикует только целиком проверенный снимок без последующих изменений.
class DeifSnapshot: # Тип не зависит от Qt, HAL, конкретного MCU и способа доставки регистров.
words: tuple # Содержит 52 беззнаковых слова, прочитанных одной командой FC03.
mode: int # Режим из конфигурации STM: 4 Master, 5 Slave либо иной остановленный режим.
@property # Роль берётся из ответа платы, а не из ранее выбранного пункта GUI.
def role(self): # Возвращает 1 Master либо 2 Slave согласно set_emu_deif.h.
return self.words[3] # Слово 3 не совпадает с номерами режимов 4 и 5 блока STM.
@property # Проверка запущенного состояния нужна для запрета изменения сетевого профиля.
def running(self): # Возвращает логическое состояние модели без запуска либо остановки.
return bool(self.words[2]) # Слово 2 равно 1 только после подтверждённой команды пуска.
@property # Знаковое представление сохраняет отрицательные RAW и служебные коды DEIF.
def raw(self): # Возвращает шесть значений I16 в порядке DEIF_FIELDS.
return tuple(value if value < 0x8000 else value - 0x10000 for value in self.words[16:22]) # Старший бит задаёт знак 16-битного дополнения до двух.
def decode_snapshot(words, mode): # Проверяет совместимость прошивки до разрешения любых записей DEIF.
values = tuple(words) # Не сохраняет ссылку на изменяемый транспортный буфер.
if len(values) != DEIF_WORDS or values[:2] != (0x4446, 1): # Сигнатура DF и версия 1 заданы set_deif_read, не совпадают с конфигурацией STM.
raise ValueError("Нужна прошивка с сервисом DEIF Master/Slave версии 1") # Старая прошивка не получает команд в неизвестное окно.
if values[2] not in (0, 1) or values[3] not in (1, 2): # Повреждённые running и role не должны превращаться в разрешение записи.
raise ValueError("STM вернула неверное состояние DEIF") # Отказ сохраняет форму неподтверждённой.
if mode in (4, 5) and values[3] != mode - 3: # Конфигурация и окно состояния должны описывать одну роль текущего экземпляра.
raise ValueError("Роль DEIF не совпадает с режимом STM; повторите чтение") # Между чтениями другая сессия могла изменить режим платы.
return DeifSnapshot(values, mode) # Единственная точка создания проверенного снимка для UI и тестов.
+75
View File
@@ -0,0 +1,75 @@
"""Тонкий ctypes-порт единственного C99-ядра DEIF из templates/c/set-protocol.""" # В Python нет второго кодека или автомата NMT.
import ctypes as C # Фиксированные типы повторяют только публичный ABI.
import os # Путь дополнительной библиотеки передаёт приложение, не общая библиотека.
from pathlib import Path # Дополнительный нативный компонент может лежать рядом с основной библиотекой любого приложения.
from protocan.native import _load_library # Стандартный механизм загрузки используется при отсутствии явного пути DEIF.
class Frame(C.Structure): # Структура содержит готовые STD-кадры, а не wire-структуру транспорта.
_fields_ = [("can_id", C.c_uint32), ("dlc", C.c_uint32), ("data", C.c_uint8 * 8)] # Layout размером 16 байт совпадает с deif_xdi.h.
class NativeDeif: # Экземпляр владеет только сигнатурами C, состояние выделяется отдельными моделями.
def __init__(self): # Отсутствие общего ядра является ошибкой сборки, алгоритмического fallback нет.
self.lib = C.CDLL(os.environ["SETPROTOCOL_DEIF_LIBRARY"]) if os.environ.get("SETPROTOCOL_DEIF_LIBRARY") else _load_library() # Выбор пути относится к загрузке, не к протоколу.
if not hasattr(self.lib, "setp_deif_version"): # Во время обновления основная DLL может оставаться занятой работающим приложением.
primary = Path(self.lib._name) # Путь сообщает стандартный загрузчик, структура gui_desktop здесь неизвестна.
self.lib = C.CDLL(str(primary.with_name(primary.stem + "_deif" + primary.suffix))) # Это дополнительная сборка того же C-ядра, а не алгоритмический Python fallback.
signatures = { # Сигнатуры скалярного ABI не зависят от внутренних C-структур.
"version": ([], C.c_int), "state_size": ([], C.c_size_t), # Проверка версии и caller-owned памяти.
"catalog": ([C.c_uint, C.POINTER(C.c_int32)], C.c_int), # Каталог числовых индексов и разрешений.
"selector": ([C.c_uint, C.c_uint, C.c_uint, C.c_uint, C.c_void_p], C.c_int), # C формирует готовый селектор.
"inspect": ([C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.POINTER(C.c_int32)], C.c_int), # Единственный разбор входящего wire-кадра.
"make": ([C.c_uint, C.c_uint, C.c_uint, C.c_int32, C.c_uint32, C.c_uint, C.POINTER(Frame)], C.c_int), # Единственная сборка MPDO.
"master": ([C.c_uint, C.POINTER(C.c_int32), C.c_uint32, C.c_uint, C.c_uint, C.POINTER(Frame)], C.c_int), # Единственный порядок пяти объектов цикла.
"service": ([C.c_uint, C.c_uint, C.c_uint, C.POINTER(Frame)], C.c_int), "angle_command": ([C.POINTER(Frame), C.POINTER(Frame)], C.c_int), # Служебные кадры и задание азимута собираются общим C-кодом.
"selector_info": ([C.c_void_p, C.POINTER(C.c_int32)], C.c_int), "route": ([C.c_uint, C.c_uint, C.c_uint32, C.c_uint, C.POINTER(C.c_uint32)], C.c_int), # Редакторы не разбирают адресный байт или COB-ID самостоятельно.
"readdress": ([C.c_void_p, C.c_uint, C.c_uint, C.c_void_p], C.c_int), # Перенос селектора сохраняет ручной объект без масок и endian в UI.
"validate": ([C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.c_uint], C.c_int), # Изолированные выходы MASTER и SLAVE.
"init": ([C.c_void_p, C.c_size_t] + [C.c_uint] * 7 + [C.c_uint32], C.c_int), # Семь параметров профиля и отдельный CAN ID наблюдателя.
"bind": ([C.c_void_p, C.c_uint, C.c_void_p, C.c_double], C.c_int), # Селектор и библиотечный масштаб передаются без интерпретации.
"get_binding": ([C.c_void_p, C.c_uint, C.c_void_p, C.POINTER(C.c_double)], C.c_int), # Исходные значения профиля приходят из единственного C-каталога.
"default_angle": ([C.c_void_p, C.c_void_p, C.POINTER(C.c_double)], C.c_int), # Общая миграция отсутствующей привязки.
"start": ([C.c_void_p, C.c_uint64, C.c_uint], C.c_int), "stop": ([C.c_void_p], None), "reset_values": ([C.c_void_p], None), # Жизненный цикл без системных часов C.
"accept": ([C.c_void_p, C.c_uint32, C.c_void_p, C.c_size_t, C.c_uint, C.c_uint64], C.c_int), # C проверяет NMT, адресацию, объекты и длину.
"poll": ([C.c_void_p, C.c_uint64, C.POINTER(Frame), C.c_size_t], C.c_size_t), # Порт владеет выходным буфером событий.
"read": ([C.c_void_p, C.c_uint, C.c_uint64, C.c_uint, C.POINTER(C.c_double), C.POINTER(C.c_int64)], C.c_int), # Готовое значение и временные метаданные индикатора.
"lost": ([C.c_void_p, C.c_uint64], C.c_uint), # Маска устаревших входов вычисляется в C.
"angle": ([C.c_void_p, C.c_uint64, C.c_uint, C.POINTER(C.c_double)], C.c_int), "meta": ([C.c_void_p, C.POINTER(C.c_int64)], C.c_int), # Единый выбор азимута и снимок состояния.
} # Эти объявления описывают ABI, а не копию алгоритма.
for name, (arguments, result) in signatures.items(): # Все отсутствующие символы выявляются при загрузке расширения.
function = getattr(self.lib, "setp_deif_" + name) # Названия совпадают с публичным заголовком templates.
function.argtypes, function.restype = arguments, result # ctypes не должен угадывать размеры указателей и целых.
setattr(self, name, function) # Короткие имена оставляют порты моделей читаемыми.
if self.version() != 1: # Несовместимый ABI не используется с предположением о layout.
raise RuntimeError("Нужна общая библиотека DEIF ABI 1") # Сообщение указывает на необходимость пересборки нативного ядра.
def selector_bytes(self, field, node, instance=1, dam=False): # Возвращает готовый C-селектор для настроек UI.
output = (C.c_uint8 * 4)() # Размер мультиплексора задан публичным контрактом.
if not self.selector(field, node, instance, bool(dam), output): # Проверка диапазонов находится в C.
raise ValueError("DEIF: неверный Node-ID, объект или экземпляр") # Недопустимая привязка не сохраняется в настройках.
return bytes(output) # Порт преобразует только тип контейнера.
def inspect_frame(self, identifier, data, extended=False, rtr=False): # Передаёт целый CAN-кадр общему декодеру.
if not 0 <= identifier <= 0x1FFFFFFF or len(data) > 8: # Проверяет представимость на границе ABI до сужения Python-чисел.
return -1, [0] * 12 # Переполнение нельзя превратить в другой допустимый кадр.
output = (C.c_int32 * 12)() # Индексы метаданных определены в контракте C-декодера.
code = self.inspect(identifier, bytes(data), len(data), int(bool(extended)) | (int(bool(rtr)) << 1), output) # Флаги относятся к типу CAN-кадра, не к содержимому протокола.
return code, list(output) # Локализованный текст формируется поверх готовых полей C.
def make_frame(self, field, node, value, identifier, mode="sam", instance=1): # Возвращает кадр существующего транспортного API.
from .can_transport import build_frame # Пакет транспорта не импортируется до фактической сборки кадра.
if mode not in ("sam", "dam") or type(instance) is not int or not 0 <= instance <= 4294967295 or type(value) is not int or not -2147483648 <= value <= 2147483647 or type(node) is not int or not 0 <= node <= 127 or type(identifier) is not int or not 0 <= identifier <= 0x1FFFFFFF: # Порт проверяет только типы и представимость ABI.
raise ValueError("DEIF: неверный Node-ID, режим, CAN ID или значение") # Значения не усекаются до вызова C.
output = Frame() # Память выходного CAN-кадра принадлежит Python.
if not self.make(field, node, instance, value, identifier, mode == "dam", C.byref(output)): # Wire-байты и границы протокола определяются исключительно C.
raise ValueError("DEIF: Node-ID, RPDO, экземпляр или значение вне диапазона") # Причина отклонения остаётся видимой в интерфейсе.
return build_frame(output.can_id, bytes(output.data[:output.dlc]), ide=False) # Транспорт AA55/адаптер получает уже готовые данные STD.
_native = None # Общая библиотека загружается один раз на процесс.
def get_deif_native(): # Ленивое получение не требует Qt и пригодно для CLI.
global _native # Экземпляр хранит сигнатуры, состояния приборов остаются раздельными.
if _native is None: # Первая реальная операция DEIF проверяет наличие нового нативного ядра.
_native = NativeDeif() # Отсутствие DLL или ABI не скрывается Python-реализацией протокола.
return _native # Все модели используют один и тот же проверенный C-код.
+56
View File
@@ -0,0 +1,56 @@
"""Серверный Python-порт DEIF: протокольное состояние целиком принадлежит общему C99.""" # Пакет не импортирует gui_desktop и пригоден без Qt.
import ctypes as C # Структуры ABI содержат только готовые кадры и скаляры.
from time import monotonic # Порт поставляет монотонные часы, чтобы C оставался независим от ОС.
from .deif_display import DeifDisplay, FIELDS, STATUS, _milliseconds # Пассивный и активный режимы используют одинаковые снимки индикаторов.
from .deif_native import Frame, get_deif_native # Нет Python-копии адресации, NMT, сроков или SDO.
def validate_slave_transmit(frame, node): # Серверный маршрут имеет отдельный контракт по собственному Node-ID.
if type(node) is not int or not 1 <= node <= 127 or not get_deif_native().validate(frame.can_id, bytes(frame.data), len(frame.data), int(bool(frame.ide)) | (int(bool(frame.rtr)) << 1), node): # C разрешает только собственные heartbeat, EMCY и ответы SDO.
raise ValueError("Разрешены только heartbeat, EMCY и ответы SDO своего SLAVE") # Недопустимый кадр не попадёт в физический транспорт.
class DeifSlave(DeifDisplay): # Отличие от монитора состоит в включённых сервисах C и сохранении последнего числа при Data lost.
services, expiry_ms = True, 0 # Частоту heartbeat и срок Data lost определяет C, нулевой expiry сохраняет последнее показание.
def __init__(self, node=11, source_node=1, xdi_net=True, self_start=True, dam_pdo=2, bindings=None, dimmer_group=1): # Сохраняет существующий Python API модели SLAVE.
if any(type(value) is not int for value in (node, source_node, dam_pdo, dimmer_group)): # Порт проверяет типы до преобразования unsigned ABI.
raise ValueError("Параметры профиля DEIF должны быть целыми") # Логические и дробные адреса не превращаются в другой узел.
super().__init__(source="Локальная симуляция", device_node=node, bindings=bindings, source_node=source_node, xdi_net=xdi_net, self_start=self_start, dam_pdo=dam_pdo, dimmer_group=dimmer_group) # Все ограничения и исходные привязки создаются C-ядром.
def start(self, now=None, reset_values=True): # Явное включение не использует физический транспорт напрямую.
self._sync_bindings() # Последние параметры UI должны соответствовать создаваемому сеансу.
self._api.start(self._context, _milliseconds(monotonic() if now is None else now), bool(reset_values)) # Boot-up и self-start формируются общим автоматом C.
def stop(self): # Выключение прекращает в том числе невыданные служебные события.
self._api.stop(self._context) # Никакого отдельного Python-таймера heartbeat здесь нет.
def accept(self, source, identifier, data, extended=False, rtr=False, now=None): # Транспортное имя фильтруется базовым портом, байты принимает C.
return super().accept(source, identifier, data, extended, rtr, monotonic() if now is None else now) # Явные часы позволяют одинаково проверять границы Python и JNI.
def tick(self, now=None): # Порт регулярно выгружает готовые события и передаёт их выбранному транспорту.
from .can_transport import build_frame # Формат транспорта не смешивается с форматом DEIF.
self._sync_bindings() # Прямое изменение профиля не должно оставлять старые привязки.
output = (Frame * 16)() # Вместимость соответствует ограниченному пакету событий публичного ABI.
count = self._api.poll(self._context, _milliseconds(monotonic() if now is None else now), output, len(output)) # Все сроки и NMT-переходы обслуживаются C.
return [build_frame(frame.can_id, bytes(frame.data[:frame.dlc]), ide=False) for frame in output[:count]] # Python меняет только тип контейнера готовых кадров.
def lost_fields(self, now=None): # Локализованный список строится поверх готовой маски C.
self._sync_bindings() # Непривязанное поле не должно участвовать в предупреждении.
mask = self._api.lost(self._context, _milliseconds(monotonic() if now is None else now)) # Независимые сроки по 3000 мс определяются общим ядром.
return [key for index, key in enumerate(FIELDS) if mask & (1 << index)] # Соответствие field ID и имени не повторяет алгоритм свежести.
def _meta(self): # Набор скаляров не раскрывает внутреннюю структуру C.
output = (C.c_int64 * 10)() # Число элементов закреплено в deif_xdi.h.
self._api.meta(self._context, output) # Состояние хранится только в одном месте.
return list(output) # Python-порт не пересчитывает NMT или периоды.
active = property(lambda self: bool(self._meta()[0])) # Флаг нужен жизненному циклу страницы.
state = property(lambda self: self._meta()[1]) # Готовый CANopen NMT-код используется в локализованной подписи.
started = property(lambda self: self._meta()[2] / 1000) # Секунды сохраняют совместимость прежнего Python API.
last_heartbeat = property(lambda self: self._meta()[3] / 1000) # Преобразуется только единица системного времени.
heartbeat_ms = property(lambda self: self._meta()[4]) # Значение стандартного объекта 0x1017 выдаёт C.
accepted = property(lambda self: self._meta()[5]) # Счётчик принятия не имитируется количеством тиков GUI.
brightness = property(lambda self: self._meta()[7]) # Текущий процент подсветки из C-профиля.
palette = property(lambda self: self._meta()[8]) # Day/Night/Dusk хранится как код общего ядра.
invalid = property(lambda self: {key: STATUS[item[2]] for key, item in self._snapshots().items() if item[2]}) # Только текст статуса локализуется в Python.
File diff suppressed because one or more lines are too long
+140
View File
@@ -0,0 +1,140 @@
"""SET v2/RTU session for emulator panels; no I/O or Qt dependencies.
Only discovery may fall back to RTU. An application command is sent once;
failure after choosing v2 must never resend a write using another protocol.
"""
import secrets
import struct
from setprotocol.core import (Frame, FrameFlag, FrameParser, MessageType, Status,
DeviceInfo, build_frame, decode_response)
from set_devices.tms_terminal import crc16_modbus
from set_devices.mcu_profiles import EMULATOR_PROFILES # Проверка DeviceInfo использует модель конкретной платы из общего аппаратного профиля.
REG_REQUEST = 0x1000
TMS_REQUEST = 0x1001
WAVE_BLOCK = 0x1002
MODEL = 'STM32F407-EMU-EXPERIMENT'
def with_crc(body):
return body + struct.pack('<H', crc16_modbus(body))
class EmulatorProtocol:
def __init__(self, mode='auto', *, mcu="STM32F407"): # Необязательный MCU сохраняет старый API и задаёт ожидаемую идентичность до прикладных команд.
if mode not in ('auto', 'rtu'):
raise ValueError('Неизвестный режим протокола: ' + str(mode))
self.mode = mode
self.model = EMULATOR_PROFILES[mcu]["model"] # F407 и G474RB должны подтвердить собственную прошивку, а не только общий формат SET v2.
self.phase = None
self.parser = FrameParser()
self.sequence = secrets.randbelow(65535)
self.pending = b''
self.node = None
self.expected = None
def _frame(self, kind, payload=b'', destination=0):
self.sequence = (self.sequence + 1) & 0xFFFF
self.expected = (self.sequence, kind, destination)
return build_frame(Frame(kind, self.sequence, payload,
FrameFlag.ACK_REQUIRED, 0, destination))
def begin(self, raw):
self.pending = bytes(raw)
if self.mode == 'rtu':
return self.pending
if self.mode == 'v2':
return self._application()
self.parser.reset()
self.phase = 'ping'
return self._frame(MessageType.PING)
def fallback(self):
if self.phase != 'ping' or self.mode != 'auto':
raise ValueError('SET v2 обнаружен, но устройство не подтвердило совместимость')
self.mode, self.phase = 'rtu', None
self.parser.reset()
return self.pending
def cancel(self):
self.pending = b''
self.phase = None
self.expected = None
self.parser.reset()
def _application(self):
raw = self.pending
if not raw or crc16_modbus(raw):
raise ValueError('Неверный исходный запрос эмулятора')
self.phase = 'reply'
if len(raw) >= 9 and raw[1] == 16:
return self._frame(WAVE_BLOCK, raw, raw[0])
if len(raw) == 8 and raw[1] in (3, 6):
unit, fn, address, value = struct.unpack('>BBHH', raw[:6])
return self._frame(REG_REQUEST, struct.pack('<BHH', fn, address, value), unit)
if len(raw) == 18 and raw[1] == 65:
return self._frame(TMS_REQUEST, raw, self.node)
raise ValueError('Эта команда пока не поддерживается эмулятором SET v2')
def feed(self, chunk):
if self.mode == 'rtu':
return b'', bytes(chunk)
for frame in self.parser.feed(chunk):
if self.expected is None:
continue
seq, kind, destination = self.expected
if (frame.sequence != seq or frame.message_type != kind or
frame.destination != 0 or not frame.flags & FrameFlag.RESPONSE or
frame.flags & (FrameFlag.EVENT | FrameFlag.MORE) or
frame.source in (0, 65535) or
(destination and frame.source != destination)):
continue
status, body = decode_response(frame)
if bool(frame.flags & FrameFlag.ERROR) != bool(status):
raise ValueError('Несогласованные status/ERROR в ответе SET v2')
if self.phase == 'ping':
if status or len(body) != 4:
raise ValueError('Неверный PING SET v2')
self.node, self.phase = frame.source, 'info'
return self._frame(MessageType.DEVICE_INFO, destination=self.node), b''
if self.phase == 'info':
if status or DeviceInfo.decode(body).model != self.model: # Запись разрешается только после подтверждения аппаратного профиля выбранного MCU.
raise ValueError('SET v2: подключено другое устройство, команды эмулятора не отправлены')
self.mode = 'v2'
return self._application(), b''
if self.phase == 'reply':
response = self._reply(status, body)
self.phase, self.expected = None, None
return b'', response
return b'', b''
def _reply(self, status, body):
raw = self.pending
if raw[1] == 16:
if status:
raise ValueError(f'SET v2 отклонил блок генератора: status={status}')
# The shared C waveform codec validates CRC, address, count and echo.
return body
if raw[1] == 65:
if status == Status.UNSUPPORTED:
raise ValueError('Обновите прошивку STM: требуется CMD_STD через SET v2')
if status:
raise ValueError(f'SET v2 отклонил CMD_STD: status={status}')
if len(body) != 101 or body[:2] != raw[:2] or crc16_modbus(body[:-1]):
raise ValueError('Неверный ответ CMD_STD внутри SET v2')
return body
if status:
error = {Status.UNSUPPORTED: 1, Status.NOT_FOUND: 2,
Status.INVALID_ARGUMENT: 3, Status.INVALID_LENGTH: 3,
Status.BUSY: 6}.get(status, 4)
return with_crc(bytes([raw[0], raw[1] | 128, error]))
count = int.from_bytes(raw[4:6], 'big') if raw[1] == 3 else 2
if len(body) != 2 + 2 * count or struct.unpack_from('<H', body)[0] != count:
raise ValueError('SET v2: неверная длина ответа регистров')
words = struct.unpack_from(f'<{count}H', body, 2)
if raw[1] == 6:
if words != struct.unpack('>HH', raw[2:6]):
raise ValueError('SET v2: неверное подтверждение записи')
return raw
return with_crc(bytes([raw[0], 3, count * 2]) + struct.pack(f'>{count}H', *words))
+24 -2
View File
@@ -57,6 +57,29 @@ def decode_identity(words):
raise ValueError("Некорректные данные версии: " + str(error)) from error
def validate_image_device(meta, expected_product, expected_target="TMS320F28335"): # Сопоставляет назначение в паспорте с настройкой загрузчика без обращения к подключённой плате.
product, target = meta.get('product', 'не указана'), meta.get('target', 'не указан') # Отсутствующее назначение тоже даёт понятный отказ, включая Flash RS, где KeyError не является пользовательской ошибкой.
if product == expected_product and target == expected_target: # Совпадение обоих полей сохраняет прежнее разрешение на дальнейшие проверки BIN.
return # Не заменяет проверку размера, SHA-256 и формата, которую вызывающий код выполняет отдельно.
models = {'Balsam-166-PM35': 'Бальзам-166 ПМ35', 'Balsam-167-PM35': 'Бальзам-167 ПМ35'} # Человекочитаемые названия соответствуют двум моделям из списка «Модель платы».
selected = models.get(str(expected_product), str(expected_product)) # Для других устройств сохраняет исходное обозначение, не выдавая неизвестную модель за 166 или 167.
actual = models.get(str(product), str(product)) # Показывает назначение из паспорта; ошибочный тип поля тоже не мешает вывести причину отказа.
differences = [] # Собирает только действительно различающиеся признаки, чтобы общий процессор не выглядел причиной отказа.
if product != expected_product: # Различие моделей проверяется независимо от процессора, который может совпадать у разных плат.
differences.append('модели платы') # Указывает на расхождение обозначений 166/167 даже при одинаковом TMS320F28335.
if target != expected_target: # Отдельно выявляет несовместимый процессор без ошибочного обвинения модели платы.
differences.append('процессора') # Дополняет причину вторым признаком, только если его значение отличается.
raise ValueError( # Блокирует загрузку с подробным объяснением; метаданные не доказывают аппаратное назначение содержимого BIN.
'Назначение прошивки не совпадает с выбранным устройством. Загрузка заблокирована.\n' # Сразу сообщает результат проверки и его влияние на загрузку.
f'Выбрано в программе: {selected} ({expected_product}); процессор {expected_target}.\n' # Связывает ожидаемые значения с пользовательской настройкой, а не с результатом опроса платы.
f'В паспорте файла: {actual} ({product}); процессор {target}.\n' # Показывает фактические значения полей паспорта рядом с ожидаемыми.
f'Причина: несовпадение {" и ".join(differences)}. Модель подключённой платы этой проверкой не определялась.\n' # Разделяет установленное расхождение метаданных и неизвестное состояние оборудования.
f'Для платы {selected} выберите BIN с паспортом для этой модели и процессора {expected_target}. ' # Предлагает комплект для нужной платы, сохраняя выбранную пользователем модель.
f'Если эта прошивка собрана для {selected}, проверьте назначение в настройках сборки и заново сформируйте выпуск с паспортом. ' # Объясняет исправление ошибочной маркировки сборки без подмены значений готового паспорта.
'Поле «Модель платы» меняйте, только если в программе выбрана не ваша плата.' # Не предлагает выбирать 167 ради обхода сообщения при реальной плате 166.
) # Завершает единое сообщение, одинаковое для EEPROM BIN и Flash RS BIN.
def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None,
expected_product="Balsam-167-PM35", expected_target="TMS320F28335",
max_image_size=MAX_IMAGE_SIZE):
@@ -82,8 +105,7 @@ def inspect_image(path: Path, data: bytes | None = None, *, expected_eeprom=None
if (meta['schema'] != 1 or meta['identity_contract'] != 1
or meta['identity_register_count'] != REGISTER_COUNT):
raise ValueError("Неизвестный формат паспорта")
if meta['product'] != expected_product or meta['target'] != expected_target:
raise ValueError(f"Паспорт предназначен для другого устройства (ожидается {expected_product} / {expected_target})")
validate_image_device(meta, expected_product, expected_target) # При несовпадении показывает выбранные и паспортные модель/процессор с причиной отказа и способом исправления.
if (type(meta['size_bytes']) is not int or meta['size_bytes'] != len(data)
or meta['sha256'] != hashlib.sha256(data).hexdigest()):
raise ValueError("BIN не соответствует паспорту: размер или SHA-256 не совпадает")
+11 -9
View File
@@ -8,6 +8,7 @@ import io
import math
import json
from datetime import datetime
from functools import lru_cache # Таблица достижимых частот строится один раз для каждого аппаратного таймера MCU.
from set_devices.waveform import Waveform, generate as interpolate, load_recipe as load_interpolated_recipe
from set_devices.signal_reconstruction import dac12
@@ -15,13 +16,14 @@ from set_devices.signal_reconstruction import dac12
# TIM6 clock of the supported STM32F407 emulator firmware. The device protocol
# does not currently expose its timer clock; keep this hardware profile explicit.
F407_TIMER_HZ = 72_000_000
F407_SAMPLE_RATES = tuple(sorted({
rate
for divisor in range(1, math.isqrt(F407_TIMER_HZ) + 1)
if F407_TIMER_HZ % divisor == 0
for rate in (divisor, F407_TIMER_HZ // divisor)
if 1 <= rate <= 1_000_000
}))
@lru_cache(maxsize=16) # Небольшой кэш хранит аппаратные профили без повторного перебора делителей при построении сигнала.
def timer_sample_rates(timer_hz, sample_max=1_000_000): # Частоты задаются целочисленным делением TIM6 выбранного MCU, единицы обоих параметров — Гц.
if timer_hz <= 0 or sample_max <= 0: # Нулевые и отрицательные аппаратные параметры не описывают рабочий таймер.
raise ValueError("Частота таймера и предел выборок должны быть положительными") # Ошибка профиля выявляется до формирования таблицы DAC.
return tuple(sorted({rate for divisor in range(1, math.isqrt(timer_hz) + 1) if timer_hz % divisor == 0 for rate in (divisor, timer_hz // divisor) if 1 <= rate <= sample_max})) # Парные делители охватывают все точные частоты без перебора каждого значения до тактовой частоты.
F407_SAMPLE_RATES = timer_sample_rates(F407_TIMER_HZ) # Старый публичный экспорт сохраняет прежние результаты для профиля F407 72 МГц.
def nearest_f407_sample_rate(rate):
@@ -29,9 +31,9 @@ def nearest_f407_sample_rate(rate):
return min(F407_SAMPLE_RATES, key=lambda candidate: (abs(candidate - rate), candidate))
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Прозрачно передаём выбранный настольный решатель общему генератору.
if method != "samples":
return interpolate(points, sample_rate, vref, method, degree)
return interpolate(points, sample_rate, vref, method, degree, backend=backend) # Расчёт остаётся в нативном порте; обработчик готовых отсчётов не требует МНК.
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
if not math.isfinite(vref) or not 0 < vref <= 3.6:
File diff suppressed because one or more lines are too long
+9 -6
View File
@@ -13,7 +13,7 @@ from bisect import bisect_left, bisect_right
from .plot_decimation import PointTimes # Используем координаты X без копирования ряда и без недоступного в Python 3.8 параметра key.
from dataclasses import dataclass
from datetime import datetime, timezone
from .signal_reconstruction import METHODS, reconstruct
from .signal_reconstruction import BACKENDS, METHODS, reconstruct # Общие подписи решателей используются в легенде и CSV, чтобы различать результаты сравнения.
class FrozenPoints(tuple):
@@ -96,6 +96,7 @@ class Request:
output_count: int
degree: int
x_range: tuple | None
backend: str = "native" # Вариант входит в равенство запросов, поэтому две расчётные кривые могут сосуществовать.
@dataclass(frozen=True)
@@ -106,6 +107,7 @@ class Curve:
unique_count: int
rmse: float
pieces: tuple = ()
elapsed_ms: float = 0.0 # Сохраняем полное время расчёта отдельно от геометрии и оценки точности.
def piece_at(self, x):
for piece in self.pieces:
@@ -115,10 +117,11 @@ class Curve:
@property
def label(self):
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})"
variant = " · " + BACKENDS[self.request.backend] if self.request.method == "polynomial" else "" # Дополняем название только у метода, имеющего альтернативный решатель.
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]}{variant})" # Подпись однозначно связывает слой графика и экспорт CSV с вариантом МНК.
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2, backend="native"): # Новый последний параметр не меняет существующие вызовы подготовки снимка.
"""Return a comparable request, or None when the source is not processable."""
if snapshot.blocked_reason:
return None
@@ -130,14 +133,14 @@ def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
selected = Series(selected.key, selected.label,
selected.points.between(left, right),
y_unit=selected.y_unit)
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range, backend) # Включаем выбранный решатель в идентичность запроса и проверку устаревших результатов.
def process(request):
if request is None:
raise ValueError("Нет доступного аналогового канала")
result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True)
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces)
result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True, backend=request.backend) # Передаём явный выбор пользователя в общий тонкий порт без численных вычислений в UI.
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces, result.elapsed_ms) # Результат сохраняет время и метрики, относящиеся к одному конкретному запуску.
def write_csv(curve, stream):
@@ -0,0 +1,33 @@
"""Необязательные известные элементы последовательного эквивалента RLC.""" # Ввод не смешивает номиналы реального RC-снаббера с параметрами идентифицируемых полюсов.
try: # Поддерживаем современные и совместимые Windows-сборки.
from PySide6.QtCore import Signal # Одно изменение любого элемента делает старый подбор моделей неактуальным.
from PySide6.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Используем стандартные числовые поля Qt6.
except ImportError: # Qt5 получает тот же интерфейс без дополнительных зависимостей.
from PySide2.QtCore import Signal # Контракт сигналов одинаков на Windows 7 и новых системах.
from PySide2.QtWidgets import QDoubleSpinBox, QHBoxLayout, QLabel, QWidget # Набор полей и единицы не зависят от версии Qt.
class CircuitParameters(QWidget): # Компонент можно встроить в подробные параметры любого вида сравнения.
changed = Signal() # Владелец решает, запускать ли автоматический пересчёт или ожидать кнопку.
def __init__(self, parent=None): # По умолчанию ни один электрический элемент не предполагается известным.
super().__init__(parent) # Жизненным циклом компонента управляет родительская панель сравнения.
row = QHBoxLayout(self) # Три поля и пояснение занимают одну строку.
row.setContentsMargins(0, 0, 0, 0) # Сохраняем существующие отступы формы.
row.addWidget(QLabel('Эквивалентный последовательный RLC:')) # Явно называем топологию, к которой относятся формулы R/L и LC.
self.inductance, self.capacitance, self.resistance = QDoubleSpinBox(), QDoubleSpinBox(), QDoubleSpinBox() # Отдельное неизвестное значение разрешено у каждого элемента.
self.controls = (self.inductance, self.capacitance, self.resistance) # Общий список позволяет блокировать ввод во время рабочего задания.
for title, box, suffix in (('L', self.inductance, ' нГн'), ('C', self.capacitance, ' нФ'), ('R', self.resistance, ' Ом')): # Удобные единицы соответствуют обычным масштабам коммутационного контура.
row.addWidget(QLabel(title)) # Подпись отделяет физическую величину от единицы в поле.
box.setDecimals(6) # Дробные нГн, нФ и Ом сохраняются без грубого округления.
box.setRange(0., 1e12) # Ноль означает неизвестное значение; отрицательные пассивные элементы не допускаются.
box.setSpecialValueText('неизвестно') # Не скрываем отсутствие исходных электрических данных за произвольным номиналом.
box.setSuffix(suffix) # Единица видна при вводе любого положительного значения.
box.setMaximumWidth(170) # Ширина в логических пикселях оставляет место всем трём элементам.
box.setToolTip('Введите известный элемент последовательного эквивалента. Ноль — неизвестно. Это не автоматическая модель топологии RC-снаббера.') # Объяснение предотвращает неверную интерпретацию восстановленных номиналов.
box.valueChanged.connect(lambda _value: self.changed.emit()) # Любое изменение требует нового численного подбора и отчёта.
row.addWidget(box) # Поля располагаются в порядке L, C и R.
row.addStretch(1) # Свободная ширина не растягивает числовые поля.
def values(self): # Снимок значений фиксируется до запуска фоновой идентификации.
return dict(inductance_h=self.inductance.value() * 1e-9, capacitance_f=self.capacitance.value() * 1e-9, resistance_ohm=self.resistance.value()) # нГн и нФ переводятся в СИ; Ом уже соответствует внутренним формулам.
@@ -0,0 +1,120 @@
"""Асинхронное управление общим DEIF через SET v2 или RTU.""" # Обмен не зависит от приложения-потребителя и аппаратного порта MCU.
import struct # FC03 и FC06 используют порядок байтов Modbus big-endian внутри общего транспорта.
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Поддерживает одинаковую машину обмена для Qt5 и Qt6.
from .emulator_serial import EmulatorSerialPort # Автомат обнаруживает протокол и проверяет модель выбранной платы.
from .stm_settings import request, decode_config # Настройки STM читаются тем же кодеком, что явный выбор эмулятора.
from set_devices.tms_terminal import crc16_modbus # CRC проверяется также при транспортировке RTU-команды внутри SET v2.
from set_devices.deif_emulator import DEIF_BASE, DEIF_WORDS, decode_snapshot # Адреса, сигнатура и интерпретация снимка общие для всех MCU.
class DeifEmulatorClient(QObject): # Каждая операция заканчивается закрытием COM, не удерживая общий порт между действиями.
finished = Signal(bool, str, object) # Возвращает подтверждённый снимок либо ошибку без ложного успеха записи.
def __init__(self, parent=None, *, serial_factory=EmulatorSerialPort): # Подмена фабрики предназначена для проверки на реальном C-ядре без оборудования.
super().__init__(parent) # QObject освобождает таймеры вместе с владельцем панели.
self.serial = serial_factory(self) # Один адаптер хранит выбранный MCU и выполняет безопасный handshake.
self.serial.readyRead.connect(self._receive) # Разбор допускает произвольную фрагментацию USB/UART.
self.serial.errorOccurred.connect(self._error) # Ошибка открытия или передачи завершает только текущую операцию.
self.timeout = QTimer(self) # Ограничение ожидания применяется отдельно к каждому запросу.
self.timeout.setSingleShot(True) # Тайм-аут не создаёт повторных записей со сторонними эффектами.
self.timeout.timeout.connect(lambda: self._finish(False, "Тайм-аут DEIF; состояние записи проверьте повторным чтением")) # Потерянный ответ не означает, что команда не исполнилась.
self.delay = QTimer(self) # Следующий запрос отправляется вне стека readyRead.
self.delay.setSingleShot(True) # Каждое подтверждение разрешает ровно один следующий шаг.
self.delay.timeout.connect(self._next) # Последовательные FC06 не перемешиваются с финальной проверкой.
self.active = False # Новая операция разрешена только после полного завершения предыдущей.
def start(self, port, unit, baud, timeout, writes=()): # Профиль связи передаётся потребителем явно, без глобального COM и адреса.
if self.active: # Повторное нажатие не должно сбросить ожидающую подтверждения запись.
return # Текущая операция сохраняет первоначальный профиль.
self.writes = tuple((int(offset), int(value) & 0xFFFF) for offset, value in writes) # RAW I16 кодируется теми же 16 битами без потери знака.
if any(offset not in (2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) and not 32 <= offset < 44 for offset, _value in self.writes): # Только документированные writable слова версии 1 допускаются в очередь.
self.finished.emit(False, "Регистр DEIF недоступен для записи", None) # Локальная ошибка обнаруживается до открытия физического порта.
return # Чужие регистры и конфигурация STM не могут попасть в эту транзакцию.
self.unit, self.timeout_ms = unit, timeout # Адрес управления STM по умолчанию 16; тайм-аут задаётся в мс.
self.active, self.stage, self.rx = True, "config", bytearray() # Первая команда только читает действующий режим MCU.
self.serial.setPortName(port) # Выбор COM не открывает соединение до настройки формата.
self.serial.setBaudRate(baud) # USART использует выбранную скорость; для CDC это поле совместимости.
self.serial.setDataBits(QSerialPort.DataBits.Data8) # Восемь бит данных соответствуют аппаратным портам STM.
self.serial.setParity(QSerialPort.Parity.NoParity) # Проверку целостности выполняет CRC, формат UART без чётности.
self.serial.setStopBits(QSerialPort.StopBits.OneStop) # Один стоп-бит завершает формат 8N1.
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl) # RTS/CTS прошивкой не используются.
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite): # COM может быть занят другой программой.
self._finish(False, self.serial.errorString()) # Ошибка сообщает реальную причину открытия.
return # Ни чтение, ни запись при закрытом порте не выполняются.
self._send(3, 0x1210, 10) # Адрес и длина блока настроек STM взяты из set_emu_server.
def _send(self, function, address, value): # Запрос отправляется один раз; автоматического повтора записи нет.
self.packet = request(self.unit, function, address, value) # Общий кодек добавляет правильный CRC16.
self.rx.clear() # Байты предыдущего подтверждённого ответа не примешиваются к новому.
self.timeout.start(self.serial.response_timeout(self.timeout_ms)) # Первый обмен учитывает время определения SET v2/RTU.
if self.serial.write(self.packet) != len(self.packet): # Частичная постановка запроса в очередь не считается отправкой.
self._finish(False, "Не удалось передать запрос DEIF") # Операция останавливается без повторной передачи.
def _receive(self): # Накапливает только ответ текущего FC03 или FC06.
chunk = bytes(self.serial.readAll()) # Готовые байты забираются даже при отменённой операции.
if not self.active or not self.timeout.isActive(): # Поздний ответ не возобновляет отменённую последовательность.
return # Команда остаётся завершённой до нового явного действия пользователя.
self.rx.extend(chunk) # USB может разрезать ответ по любым границам.
function = self.packet[1] # Функция текущего запроса определяет размер ответа.
while len(self.rx) >= 3: # Заголовок содержит unit, function и byte count либо exception code.
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80): # Чужие либо старые кадры не подтверждают текущую операцию.
del self.rx[0] # Сдвиг на байт позволяет восстановиться после шумового префикса.
continue # Повторно ищет подходящий заголовок в оставшихся байтах.
expected = self.packet[5] * 2 if function == 3 else 0 # Все чтения короче 256 слов, старший байт количества равен нулю.
size = 5 if self.rx[1] & 0x80 else expected + 5 if function == 3 else 8 # Exception имеет 5 байт, FC06 echo — 8, FC03 — данные плюс заголовок и CRC.
if len(self.rx) < size: # Незавершённый ответ остаётся в буфере до readyRead или тайм-аута.
return # Никаких записей до полного подтверждения не выполняется.
raw = bytes(self.rx[:size]) # Фиксирует кадр до удаления из транспортного буфера.
del self.rx[:size] # Следующий шаг начнёт новый обмен с чистым буфером.
self.timeout.stop() # Полный ответ больше не ожидает истечения тайм-аута.
try: # Ошибки протокола должны возвращаться через finished, не покидая слот Qt.
if crc16_modbus(raw): # Нулевая CRC полного кадра подтверждает целостность.
raise ValueError("Неверная CRC ответа DEIF") # Повреждённый кадр не подтверждает запись.
if raw[1] & 0x80: # Старая прошивка либо неверный режим возвращает Modbus exception.
raise ValueError("STM отклонила DEIF, код %d; проверьте режим и версию прошивки" % raw[2]) # Показывает реальный код без предположения об успешном исполнении.
if function == 6 and raw != self.packet or function == 3 and raw[2] != expected: # Echo и фактическая длина должны точно совпасть с запросом.
raise ValueError("Неверное подтверждение DEIF") # Ответ другой команды не продвигает очередь.
self._accept(raw) # Только проверенный ответ может разрешить следующий шаг.
except ValueError as error: # Локальная проверка сигнатуры или роли имеет тот же путь завершения.
self._finish(False, str(error)) # Все таймеры и COM закрываются одинаково при любой ошибке.
return # За один сигнал обрабатывается только текущий запрос последовательности.
def _accept(self, raw): # Порядок: конфигурация, сигнатура DEIF, записи, итоговый снимок.
if self.stage == "config": # Чтение действующего режима предшествует любым командам DEIF.
self.mode = decode_config(raw)[8] # Общий декодер проверяет сигнатуру STM и допустимые режимы.
if self.writes and self.mode not in (4, 5): # Выбор режима выполняется отдельной явной транзакцией STM.
raise ValueError("Выберите DEIF Master или DEIF Slave в настройках STM и примените режим") # Чтение остаётся доступным для диагностики любой прошивки с DEIF.
self.stage = "probe" # Теперь нужно проверить версию и роль отдельного окна DEIF.
self.steps = [(3, DEIF_BASE, DEIF_WORDS)] # Одно чтение возвращает атомарный снимок 52 слов.
elif self.packet[1] == 3: # Окно читается перед записями и после них.
snapshot = decode_snapshot(struct.unpack(">%dH" % DEIF_WORDS, raw[3:-2]), self.mode) # Совместимость подтверждается данными реального MCU.
if self.stage == "probe" and self.writes: # Совместимый сервис разрешает подготовленные пользователем команды.
self.stage = "write" # С этой точки любой отказ может означать частично применённый профиль.
self.steps = [(6, DEIF_BASE + offset, value) for offset, value in self.writes] + [(3, DEIF_BASE, DEIF_WORDS)] # Финальное чтение обязательно даже после успешных echo.
else: # Чтение без записей и финальная проверка завершают транзакцию.
if any(snapshot.words[offset] != value for offset, value in dict(self.writes).items()): # Повторная запись того же регистра проверяется по последнему значению, включая временную сеть и нулевую маску при настройке.
raise ValueError("DEIF не подтвердил новые значения; повторите чтение состояния") # ACK передачи сам по себе не является проверкой применения.
self._finish(True, "Состояние DEIF прочитано и проверено", snapshot) # Только этот путь публикует подтверждённый снимок формы.
return # Завершённая операция не назначает следующий запрос.
self.delay.start(20) # Пауза 20 мс не блокирует GUI и совпадает с шагом клиента настроек STM.
def _next(self): # Слот таймера продвигает ровно одну команду подтверждённой последовательности.
if self.active and self.steps: # Отмена удаляет разрешение последующих запросов.
self._send(*self.steps.pop(0)) # Следующая запись выполняется только после echo предыдущей.
def _error(self, error): # Обрабатывает ошибки последовательного порта в том же GUI-потоке.
if self.active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError): # Собственный таймер уже отвечает за тайм-аут транзакции.
self._finish(False, self.serial.errorString()) # Не скрывает причину отключения или отказа COM.
def cancel(self): # Отмена доступна пользователю без смены режима на MCU.
if self.active: # Повторная отмена не создаёт повторных сигналов завершения.
self._finish(False, "Обмен DEIF отменён; проверьте состояние повторным чтением") # Уже подтверждённые записи не откатываются и не повторяются.
def _finish(self, ok, message, snapshot=None): # Единое завершение используется при успехе, отмене и ошибке.
if not self.active: # Поздняя ошибка Qt после close не должна затронуть новую операцию.
return # finished публикуется ровно один раз за запуск.
self.active = False # Состояние сбрасывается раньше закрытия COM и внешних обработчиков.
self.timeout.stop() # Ожидание текущего ответа больше не действует.
self.delay.stop() # Следующие записи не отправляются после ошибки.
self.serial.close() # Общий COM освобождается для логгера и других панелей.
self.finished.emit(ok, message, snapshot) # UI получает результат после полного освобождения ресурсов.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,181 @@
"""Async COM adapter for direct RTU or automatic emulator discovery.
Panels retain their register/TMS decoders. Only this boundary translates them
to SET v2 on the wire. No protocol cache survives closing/reopening a port.
"""
import time
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Общий порт не зависит от импортов SETGUI и поддерживает Qt6/Qt5.
from set_devices.emulator_protocol import EmulatorProtocol # Автомат SET v2/RTU находится рядом с остальными переносимыми моделями templates.
def response_timeout(port, milliseconds):
return port.response_timeout(milliseconds) if isinstance(port, EmulatorSerialPort) else milliseconds
def cancel_pending(port):
if isinstance(port, EmulatorSerialPort):
port.cancel_pending()
class EmulatorSerialPort(QObject):
readyRead = Signal()
errorOccurred = Signal(object)
protocolChanged = Signal(str)
wireEvent = Signal(str, bytes)
PROBE_MS = 300
def __init__(self, parent=None, port=None, *, protocol_mode='auto', rtu_suffix=b'', mcu="STM32F407"): # Профиль MCU фиксирует проверяемую модель прошивки, сохраняя все старые параметры порта.
super().__init__(parent)
self._port = port if port is not None else QSerialPort(self)
self._port.readyRead.connect(self._receive)
self._port.errorOccurred.connect(self.errorOccurred.emit)
self._probe = QTimer(self)
self._probe.setSingleShot(True)
self._probe.timeout.connect(self._probe_timeout)
self._protocol_mode = protocol_mode
self._rtu_suffix = bytes(rtu_suffix)
self._mcu = mcu # Аппаратная привязка сохраняется между закрытием и повторным открытием того же COM-адаптера.
self.protocol = EmulatorProtocol(protocol_mode, mcu=mcu) # Первичная конфигурация использует ожидаемую модель выбранного MCU.
self._rx = bytearray()
self._error = ''
self._deadline = 0
def __getattr__(self, name):
port = self.__dict__.get('_port')
if port is None:
raise AttributeError(name)
return getattr(port, name)
def open(self, mode):
self._probe.stop()
self._deadline = 0
self.protocol = EmulatorProtocol(self._protocol_mode, mcu=self._mcu) # Повторное открытие начинает новый handshake с той же аппаратной привязкой.
self._rx.clear()
self._error = ''
return self._port.open(mode)
def configure_protocol(self, mode, *, rtu_suffix=b''):
"""Choose the wire format before opening a new connection."""
if self.isOpen():
raise ValueError('Сначала отключите COM-порт для смены протокола')
protocol = EmulatorProtocol(mode, mcu=self._mcu) # Смена wire-формата не должна сбрасывать выбранный MCU на F407.
self.cancel_pending()
self._protocol_mode = mode
self._rtu_suffix = bytes(rtu_suffix)
self.protocol = protocol
self._rx.clear()
def close(self):
self.cancel_pending()
self._port.close()
self._rx.clear()
def set_mcu(self, mcu): # Профиль разрешается менять только до открытия физического соединения.
if self.isOpen(): # Открытая транзакция должна завершиться с первоначальной идентичностью устройства.
raise ValueError("Сначала отключите COM-порт для смены MCU") # Предотвращаем подмену цели незавершённого обмена.
protocol = EmulatorProtocol(self._protocol_mode, mcu=mcu) # Неизвестный профиль отвергается до изменения сохранённых параметров.
self._mcu = mcu # Следующее открытие порта проверит DeviceInfo именно этой платы.
self.protocol = protocol # Старое состояние обнаружения не переносится на другой MCU.
def cancel_pending(self):
self._probe.stop()
self._deadline = 0
self.protocol.cancel()
def response_timeout(self, milliseconds):
return milliseconds + (2 * self.PROBE_MS if self.protocol.mode == 'auto' else 0)
def errorString(self):
return self._error or self._port.errorString()
def clear(self, *args):
self.cancel_pending()
self._rx.clear()
return self._port.clear(*args)
def bytesAvailable(self):
return len(self._rx)
@property
def supports_wave_blocks(self):
return self.protocol.mode == 'v2'
def readAll(self):
result = bytes(self._rx)
self._rx.clear()
return result
def write(self, data):
self._error = ''
self._rx.clear()
try:
packet = self.protocol.begin(bytes(data))
return len(data) if self._send(packet) else -1
except ValueError as error:
self._fail(str(error))
return -1
def _send(self, packet):
self._probe.stop()
self._deadline = 0
if self.protocol.phase in ('ping', 'info'):
self._deadline = time.monotonic() + self.PROBE_MS / 1000
self._probe.start(self.PROBE_MS)
if self.protocol.mode == 'rtu':
packet += self._rtu_suffix
written = self._port.write(packet)
if written > 0:
self.wireEvent.emit('TX', packet[:written])
if written != len(packet):
self._fail('Не удалось передать запрос целиком')
return False
return True
def _receive(self):
raw = bytes(self._port.readAll())
if not raw:
return
self.wireEvent.emit('RX', raw)
try:
old_mode = self.protocol.mode
outgoing, incoming = self.protocol.feed(raw)
if self.protocol.mode != old_mode:
self.protocolChanged.emit('SET v2')
if outgoing and not self._send(outgoing):
return
if incoming:
self._rx.extend(incoming)
self.readyRead.emit()
except ValueError as error:
self._fail(str(error))
def _probe_timeout(self):
if not self._deadline:
return
if time.monotonic() < self._deadline:
self._probe.start(max(1, int((self._deadline-time.monotonic())*1000)+1))
return
try:
packet = self.protocol.fallback()
self.protocolChanged.emit('RTU')
self._send(packet)
except ValueError as error:
self._fail(str(error))
def _fail(self, message):
self._error = message
self.cancel_pending()
self.errorOccurred.emit(QSerialPort.SerialPortError.ReadError)
def waitForReadyRead(self, milliseconds):
# Used only in WaveTransfer's worker. No GUI event loop is blocked.
deadline = time.monotonic() + milliseconds / 1000
while not self._rx and not self._error and time.monotonic() < deadline:
if self._deadline and time.monotonic() >= self._deadline:
self._probe_timeout()
remaining = max(1, int((deadline-time.monotonic())*1000))
self._port.waitForReadyRead(min(remaining, 20))
if self._error:
raise OSError(self._error)
return bool(self._rx)
@@ -0,0 +1,36 @@
from .qt_compat import Signal # Общий сигнал Qt работает в портах PySide6 и PySide2 без зависимости от SETGUI.
try: # Настольные потребители могут использовать любую из поддерживаемых версий Qt.
from PySide6.QtWidgets import QComboBox, QTabWidget # Qt6 предоставляет контейнер общих панелей и явный выбор MCU.
from PySide6.QtCore import Qt, QSignalBlocker # Блокировка сигнала предотвращает рекурсивное переключение профиля.
except ImportError: # Порт Windows 7 использует тот же API поверх PySide2.
from PySide2.QtWidgets import QComboBox, QTabWidget # Состав и поведение панелей сохраняются в Qt5.
from PySide2.QtCore import Qt, QSignalBlocker # Оба поколения Qt поддерживают блокировку сигналов комбобокса.
from set_devices.mcu_profiles import EMULATOR_PROFILES, EMULATOR_TOOLS # Модели и аппаратные привязки задаёт общая библиотека.
class EmulatorWorkspace(QTabWidget): # Потребитель предоставляет UI-панели, а templates выбирает их состав для конкретного MCU.
mcu_changed = Signal(str) # Приложение обновляет локальные настройки и подписи после явного выбора аппаратного профиля.
def __init__(self, parent=None): # Контейнер не открывает COM/CAN и не создаёт экземпляров аппаратных моделей.
super().__init__(parent) # Qt владеет переданными страницами в течение жизни рабочей области.
self.mcu = "STM32F407" # Сохраняем профиль прежнего эмулятора для существующих пользователей и вызовов.
self.mcu_selector = QComboBox(self) # Общая панель явно показывает MCU, которому предназначены команды.
for key, profile in EMULATOR_PROFILES.items(): # В списке появляются только платы с реализованными портами set_emu_server.
self.mcu_selector.addItem(profile["title"], key) # Стабильный ключ отделяет совместимость устройства от отображаемой подписи.
self.setCornerWidget(self.mcu_selector, Qt.Corner.TopRightCorner) # Выбор MCU доступен рядом с вкладками общих инструментов.
self.mcu_selector.currentIndexChanged.connect(lambda index: self.select_mcu(self.mcu_selector.itemData(index))) # Пользовательский выбор проходит тот же метод, что переход из меню конкретного MCU.
self._emulator_pages = {} # Реестр хранит единственные экземпляры переданных панелей, сохраняя общие транспорты приложения.
def add_emulator_page(self, key, widget): # Приложение связывает собственный виджет с публичным ключом общего сервиса.
self._emulator_pages[key] = widget # Повторное использование панели не копирует её обработчики или состояние модели.
self.addTab(widget, EMULATOR_TOOLS[key]) # Название инструмента берётся из общего каталога templates.
def select_mcu(self, key): # Смена профиля меняет только UI и настройки; команды активации остаются явными действиями пользователя.
profile = EMULATOR_PROFILES[key] # Неизвестный MCU отвергается до изменения текущей рабочей области.
self.mcu = key # Текущий ключ используется потребителем для выбора настроек и аппаратных параметров.
with QSignalBlocker(self.mcu_selector): # Программный переход из раздела MCU не вызывает повторный вход через комбобокс.
self.mcu_selector.setCurrentIndex(self.mcu_selector.findData(key)) # Видимый выбор всегда совпадает с применённым профилем.
for name, widget in self._emulator_pages.items(): # Доступность каждого инструмента определяется сервисами выбранного порта.
self.setTabVisible(self.indexOf(widget), name in profile["tools"]) # Новая плата может подключать подмножество тех же панелей без изменения их реализации.
self.mcu_changed.emit(key) # Локальный адаптер обновляет настройки после завершения общего переключения.
return True # Вызывающая навигация может отличить выполненный переход от отказа занятого адаптера.
+138
View File
@@ -0,0 +1,138 @@
"""Asynchronous IGBT register transactions with probe and readback."""
import struct
from .qt_compat import QObject, QTimer, Signal, QSerialPort # Клиент общий для Qt6 и Qt5 и не импортирует приложение-потребитель.
from set_devices.qt_ports.stm_settings import request
from set_devices.tms_terminal import crc16_modbus
from .emulator_serial import EmulatorSerialPort, response_timeout # Тот же транспорт SET v2/RTU обслуживает эмуляторы всех поддержанных MCU.
class IgbtClient(QObject):
finished = Signal(bool, str, object)
def __init__(self, parent=None):
super().__init__(parent)
self.serial = EmulatorSerialPort(self)
self.serial.readyRead.connect(self._receive)
self.serial.errorOccurred.connect(self._error)
self.timer = QTimer(self)
self.timer.setSingleShot(True)
self.timer.timeout.connect(lambda: self._finish(False, 'Тайм-аут ответа IGBT'))
self.active = False
self.rx = bytearray()
def start(self, port, unit, baud, timeout, writes=()):
if self.active:
return
limits = {2: (0, 1), 6: (0, 255), 7: (0, 1000), 8: (1, 255),
9: (0, 0), 12: (0, 1), 13: (0, 255), 14: (1, 255)}
channel_limits = [(0, 7), (0, 3), (0, 1000), (1, 1000), (0, 1000),
(1, 1000), (1, 60000), (0, 3), (0, 60000)]
for ch in range(8):
for field, bounds in enumerate(channel_limits):
limits[16 + ch * 12 + field] = bounds
self.writes = list(writes)
if any(off not in limits or not limits[off][0] <= value <= limits[off][1]
for off, value in self.writes):
self.finished.emit(False, 'Недопустимые параметры IGBT', None)
return
self.unit, self.timeout_ms = unit, timeout
self.active, self.write_sent = True, False
self.stage = 'probe'
self.serial.setPortName(port)
self.serial.setBaudRate(baud)
self.serial.setDataBits(QSerialPort.DataBits.Data8)
self.serial.setParity(QSerialPort.Parity.NoParity)
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
self._finish(False, self.serial.errorString())
return
self._send(3, 0x1400, 16)
def _send(self, function, register, value):
self.rx.clear()
self.packet = request(self.unit, function, register, value)
self.write_sent |= function == 6
self.timer.start(response_timeout(self.serial, self.timeout_ms))
if self.serial.write(self.packet) != len(self.packet):
self._finish(False, 'Ошибка передачи IGBT')
def _receive(self):
chunk = bytes(self.serial.readAll())
if not self.active:
return
self.rx.extend(chunk)
function = self.packet[1]
while len(self.rx) >= 3:
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80):
del self.rx[0]
continue
size = 5 if self.rx[1] & 0x80 else (5 + self.rx[2] if function == 3 else 8)
if len(self.rx) < size:
return
raw = bytes(self.rx[:size])
self.timer.stop()
if crc16_modbus(raw):
self._finish(False, 'Неверная CRC ответа IGBT')
return
if raw[1] & 0x80:
self._finish(False, f'Устройство отклонило запрос IGBT, код {raw[2]}')
return
if function == 6:
if raw != self.packet:
self._finish(False, 'Неверное подтверждение записи IGBT')
return
self._next()
return
count = int.from_bytes(self.packet[4:6], 'big')
if len(raw) != 5 + count * 2 or raw[2] != count * 2:
self._finish(False, 'Неверная длина состояния IGBT')
return
words = struct.unpack(f'>{count}H', raw[3:-2])
if words[:2] != (0x4947, 3) or words[10:12] != (8, 1) or words[15] != 12:
self._finish(False, 'Требуется новая прошивка с IGBT версии 3 и настройкой каналов')
return
if self.stage == 'probe':
self.stage = 'state'
self._send(3, 0x1400, 112)
return
if self.stage == 'state' and self.writes:
self.pending = list(self.writes)
self.stage = 'write'
self._next()
else:
# The edge counter may advance immediately after being reset.
expected = dict(self.writes)
if any(words[off] != value for off, value in expected.items() if off not in (9, 14)):
self._finish(False, 'Записанные параметры IGBT не подтвердились')
else:
self._finish(True, 'IGBT: параметры записаны и проверены' if self.writes
else 'IGBT: состояние прочитано', words)
return
def _next(self):
if self.pending:
off, value = self.pending.pop(0)
self._send(6, 0x1400 + off, value)
else:
self.stage = 'verify'
self._send(3, 0x1400, 112)
def _error(self, error):
if self.active and error not in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.TimeoutError):
self._finish(False, self.serial.errorString())
def cancel(self):
self._finish(False, 'Обмен IGBT отменён')
def _finish(self, ok, message, words=None):
if not self.active:
return
self.active = False
self.timer.stop()
self.serial.close()
if not ok and self.write_sent:
message += '. Часть настроек могла примениться; прочитайте состояние повторно.'
self.finished.emit(ok, message, words)
@@ -25,11 +25,11 @@ def small_amplitude(values, threshold=30):
return magnitude is not None and magnitude <= threshold
def split_amplitudes(series, threshold=30):
def split_amplitudes(series, threshold=30, *, include_hidden=False): # Порог 30 в единицах канала разделяет шкалы; файловый canvas может сохранить в них скрытые ряды.
"""Channels with finite peak magnitude <= 30 use the right amplitude axis."""
result = set()
for channel in series:
if not channel.visible or channel.discrete:
if (not channel.visible and not include_hidden) or channel.discrete: # По умолчанию скрытые каналы исключены; независимая политика границ может учитывать их для сохранения масштаба.
continue
if small_amplitude(channel.values, threshold):
result.add(channel.key)
@@ -51,8 +51,8 @@ class DualAxisPlot:
super().__init__(*args, **kwargs)
def _sync_bounds(self):
keys = split_amplitudes(self._series)
if keys != self.right_axis_keys:
keys = split_amplitudes(self._bounds_series(), include_hidden=True) # Выбираем правые каналы из того же набора, что задаёт общие границы: LogsPlot сохраняет в нём скрытые источники.
if not keys.intersection(self.right_axis_keys): # Сохраняем масштаб при добавлении или временном пересоздании эталона, пока на правой Y остаётся хотя бы один прежний канал.
self.right_viewport = Viewport()
self.right_axis_keys = keys
super()._sync_bounds()
@@ -60,8 +60,8 @@ class DualAxisPlot:
def set_plot_bounds(self, bounds, has_data=True, x_origin=0):
if self.right_axis_keys and self.has_left_axis():
groups = [[], []]
for series in self._series:
if series.visible and not series.discrete:
for series in self._bounds_series(): # Обе независимые Y соблюдают политику canvas, чтобы снятие флажка не подменяло диапазон токов диапазоном напряжений.
if not series.discrete: # Дискретные дорожки не имеют общей амплитудной шкалы и не участвуют в её пределах.
groups[series.key in self.right_axis_keys].extend(
getattr(series.values, "finite_extent", None) or
(v for v in series.values if math.isfinite(v)))
@@ -74,8 +74,8 @@ class DualAxisPlot:
super().set_plot_bounds(bounds, has_data, x_origin)
def has_left_axis(self):
return any(s.visible and not s.discrete and s.values and s.key not in self.right_axis_keys
for s in self._series)
return any(not s.discrete and s.values and s.key not in self.right_axis_keys # Наличие левой координатной системы определяется теми же рядами, что и её границы.
for s in self._bounds_series()) # Файловый график сохраняет обе шкалы при скрытии линий; живой график учитывает только видимые каналы.
@property
def right_visible_bounds(self):
+19 -7
View File
@@ -21,7 +21,7 @@ except ImportError:
QVBoxLayout, QWidget,
)
from set_devices.signal_reconstruction import MAX_OUTPUT_POINTS, METHODS
from set_devices.signal_reconstruction import BACKENDS, MAX_OUTPUT_POINTS, METHODS # Используем единый список вариантов расчёта для панели и подписей результатов.
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
RESULT_COLORS = ("#FF70D0", "#50D5FF", "#FFD166", "#83E377", "#B99AFF", "#FF9870")
@@ -78,6 +78,11 @@ class SignalProcessingPanel(QWidget):
self.method.addItem(title, key)
self.method.setCurrentIndex(2)
row.addWidget(self.method)
self.backend = QComboBox() # Явный переключатель позволяет сравнить прежний МНК и новый Eigen на одном снимке данных.
for key, title in BACKENDS.items(): # Порядок оставляет исходный решатель значением по умолчанию.
self.backend.addItem(title, key) # В данных элемента хранится стабильный ключ, независимый от русского названия.
self.backend.setToolTip("Вариант полиномиального МНК. Рассчитайте оба: кривые сохранятся вместе, подписи и время помогут сравнению.") # Объясняем практический способ сравнения непосредственно рядом с выбором.
row.addWidget(self.backend) # Располагаем вариант рядом с методом, к которому он относится.
row.addStretch(1)
layout.addLayout(row)
row = QHBoxLayout()
@@ -110,15 +115,18 @@ class SignalProcessingPanel(QWidget):
"Полином МНК — сглаживание шумных данных. Линейная — отрезки между точками.\n"
"PCHIP — сохранение формы без выбросов. Сплайн — гладкая кривая, возможны выбросы.\n"
"Восстановление — оценка между измерениями; утраченные детали не определяются однозначно.")
for widget in (self.channel, self.method):
widget.currentIndexChanged.connect(self._parameters_changed)
for widget in (self.channel, self.method, self.backend): # Смена решателя делает текущий запрос новым, сохраняя предыдущие слои.
widget.currentIndexChanged.connect(self._parameters_changed) # Общая проверка предотвращает принятие результата для уже изменённого выбора.
for widget in (self.degree, self.count):
widget.valueChanged.connect(self._parameters_changed)
self._update_actions()
def _signature(self):
return prepare(self._snapshot, self.channel.currentData(), self.method.currentData(),
self.count.value(), self.degree.value())
self.count.value(), self.degree.value(), self._backend()) # Вариант участвует в идентичности снимка и результата.
def _backend(self): # Для методов без альтернативы всегда используем исходный алгоритм, сохраняя последний выбор МНК в списке.
return self.backend.currentData() if self.method.currentData() == "polynomial" else "native" # Переключение на интерполяцию не выдаёт её за расчёт Eigen.
def _current_request_for(self, signature):
if not self.preserve_on_view_change or signature is None:
@@ -126,7 +134,7 @@ class SignalProcessingPanel(QWidget):
# Validate the original calculation domain, independently of navigation.
return prepare(replace(self._snapshot, x_range=signature.x_range),
self.channel.currentData(), self.method.currentData(),
self.count.value(), self.degree.value())
self.count.value(), self.degree.value(), self._backend()) # Сохранённое окно проверяется вместе с текущим вариантом решателя.
def set_snapshot(self, snapshot):
previous = self._job.signature if self._job is not None else self._signature()
@@ -154,7 +162,7 @@ class SignalProcessingPanel(QWidget):
# depend on samples/units, not the source curve's checkbox.
source = replace(source, series=tuple(replace(series, visible=True)
for series in source.series))
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree, request.backend) == request: # Проверяем каждый слой с его собственным решателем, чтобы оба варианта оставались на графике.
retained.append(result)
if len(retained) != len(self._results):
self._results = retained
@@ -173,6 +181,7 @@ class SignalProcessingPanel(QWidget):
def _update_actions(self):
self.degree.setEnabled(self.method.currentData() == "polynomial")
self.backend.setEnabled(self.method.currentData() == "polynomial") # Выбор решателя доступен только для метода МНК, где есть два разных алгоритма.
request = self._signature()
self.apply_button.setEnabled(self._job is None and request is not None and
len(request.series.points) >= 2)
@@ -250,9 +259,12 @@ class SignalProcessingPanel(QWidget):
duplicates = result.input_count - result.unique_count
self.status.setText(
f"{self.channel.currentText()}: {result.unique_count} исходных → {len(result.points)} расчётных точек; "
f"СКО на измерениях: {result.rmse:.6g}. "
f"СКО на измерениях: {result.rmse:.6g}; расчёт: {result.elapsed_ms:.3f} мс. " # Показываем точность и время одного и того же результата, включая преобразование буферов.
+ (f"Повторы X усреднены: {duplicates}. " if duplicates else "")
+ f"Расчётных кривых: {len(self._results)}. Исходные данные сохранены.")
comparable = [curve for curve in self._results if replace(curve.request, backend=signature.backend) == signature] if signature.method == "polynomial" else [] # Сопоставляем только одинаковые данные, степень, сетку и окно; различается исключительно решатель.
if len(comparable) > 1: # Сравнение появляется после явного расчёта обоих вариантов на одном наборе данных.
self.status.setText(self.status.text() + "\n" + " | ".join(f"{BACKENDS[curve.request.backend]}: СКО {curve.rmse:.6g}, {curve.elapsed_ms:.3f} мс" for curve in comparable)) # Сохраняем метрики обоих запусков рядом, не объявляя победителя по единичному замеру.
self._update_actions()
self.changed.emit()
@@ -0,0 +1,375 @@
"""Переиспользуемые 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 штатное закрытие.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -7,6 +7,7 @@ import struct
from .qt_compat import QObject, QTimer, Signal
from .qt_compat import QSerialPort
from set_devices.mcu_profiles import EMULATOR_MODES # Допустимые номера режимов берутся из общего каталога, включая обе роли DEIF.
from set_devices.tms_terminal import crc16_modbus
BAUDRATES = (9600, 19200, 38400, 57600, 115200)
@@ -24,7 +25,7 @@ def decode_config(raw):
if words[0] != 0x5343 or words[1] not in (2, 3):
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
address, tms, rate = words[2:5]
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > (3 if words[1] == 3 else 1) or words[9] > (3 if words[1] == 3 else 1):
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] not in (EMULATOR_MODES if words[1] == 3 else (0, 1)) or words[9] not in (EMULATOR_MODES if words[1] == 3 else (0, 1)): # Версия 3 C-сервера поддерживает режимы 0…5; версия 2 сохраняет только УМП и 2812.
raise ValueError("STM вернула неверные параметры связи")
return words
@@ -52,7 +53,7 @@ class StmSettingsClient(QObject):
return
if desired is not None:
addr, tms, rate, mode = desired
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1, 2, 3):
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in EMULATOR_MODES: # Номера 4 и 5 выбирают DEIF Master/Slave того же общего C-сервера.
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
return
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
@@ -120,7 +121,7 @@ class StmSettingsClient(QObject):
return
addr, tms, baud, mode = self.desired
if mode >= 2 and words[1] < 3:
raise ValueError("Обновите прошивку STM: для отдельных режимов IGBT и ЦАП нужна версия настроек 3")
raise ValueError("Обновите прошивку STM: для IGBT, ЦАП и DEIF нужна версия настроек 3") # Сообщение учитывает обе роли DEIF и возможное применение настроек при потере ответа.
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
@@ -173,5 +174,5 @@ class StmSettingsClient(QObject):
if self.serial.isOpen():
self.serial.close()
if not ok and self.committed:
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП). Повторите чтение по ним или перезагрузите плату." % self.desired
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812, 2=IGBT, 3=ЦАП, 4=DEIF Master, 5=DEIF Slave). Повторите чтение по ним или перезагрузите плату." % self.desired # Сообщение учитывает обе роли DEIF и возможное применение настроек при потере ответа.
self.finished.emit(ok, message, values)
@@ -25,10 +25,13 @@ class ViewportNavigation(QObject):
self._navigation_timer.setInterval(180)
self._navigation_timer.timeout.connect(self._navigation_finished)
def needs_worker(self): # Общая нагрузка всех каналов определяет допустимость синхронного расчёта, а не длина самого длинного ряда.
return sum(len(series.values) for series in self.chart.plot._series) > 20000 # Бюджет 20000 исходных значений сохраняет быстрый путь малых записей и отправляет многоканальные окна в фон.
def _navigation_changed(self):
self._navigation_revision += 1
self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов.
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
if not self.needs_worker(): # Проверяем суммарный объём тем же правилом, что используют панель измерений и легенда.
self.chart._scope_measurements()
self.chart._refresh_processing()
self.chart._refresh_legend()
@@ -38,7 +41,7 @@ class ViewportNavigation(QObject):
def _navigation_finished(self):
if False:
return
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
if not self.needs_worker(): # После задержки повторно проверяем текущие данные: источник мог измениться во время ожидания.
self.chart._scope_measurements()
self.chart._refresh_processing()
return
+131
View File
@@ -0,0 +1,131 @@
"""Локальные гипотезы неисправностей по сравнению осциллограмм с эталоном.""" # Правила не требуют сети, модели ИИ или знания имени файла.
import math # Конечность отсчётов и масштабы защищают признаки от пропусков и нулевого размаха.
import re # Обозначения физических каналов позволяют отделить Uce, Uge и Ic.
from set_devices.signal_fault_models import identify_fault_models # Параметрические модели конкурируют по BIC и ошибке контрольных наблюдений.
def _role(name): # Название файла не используется как доказательство неисправности.
match = re.match(r'^\s*(uce|uge|ic)\b', name, re.IGNORECASE) # Узнаём только явные обозначения напряжений ключа, затвора и тока.
return match.group(1).lower() if match else '' # Неизвестные обозначения получают только общие статистические гипотезы.
class _Edges: # Гистерезис отделяет полное повторное переключение от небольшого звона и шума.
def __init__(self, low, high): # Пороги задаются относительно размаха эталона в единицах канала.
self.low, self.high = low, high # Нижняя и верхняя границы определяют устойчивые состояния.
self.state, self.start, self.previous = None, None, None # Начальное состояние и разрыв не считаются фронтами.
self.events, self.count = [], 0 # Полный счётчик дополняется ограниченным списком для оценки задержки.
def reset(self): # Через NaN или большой разрыв нельзя восстанавливать ненаблюдавшееся переключение.
self.state, self.start, self.previous = None, None, None # После разрыва ждём нового устойчивого состояния.
def add(self, stamp, value): # Последовательный обход не создаёт дополнительную копию многомиллионной записи.
state = 0 if value <= self.low else 1 if value >= self.high else None # Зона между порогами сохраняет предыдущее устойчивое состояние.
if self.state is None: # Первая наблюдаемая полка задаёт исходное состояние.
self.state = state # Попадание только в середину фронта ещё не определяет направление.
elif state == self.state: # Возврат к прежней полке отменяет незавершённый переход.
self.start = None # Шум внутри полосы не накапливает ложную длительность фронта.
else: # Сигнал покинул прежнюю полку и может достичь противоположной.
if self.start is None: # Запоминаем начало только один раз на каждый фронт.
self.start = self.previous if self.previous is not None else stamp # Шаг дискретизации ограничивает точность начала перехода.
if state is not None: # Полное пересечение гистерезиса подтверждает переключение.
self.count += 1 # Считаем все наблюдаемые фронты независимо от длины записи.
if len(self.events) < 256: # Первых 256 переходов достаточно для сводки; память остаётся ограниченной.
self.events.append(((self.start + stamp) / 2., stamp - self.start, state)) # Сохраняем середину, длительность в мс и направление фронта.
self.state, self.start = state, None # Новая полка становится исходной для следующего перехода.
self.previous = stamp # Предыдущая временная метка нужна для оценки длительности на дискретной сетке.
def analyze_comparison(result, cancel=lambda: False, *, circuit=None, channels=None): # Подбор получает необязательные параметры электрического эквивалента и связанные физические каналы.
metrics = result.metrics # Покрытие и допуски уже рассчитаны по полным исходным данным.
measured_role, reference_role = _role(result.measured.name), _role(result.reference.name) # Физические роли важнее совпадения единицы «В».
findings, notes = [], [] # Несколько одновременно наблюдаемых признаков сохраняются в одном отчёте.
features = {} # Численные признаки экспортируются для проверки и воспроизведения решения.
model_report = {} # До проверки входных данных никакая математическая модель не считается идентифицированной.
def finish(summary, attention=True): # Краткая строка и подробности всегда относятся к одному результату.
details = [summary, f'Каналы: {result.measured.name} ↔ {result.reference.name}.', # Обозначаем фактически выбранные измерение и эталон.
f'Сравнено {metrics["count"]} отсчётов; покрытие {metrics["coverage_pct"]:.4g}%; за допуском {metrics["outside_tolerance"]}.', # Не смешиваем непокрытые точки с превышениями.
f'RMSE {metrics["rmse"]:.7g}; максимум |Δ| {metrics["max_abs"]:.7g} в единицах канала.', # Ошибка сохраняет физические единицы выбранной пары.
*[item['text'] for item in findings if item['text'] != summary], *notes, # Совместные причины и ограничения сохраняются без повторения единственного краткого вывода.
'Диагностические гипотезы основаны на параметрических моделях. BIC и контрольная ошибка оценивают согласие модели с данными, но не доказывают отказ конкретного элемента.'] # Отделяем идентификацию динамики от причинного диагноза оборудования.
if features: # Наблюдаемые численные признаки доступны только после полного прохода по данным.
details.insert(4, f'Полных переходов: эталон {features["reference_edges"]}, измерение {features["measured_edges"]}; смен знака после выключения {features["turn_off_reversals"]}; выброс после выключения {features["turn_off_overshoot"]:.7g} в единицах канала.') # Объясняем гипотезы дребезга и снаббера фактическими числами.
for model in model_report.get('models', []): # Подробности показывают также конкурентов выбранного объяснения.
details.append(f'Модель {model["code"]}: {model["formula"]}\nПараметры: {model["parameters"]}\nRMSE={model["rmse"]:.6g}; контроль={model["validation_rmse"]:.6g}; BIC={model["bic"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; объяснено ошибки {model["explained_error_pct"]:.3g}%.') # Каждый вывод сопровождается фактическими параметрами и штрафом за сложность.
for pair in model_report.get('ringing', []): # Отклики измерения и эталона имеют отдельные динамические оценки.
for side in ('measured', 'reference'): # Не выдаём качество одного сигнала за проверку другой стороны сравнения.
model = pair.get(side) # Эталон может не содержать пригодного колебательного участка.
if model: # Отсутствующая оценка не форматируется как нулевая частота или затухание.
details.append(f'RLC {side}: {model["parameters"]}\nR²={model["r_squared"]}; контроль RMSE={model["validation_rmse"]:.6g}; ΔBIC={model["delta_bic"]:.6g}; модель подтверждена: {model["supported"]}.') # Приводим качество именно подбора затухающего отклика.
details.append(f'Эквивалентный контур: {pair["equivalent"]}') # Введённые элементы, восстановленные значения и неопределённость сохраняются в отчёте.
joint = model_report.get('joint', {}) # Совместная электрическая проверка не должна теряться в одном только JSON.
if joint: # Даже отсутствие каналов является существенным ограничением модели.
details.append(f'Совместные модели Uce/Uge/Ic: {joint}') # Сохраняем результаты ВАХ, остаточного тока, совпадения провалов и причины неидентифицируемости.
details.extend(model_report.get('assumptions', [])) # Предположения BIC, выборки и эквивалентной топологии видны рядом с результатами.
if model_report.get('criteria'): # Критерии поддержки модели не должны оставаться неявными константами кода.
details.append(f'Начальные критерии принятия моделей: {model_report["criteria"]}. Это настройки анализа, не паспортные нормы оборудования.') # Отделяем математическую идентификацию от выбранной чувствительности диагностического решения.
return dict(summary=summary, details='\n\n'.join(details), attention=attention, findings=findings, features=features, model_report=model_report, version=2) # Новый отчёт сохраняет совместимые поля и добавляет структурированные математические модели.
def finding(code, text): # Коды не зависят от русской формулировки и позволяют проверять сценарии.
findings.append(dict(code=code, text=text)) # Сохраняем причину вместе с объясняющим наблюдением.
if measured_role and reference_role and measured_role != reference_role: # Uce и Uge нельзя диагностировать как один физический сигнал даже при одинаковых единицах.
return finish('Выбраны разные физические каналы; сопоставьте одинаковые Uce, Uge или Ic.') # Не выдаём ложную неисправность при ошибке выбора на экране.
units = [re.search(r'\(([^()]*)\)\s*$', trace.name) for trace in (result.measured, result.reference)] # Проверяем явные единицы также при независимом вызове ядра без Qt.
if all(units) and units[0].group(1) != units[1].group(1): # Одинаковые обозначения с разными единицами не позволяют строить аппаратные гипотезы.
return finish('Единицы каналов различаются; сопоставьте сигналы с одинаковыми единицами.') # Численные показатели доступны, но физическая причина не определяется.
if metrics['coverage_pct'] < 100.: # Любое неполное покрытие ограничивает вывод областью реально наблюдаемых данных.
notes.append('Покрытие неполное: пропуски и время вне эталона исключены; состояние всей записи не определено.') # Даже нулевая ошибка на остатке не подтверждает исправность всей записи.
if metrics['count'] < 16: # Минимум 16 общих точек нужен для распознавания формы, а не для вычисления RMSE.
return finish('Недостаточно общих отсчётов для гипотезы неисправности.') # Короткий набор не должен давать уверенное заключение.
low, high = metrics['reference_min'], metrics['reference_max'] # Пределы относятся к интерполированному эталону выбранного интервала.
span = high - low # Размах определяет относительные пороги переключения.
scale = max(span, abs(low), abs(high), 1e-12) # Для постоянной ненулевой полки используем её уровень; 10^-12 исключает деление на ноль.
switching_span = span >= .5 * scale # Полный переход должен занимать не менее половины масштаба; малый звон около 400 В не является включением ключа.
expected_edges = _Edges(low + .3 * span, low + .7 * span) # Уровни 30% и 70% подавляют малые колебания на полках.
measured_edges = _Edges(expected_edges.low, expected_edges.high) # Одинаковые пороги позволяют сравнивать число и длительность переходов.
minimum, maximum, step = math.inf, -math.inf, math.inf # Границы измерения и минимальный шаг рассчитываются без дополнительного массива.
previous, residual_sign, reversals = None, 0, 0 # Знак значимого остатка нужен для распознавания затухающего звона.
off_sign, off_reversals, off_overshoot = 0, 0, 0. # Снаббер проверяется по колебаниям после выключения Uce, отдельно от помех на включённой полке.
band = max(.02 * scale, metrics['absolute_tolerance']) # 2% масштаба и абсолютный допуск отсекают небольшую погрешность и фоновый шум.
for index, (stamp, expected, delta) in enumerate(zip(result.difference.times, result.expected, result.difference.values)): # Анализируем каждый сравниваемый отсчёт без экранного прореживания.
if index % 16384 == 0 and cancel(): # Проверка отмены порциями по 16384 точки сохраняет отзывчивость больших записей.
raise InterruptedError('Анализ отменён') # Устаревшие гипотезы не публикуются после смены записи.
if not math.isfinite(expected) or not math.isfinite(delta): # Пропуск обрывает и фронт, и последовательность знаков остатка.
expected_edges.reset(); measured_edges.reset() # Обе стороны имеют общую область достоверного наблюдения.
previous, residual_sign = None, 0 # Через NaN нельзя считать смену знака или длительность фронта.
off_sign = 0 # Пропуск также прерывает подтверждение звона после выключения.
continue # Некорректные значения не влияют на признаки.
if previous is not None: # Первый корректный отсчёт ещё не определяет шаг времени.
distance = stamp - previous # Внутренняя единица времени — миллисекунда.
if distance > 5. * step: # Разрыв более пяти минимальных шагов не интерполируется диагностикой.
expected_edges.reset(); measured_edges.reset() # Не считаем фронт через ненаблюдавшийся промежуток времени.
residual_sign = 0 # Звон после большого разрыва начинается новой последовательностью.
off_sign = 0 # Колебания по разные стороны временного разрыва не образуют одну последовательность.
step = min(step, distance) # Минимальный наблюдаемый шаг задаёт нижнюю границу временной точности.
value = expected + delta # Восстанавливаем амплитуду на общей сетке без поиска по исходному массиву.
minimum, maximum = min(minimum, value), max(maximum, value) # Выброс сравнивается с огибающей эталона.
if switching_span: # Небольшие колебания отдельной полки не должны считаться полными переключениями транзистора.
expected_edges.add(stamp, expected); measured_edges.add(stamp, value) # Считаем переходы обеих кривых на одной временной сетке.
sign = 1 if delta > band else -1 if delta < -band else 0 # Малая ошибка внутри полосы не образует новую полуволну звона.
if sign: # Нулевая зона между полуволнами сохраняет последний значимый знак.
reversals += int(residual_sign != 0 and residual_sign != sign) # Смена положительного и отрицательного отклонения подтверждает колебание.
residual_sign = sign # Следующая значимая полуволна сравнивается с текущей.
if expected_edges.count and expected_edges.state == 1 and expected >= expected_edges.high: # Для Uce высокая полка после наблюдаемого фронта соответствует выключенному транзистору.
off_overshoot = max(off_overshoot, value - high, low - value) # Выброс на включённой полке не является признаком неисправности снаббера.
if sign: # Для звона учитываем только значимые знакопеременные отклонения на выключенной полке.
off_reversals += int(off_sign != 0 and off_sign != sign) # Пересчитываем смены знака отдельно от всех помех записи.
off_sign = sign # Последняя полуволна сохраняется до следующего выключенного отсчёта.
else: # Включённая полка и область фронта прерывают проверяемую последовательность звона.
off_sign = 0 # Шум между переключениями не связывается со следующим выключением.
previous = stamp # Временная непрерывность проверяется только по корректным точкам.
if cancel(): # Проверяем отмену также после последней неполной порции.
raise InterruptedError('Анализ отменён') # Не возвращаем результат закрытой панели.
features.update(reference_edges=expected_edges.count, measured_edges=measured_edges.count, residual_reversals=reversals, # Количественные признаки объясняют гипотезы дребезга и звона.
overshoot=max(0., maximum - high, low - minimum), turn_off_reversals=off_reversals, turn_off_overshoot=off_overshoot, sample_step_ms=step if math.isfinite(step) else None) # Общий выброс отделён от признаков после выключения; шаг выражен в мс.
physical = bool(measured_role and measured_role == reference_role) # Электрические гипотезы допустимы только для одинаковых явно обозначенных физических каналов.
model_report, findings = identify_fault_models(result, expected_edges.events, features, physical, measured_role, circuit, channels, cancel) # Конкуренты подбираются по данным и проверяются на отложенных точках.
if metrics['outside_tolerance'] == 0 and not findings: # Совпадение выбранного Uce не скрывает нарушение, подтверждённое другими синхронными каналами.
summary = 'Отклонений от эталона в заданных допусках не обнаружено.' if metrics['absolute_tolerance'] > 0. or metrics['relative_tolerance_pct'] > 0. else 'Сигнал совпадает с эталоном; допуски не заданы.' # Совпадение формы не является полной проверкой исправности устройства.
return finish(summary + (' Покрытие неполное.' if notes else ''), bool(notes)) # Сохраняем ограничение неполного покрытия и параметры найденных моделей.
if metrics['absolute_tolerance'] == 0. and metrics['relative_tolerance_pct'] == 0.: # Нулевой коридор должен быть явно отличим от заданного норматива.
notes.append('Допуски не заданы: любое ненулевое отклонение считается превышением; задайте погрешность измерения.') # Обычный шум не должен восприниматься как доказанный аппаратный отказ.
summary = ' '.join(item['text'] for item in findings) # Разные модели совместного дефекта остаются видимыми в одной строке.
return finish(summary + (' Покрытие неполное.' if metrics['coverage_pct'] < 100. else '')) # Краткий вывод связан с тем же покрытием, что полный математический отчёт.
+18
View File
@@ -0,0 +1,18 @@
import ctypes as C # Загружаем необязательный настольный модуль через стабильный C ABI.
from functools import lru_cache # Удерживаем DLL и её функции, пока расчётные задачи используют указатели.
import os # Разрешаем явно выбрать альтернативную библиотеку в тестах или нестандартной установке.
from pathlib import Path # Ищем модуль рядом с уже выбранной основной SETProtocol DLL.
import sys # Расширение нативного модуля соответствует текущей настольной ОС.
@lru_cache(maxsize=1) # Один экземпляр DLL безопасно обслуживает независимые расчёты без глобальных рабочих буферов.
def library(core_path): # Путь основной библиотеки задаёт согласованный каталог установки обоих модулей.
name = {"win32": "setsignal_eigen.dll", "darwin": "libsetsignal_eigen.dylib"}.get(sys.platform, "libsetsignal_eigen.so") # Отдельное имя сохраняет возможность запуска с прежней основной DLL.
path = Path(os.environ.get("SETSIGNAL_EIGEN_LIBRARY", str(Path(core_path).resolve().with_name(name)))) # Явный путь окружения имеет приоритет над соседним компонентом приложения.
try: # Отсутствующий или несовместимый компонент показывается пользователю как ошибка выбранного варианта.
result = C.CDLL(str(path)) # Загружаем только явно вычисленный путь, не подбирая случайную DLL из PATH.
pointer = C.POINTER(C.c_double) # Входные и выходные массивы содержат значения double общего алгоритма.
result.set_signal_reconstruct_eigen.argtypes = [pointer, pointer, C.c_size_t, C.c_int, C.c_uint, C.c_size_t, C.c_int, pointer, pointer, pointer, pointer, C.c_size_t] # Сигнатура повторяет исходную реконструкцию и сохраняет размер size_t для x86/x64.
result.set_signal_reconstruct_eigen.restype = C.c_int # Ошибки ранга и нехватки памяти возвращаются целочисленным кодом.
return result # Удерживаем объект DLL для всего времени жизни привязанной функции.
except (AttributeError, OSError) as error: # Отдельно обрабатываем отсутствие экспорта и невозможность загрузки библиотеки.
raise ValueError("Eigen QR недоступен: соберите модуль cpp/signal-eigen/build.py для разрядности приложения") from error # Не подменяем явно выбранный Eigen текущим МНК.
File diff suppressed because one or more lines are too long
+79
View File
@@ -0,0 +1,79 @@
"""Совместные электрические модели Uce, Uge и Ic на общей временной сетке.""" # Наличие трёх каналов даёт дополнительные проверяемые ограничения аппаратных гипотез.
import math # Корни дисперсий задают оценку неопределённости коэффициентов проводящего состояния.
import re # Физические каналы определяются по явным обозначениям, а не имени CSV.
from statistics import median # Медианные уровни и MAD устойчивы к отдельным коммутационным выбросам.
from set_devices.signal_fault_models import checkpoint, fit_model, interpolate # Переиспользуем численные методы без зависимости от GUI и циклического импорта ядра.
def _channels(traces): # Дублирующиеся обозначения нельзя выбирать произвольно для физических расчётов.
found = {} # Для каждой физической роли разрешён только один однозначный канал.
for trace in traces.values(): # Имена и значения предоставляются снимком исходной записи.
match = re.match(r'^\s*(uce|uge|ic)\s*\((V|A)\)\s*$', trace.name, re.IGNORECASE) # Требуем явные единицы В для напряжений и А для тока.
if match and match.group(2).lower() == ('a' if match.group(1).lower() == 'ic' else 'v'): # Не используем ток в мА как амперы без явного преобразования.
role = match.group(1).lower() # Внутренние обозначения не зависят от регистра заголовка.
found[role] = trace if role not in found else None # Повторная роль помечается неоднозначной и не участвует в выводе.
return {role: trace for role, trace in found.items() if trace is not None} # Неоднозначность не маскируется выбором первого столбца.
def fit_joint_channels(result, channels, events=None, cancel=lambda: False): # Все связанные каналы интерполируются на общую фактическую сетку сравнения.
if not channels: # Синхронные осциллограммы могут отсутствовать у одиночного источника.
return dict(available=False, reason='Для совместной проверки нужны Uce (V), Uge (V) и Ic (A) измерения и эталона.'), [] # Не подменяем отсутствующий ток или затвор выдуманными данными.
measured, reference = _channels(channels.get('measured', {})), _channels(channels.get('reference', {})) # Две стороны сравнения проверяются независимо.
if any(role not in measured or role not in reference for role in ('uce', 'uge', 'ic')): # Физические ограничения требуют полного набора каналов с однозначными единицами.
return dict(available=False, reason='Недостаточно однозначных синхронных каналов Uce/Uge/Ic в В/А.'), [] # Пропущенный канал остаётся ограничением диагностического вывода.
count = len(result.difference.times) # Общая область уже ограничена интервалом и временем эталона.
stride = max(1, math.ceil(count / 4096)) # До 4096 точек ограничивают дополнительную стоимость совместной электрической модели.
points = [] # Каждая строка содержит синхронные тройки фактического измерения и эталона.
for index in range(0, count, stride): # Изменение масштаба графика не влияет на используемую область записи.
if index % 1024 == 0: # Порционная проверка отмены поддерживает отзывчивость на больших записях.
checkpoint(cancel) # Не продолжаем подготовку после смены источника.
stamp = result.difference.times[index] # Время отсчитывается от первого измеренного отсчёта в миллисекундах.
a = {role: interpolate(trace.times, trace.values, stamp + channels['measured_origin_ms']) for role, trace in measured.items()} # Сохраняем временное взаимное положение измеренных каналов.
b = {role: interpolate(trace.times, trace.values, stamp + channels['reference_origin_ms'] - channels['shift_ms']) for role, trace in reference.items()} # К эталону применяется тот же пользовательский сдвиг, что в основном сравнении.
if all(value is not None for value in (*a.values(), *b.values())): # Только полностью наблюдаемые тройки пригодны для совместной физической проверки.
points.append((stamp * 1000., a, b)) # Человеко-читаемое время событий хранится в микросекундах.
if len(points) < 30: # Дополнительные модели требуют не менее 30 совместных наблюдений.
return dict(available=False, reason='Недостаточно общих наблюдений трёх физических каналов.'), [] # Число точек явно ограничивает возможность идентификации.
low = min(point[2]['uce'] for point in points) # Низкая полка эталона определяет проводящее состояние транзистора.
high = max(point[2]['uce'] for point in points) # Высокая полка соответствует выключенному ключу.
gate_high = max(point[2]['uge'] for point in points) # Опорный высокий уровень затвора определяется по данным эталона.
on = [point for point in points if point[2]['uce'] < low + .02 * (high - low) and point[1]['uce'] < low + .25 * (high - low) and min(point[1]['uge'], point[2]['uge']) > .98 * gate_high] # ВАХ оценивается на устойчивой эталонной полке с высоким затвором и фактически проводящим измеренным ключом.
off = [point for point in points if min(point[1]['uce'], point[2]['uce']) > low + .85 * (high - low) and max(point[1]['uge'], point[2]['uge']) < .3 * gate_high] # Утечка допустима как модель только при высоком Uce и низком Uge с обеих сторон; пробой с низким Uce рассматривается отдельно.
report, findings = dict(available=True, count=len(points), on_count=len(on), off_count=len(off), contact_events=[]), [] # Совместные выводы сохраняют фактическую область подтверждения.
fits = {} # Две модели проводимости отдельно оценивают исправный и измеренный ключ.
for side, position in (('measured', 1), ('reference', 2)): # Коэффициенты U0 и Rd вычисляются из собственных тока и напряжения каждой стороны.
if len(on) < 30: # Мало наблюдений проводящего состояния не позволяет оценить наклон ВАХ.
continue # Не выдаём эквивалентное сопротивление по одному значению тока.
currents = [point[position]['ic'] for point in on] # Ток задаётся в амперах, напряжение — в вольтах.
if max(currents) - min(currents) < .1 * max(max(abs(value) for value in currents), 1e-12): # Размах менее 10% уровня не разделяет надёжно U0 и дифференциальное сопротивление.
continue # Почти постоянная нагрузка не даёт идентифицировать две независимые величины.
model = fit_model('conduction', 'Uce=Rdiff*Ic+U0', [currents, [1.] * len(on)], [point[position]['uce'] for point in on], ['resistance_ohm', 'voltage_offset_v']) # Модель является локальной линейной ВАХ, а не постоянным сопротивлением IGBT во всех режимах.
if model: # QR-решатель дополнительно проверяет независимость тока и свободного члена.
training_current = [value for index, value in enumerate(currents) if index % 3 != 1] # Оценка неопределённости использует только наблюдения, участвовавшие в подборе коэффициентов.
average = sum(training_current) / len(training_current) # Центр обучающей выборки определяет ковариацию наклона и свободного члена.
spread = sum((value - average) ** 2 for value in training_current) # Знаменатель ковариации имеет единицу А² и не увеличивается отложенными контрольными точками.
model['slope_standard_error_ohm'] = math.sqrt(model['training_sse'] / max(1, len(training_current) - 2) / spread) # Стандартная ошибка оценивается по обучающим остаткам при независимом шуме и не покрывает систематическую ошибку датчика.
fits[side] = model # Сохраняем полные параметры и контрольную ошибку каждой ВАХ.
report['conduction'] = fits or dict(identifiable=False, reason='Нужны устойчивое включённое состояние и заметное изменение тока нагрузки.') # Неидентифицируемый наклон явно отличим от нулевого сопротивления.
if len(fits) == 2: # Сравнение деградации требует оценок обеих сторон на одном режиме нагрузки.
delta = fits['measured']['parameters']['resistance_ohm'] - fits['reference']['parameters']['resistance_ohm'] # Изменение дифференциального сопротивления измеряется в омах.
uncertainty = math.hypot(fits['measured']['slope_standard_error_ohm'], fits['reference']['slope_standard_error_ohm']) # Независимые статистические ошибки наклонов складываются по дисперсии.
report['conduction_change_ohm'] = delta # Численная разница доступна даже при недостаточной статистической значимости.
if delta > max(5. * uncertainty, 1e-9): # Порог пяти стандартных ошибок ограничивает реакции на один только случайный шум.
findings.append(dict(code='conduction', text=f'Совместная модель ВАХ: дифференциальное сопротивление выросло на {delta:.4g} Ом; возможны деградация ключа или сопротивление силового контакта.', model='Uce=Rdiff*Ic+U0')) # Обе физические причины дают похожий наклон и остаются альтернативами.
if len(on) >= 30 and len(off) >= 30: # Разность токов включённого и выключенного состояний отделяет утечку от постоянного нуля датчика.
on_error = [point[1]['ic'] - point[2]['ic'] for point in on] # Ошибка тока во включённом состоянии служит проверкой общей калибровки.
off_error = [point[1]['ic'] - point[2]['ic'] for point in off] # Остаточный ток при низком затворе является кандидатом модели утечки.
on_level, off_level = median(on_error), median(off_error) # Медианы подавляют отдельные импульсы коммутации.
noise = max(1e-9, 1.4826 * median([abs(value - on_level) for value in on_error] + [abs(value - off_level) for value in off_error])) # Множитель 1,4826 переводит MAD в оценку СКО нормального шума.
report['leakage'] = dict(formula='ΔIc_off=Ileak+b; ΔIc_on=b', off_current_a=off_level, on_offset_a=on_level, contrast_a=off_level - on_level, noise_sigma_a=noise) # Двухрежимная модель явно оценивает постоянную ошибку датчика b.
if off_level > 5. * noise and off_level - on_level > 5. * noise: # Одновременно нужен положительный ток выключения и отличие от общей ошибки нуля.
findings.append(dict(code='leakage', text=f'Совместная модель остаточного тока: при низком Uge дополнительный Ic≈{off_level - on_level:.4g} А; возможны утечка ключа или параллельная токовая цепь.', model='off_current')) # Посторонняя ветвь тока остаётся альтернативой утечке самого транзистора.
for event in events or []: # Сегменты предполагаемого дребезга проверяются по синхронным затвору и току.
interval = [point for point in points if event['start_us'] <= point[0] < event['stop_us']] # Проверяем только реально наблюдаемые точки внутри найденного прерывания.
if not interval: # Слишком короткий эпизод может не попасть в ограниченную совместную выборку.
continue # Отсутствие наблюдений не считается отсутствием провала.
gate_ratio = sum(point[1]['uge'] < .5 * point[2]['uge'] for point in interval) / len(interval) # Доля одновременного провала управления количественно проверяет причину повторного выключения.
current_ratio = sum(abs(point[1]['ic']) < .5 * abs(point[2]['ic']) for point in interval) / len(interval) # Провал тока ключа дополнительно подтверждает прерывание проводимости.
report['contact_events'].append(dict(**event, gate_drop_fraction=gate_ratio, current_drop_fraction=current_ratio, supported=gate_ratio >= .8 and current_ratio >= .8)) # Подтверждение требует совпадения обоих провалов хотя бы в 80% наблюдений эпизода.
return report, findings # Одновременные нарушения проводимости, утечки и контакта не скрывают друг друга.
+18 -6
View File
@@ -8,8 +8,10 @@ import ctypes as C
from dataclasses import dataclass
from functools import lru_cache
from protocan.native import NativeProtocol
from time import perf_counter # Измеряем полное время обработки запроса, включая преобразование входных и выходных буферов.
MAX_OUTPUT_POINTS = 1_000_000
BACKENDS = {"native": "Текущий МНК", "eigen": "Eigen QR"} # Стабильные ключи сохраняются в запросах и рецептах, а подписи показываются в GUI.
METHODS = {"polynomial": "Аппроксимация: полином МНК", "linear": "Интерполяция: линейная",
"pchip": "Интерполяция: PCHIP", "spline": "Восстановление: кубический сплайн"}
@@ -21,6 +23,8 @@ class Reconstruction:
unique_count: int
rmse: float
pieces: tuple = ()
backend: str = "native" # Сохраняем выбранный вариант расчёта для сравнения и подписей результатов.
elapsed_ms: float = 0.0 # Время полного вызова в миллисекундах позволяет оценивать стоимость преобразований вместе с решателем.
@lru_cache(maxsize=1)
def library():
@@ -36,7 +40,10 @@ def library():
except (AttributeError, OSError, RuntimeError) as error:
raise ValueError("Пересоберите SETProtocol с set_signal.c и set_wavegen.c") from error
def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True, with_model=False):
def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True, with_model=False, backend="native"): # Новый именованный параметр сохраняет все существующие позиционные вызовы.
started = perf_counter() # Начинаем замер до подготовки ctypes-массивов, одинаковой для двух решателей.
if backend not in BACKENDS or (backend == "eigen" and method != "polynomial"): # Eigen предлагается только для полиномиального МНК, а неизвестные ключи отклоняются явно.
raise ValueError("Eigen QR доступен только для полинома МНК; выберите корректный вариант расчёта") # Исключаем скрытое использование другого метода при сравнении.
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= MAX_OUTPUT_POINTS:
raise ValueError(f"Неизвестный метод или число выходных точек вне 2…{MAX_OUTPUT_POINTS}")
count = len(points)
@@ -45,20 +52,25 @@ def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint
x, y = (C.c_double * count)(*(p[0] for p in points)), (C.c_double * count)(*(p[1] for p in points))
ox, oy, meta = (C.c_double * output_count)(), (C.c_double * output_count)(), (C.c_double * 3)()
work = (C.c_double * (14 * count + 128))()
code = library().set_signal_reconstruct(x, y, count, list(METHODS).index(method), degree,
output_count, int(endpoint), ox, oy, meta, work, len(work))
core = library() # Существующая библиотека остаётся источником базового алгоритма и каталога установленного модуля.
operation = core.set_signal_reconstruct # По умолчанию сохраняем исходный C99-решатель и его бинарный контракт.
if backend == "eigen": # Загружаем дополнительный компонент только после явного выбора пользователя.
from .signal_eigen import library as eigen_library # Ленивый импорт не требует наличия Eigen при обычной работе и в прошивках.
operation = eigen_library(core._name).set_signal_reconstruct_eigen # Соседняя DLL реализует тот же контракт входов, выходов и work.
code = operation(x, y, count, list(METHODS).index(method), degree, output_count, int(endpoint), ox, oy, meta, work, len(work)) # Оба решателя получают одинаковые данные и настройки сетки.
if code:
raise ValueError({1: "Недостаточно точек для выбранной степени или неверные параметры",
2: "Нужны конечные значения и минимум две различные временные точки",
3: "Неустойчивая аппроксимация: уменьшите степень"}.get(code, "Ошибка расчёта"))
3: "Неустойчивая аппроксимация: уменьшите степень", 5: "Недостаточно памяти или ошибка решателя Eigen"}.get(code, "Ошибка расчёта")) # Дополнительная настольная ошибка не меняет прежние коды C99.
pieces = ()
if with_model:
from .plot_formula import reconstruction_pieces
if method == 'polynomial' and output_count <= degree:
pieces = reconstruct(points, method, degree + 1, degree, with_model=True).pieces
pieces = reconstruct(points, method, degree + 1, degree, endpoint=endpoint, with_model=True, backend=backend).pieces # Короткий выход требует отдельной сетки для формулы; сохраняем решатель и режим конечной точки.
else:
pieces = reconstruction_pieces(work, count, int(meta[1]), method, degree, oy, endpoint)
return Reconstruction(list(zip(ox, oy)), int(meta[0]), int(meta[1]), meta[2], pieces)
result_points = list(zip(ox, oy)) # Включаем формирование выходных Python-точек в отображаемое время обработки.
return Reconstruction(result_points, int(meta[0]), int(meta[1]), meta[2], pieces, backend, (perf_counter() - started) * 1000) # Переводим секунды монотонного таймера в миллисекунды без влияния изменения системных часов.
def dac12(volts, vref=3.3):
data = (C.c_double * len(volts))(*volts)
+168
View File
@@ -0,0 +1,168 @@
"""Сравнение полной записи с CSV-эталоном независимо от Qt и приложения.""" # Ядро сохраняет все исходные отсчёты.
from array import array # Массивы double компактно хранят амплитуды без потери точности.
from dataclasses import dataclass, field # Контейнеры связывают кривые, показатели и независимый словарь анализа каждого результата.
from set_devices.signal_diagnostics import analyze_comparison # Общий локальный анализ работает без GUI и выполняется вместе со сравнением в worker.
import csv # Потоковый parser поддерживает разделители и кавычки CSV.
from datetime import datetime # Абсолютное ISO-время переводится в миллисекунды.
import math # Проверки конечности отделяют корректные измерения от пропусков.
@dataclass # Создаём конструктор независимого от GUI контейнера кривой.
class Trace: # Временная шкала каждого канала измеряется в миллисекундах.
name: str # Имя канала используется в выборе, легенде и отчёте.
times: list # Строго возрастающие метки могут быть общими для каналов CSV.
values: array # Амплитуды в единицах канала; NaN обозначает разрыв записи.
@dataclass # Передаём вычисленный результат между слоями приложения единым объектом.
class Comparison: # Полные кривые отделены от экранной огибающей.
measured: Trace # Измерение со временем относительно первого отсчёта.
reference: Trace # Эталон после совмещения начал и временного сдвига.
difference: Trace # Остаток на общей части временной сетки измерения.
expected: array # Интерполированный эталон на сетке остатка, включая пропуски.
metrics: dict # Показатели ошибки и параметры воспроизводимого расчёта.
analysis: dict = field(default_factory=dict) # Необязательное поле сохраняет совместимость прежнего конструктора с пятью аргументами.
def checkpoint(cancel): # Callback позволяет прерывать расчёт без зависимости от потоковой библиотеки.
if cancel(): # Проверяем запрос в контрольных точках прохода по данным.
raise InterruptedError('Расчёт отменён') # Не публикуем частичный результат после отмены.
def trace_from_pairs(name, points, transform=float, cancel=lambda: False): # Преобразование амплитуд задаёт потребитель, например множитель и IQ.
times, values = [], array('d') # Сохраняем время списком, амплитуды — 64-битными double.
previous = -math.inf # Первая конечная метка допустима независимо от её знака.
for index, (stamp, value) in enumerate(points): # Обходим полную историю без экранного сокращения.
if index % 16384 == 0: # Проверяем отмену порциями по 16384 точки для снижения накладных расходов.
checkpoint(cancel) # Прерываем подготовку по запросу вызывающей стороны.
if not math.isfinite(stamp) or stamp <= previous: # Повторы и обратный ход времени делают интерполяцию неоднозначной.
raise ValueError('Время должно строго возрастать, без повторов') # Не исправляем исходную сетку молча.
times.append(stamp) # Дробное время сохраняется без округления.
values.append(transform(value)) # Применяем масштаб до сравнения ровно один раз.
previous = stamp # Следующая точка должна находиться строго правее текущей.
if not times: # Пустая запись не имеет начала для совмещения.
raise ValueError('В канале нет отсчётов') # Сообщаем отсутствие исходных данных.
return Trace(name, times, values) # Возвращаем снимок, независимый от изменяемого живого буфера.
def read_reference(path, cancel=lambda: False): # Путь задаётся потребителем без правил хранения конкретного приложения.
"""Читает CSV потоково, сохраняя пустые ячейки и NaN как пропуски.""" # Разрывы впоследствии исключаются из статистики.
with open(path, encoding='utf-8-sig', newline='') as stream: # Поддерживаем UTF-8 с необязательным BOM и штатную обработку CSV-переносов.
first = stream.readline() # Заголовок определяет разделитель столбцов.
delimiter = ';' if ';' in first else '\t' if '\t' in first else ',' # Принимаем точку с запятой, табуляцию и запятую.
stream.seek(0) # Заголовок должен также попасть в CSV-parser.
rows = csv.reader(stream, delimiter=delimiter) # Читаем построчно без хранения текста всего файла.
header = [s.strip() for s in next(rows, [])] # Пробелы вокруг имён не являются частью имени канала.
if len(header) < 2 or header[0] not in ('time_ms', 'timestamp'): # Требуем явную единицу времени и хотя бы один канал.
raise ValueError('Эталон: первый столбец должен быть time_ms или timestamp') # Не угадываем шкалу неизвестного формата.
names = header[1:] # Остальные столбцы содержат амплитуды каналов.
if any(not name for name in names) or len(set(names)) != len(names): # Имена должны однозначно определять столбец.
raise ValueError('Эталон: требуются непустые уникальные имена каналов') # Предотвращаем потерю повторного столбца в словаре.
times = [] # Одна временная сетка разделяется всеми каналами файла.
traces = {name: Trace(name, times, array('d')) for name in names} # Каждый канал получает собственный массив амплитуд.
for index, row in enumerate(rows, 2): # Первая строка данных имеет номер 2 после заголовка.
if index % 16384 == 0: # Проверяем отмену раз в 16384 строки большого CSV.
checkpoint(cancel) # Закрытие окна не требует дочитывать весь файл.
if len(row) != len(header): # Каждая строка должна соответствовать заявленным столбцам.
raise ValueError(f'Эталон, строка {index}: неверное число столбцов') # Номер строки помогает найти повреждение.
if header[0] == 'time_ms': # Относительное время уже выражено во внутренних миллисекундах.
stamp = float(row[0]) # Сохраняем дробные доли миллисекунды.
else: # timestamp задаёт абсолютное ISO-время с часовым поясом.
date = datetime.fromisoformat(row[0].replace('Z', '+00:00')) # Суффикс UTC нормализуется также для Python 3.8.
if date.tzinfo is None: # Время без пояса зависит от настроек компьютера.
raise ValueError('timestamp эталона должен содержать часовой пояс') # Исключаем неоднозначное абсолютное время.
stamp = date.timestamp() * 1000 # Множитель 1000 переводит UNIX-секунды в миллисекунды.
if not math.isfinite(stamp) or (times and stamp <= times[-1]): # Сетка обязана быть конечной и строго возрастающей.
raise ValueError(f'Эталон, строка {index}: время должно строго возрастать') # Не сортируем и не удаляем отсчёты автоматически.
times.append(stamp) # Добавляем общую метку всех каналов текущей строки.
for trace, text in zip(traces.values(), row[1:]): # Порядок каналов соответствует порядку заголовка.
value = float(text) if text.strip() else math.nan # Пустая ячейка обозначает разрыв, а не нулевую амплитуду.
if math.isinf(value): # Бесконечность считается повреждением данных, в отличие от NaN.
raise ValueError(f'Эталон, строка {index}: бесконечное значение') # Указываем строку недопустимой амплитуды.
trace.values.append(value) # Сохраняем каждое значение без децимации.
if len(times) < 2: # Для линейной интерполяции необходим хотя бы один отрезок.
raise ValueError('Эталон должен содержать хотя бы две временные точки') # Одноточечный эталон не публикуется.
checkpoint(cancel) # Проверяем отмену и для короткого файла перед возвратом.
return traces # Выбор конкретного канала остаётся вызывающей стороне.
def compare(measured, reference, *, align_start=True, shift_ms=0., # Положительный сдвиг задерживает эталон на заданные миллисекунды.
interval=None, absolute_tolerance=0., relative_tolerance_pct=0., # Интервал отсчитывается от начала измерения; допуск состоит из двух частей.
cancel=lambda: False, circuit=None, channels=None): # Необязательные параметры контура и связанные каналы расширяют анализ без изменения прежних вызовов.
"""Интерполирует эталон на сетку измерения без экстраполяции и заполнения NaN.""" # Статистика использует все корректные исходные точки.
if not measured.times or len(reference.times) < 2: # Нужны начало измерения и две опоры эталона.
raise ValueError('Недостаточно данных для сравнения') # Пустые данные не должны давать нулевую ошибку.
if any(not math.isfinite(x) for x in (shift_ms, absolute_tolerance, relative_tolerance_pct)): # Неконечные параметры нарушают координаты и допуск.
raise ValueError('Параметры сравнения должны быть конечными числами') # Сообщаем причину до начала прохода.
if absolute_tolerance < 0 or relative_tolerance_pct < 0: # Коридор допуска имеет неотрицательную ширину.
raise ValueError('Допуск должен быть неотрицательным') # Отрицательный допуск не имеет физического смысла.
if interval is not None and (not all(math.isfinite(x) for x in interval) or interval[0] >= interval[1]): # Обе границы должны образовывать конечный непустой интервал.
raise ValueError('Начало интервала должно быть меньше конца') # Не переставляем ошибочно введённые границы.
origin = measured.times[0] # Начало измерения задаёт ноль результирующей шкалы времени.
ref_origin = reference.times[0] if align_start else origin # Опция совмещения отделяет относительное сравнение от абсолютного.
mt = [t - origin for t in measured.times] # Нормализуем временную сетку измерения без изменения амплитуд.
rt = [t - ref_origin + shift_ms for t in reference.times] # Совмещаем начало и применяем сдвиг эталона вправо.
left, right = interval if interval is not None else (mt[0], mt[-1]) # По умолчанию используем полную длительность измерения.
times, differences, expected = [], array('d'), array('d') # Остаток и интерполированный эталон получают общую сетку.
count = candidates = outside = invalid = beyond = 0 # Считаем сравнения, выбранные точки, отсутствие покрытия, пропуски и превышения.
mean = m2 = mean_abs = mean_square = 0. # Потоковые накопители среднего, дисперсии, MAE и среднего квадрата.
peak, peak_time, first_outside = -1., None, None # Начальный максимум ниже нуля регистрирует и полностью совпадающие кривые.
ref_min, ref_max = math.inf, -math.inf # Размах определяется на реально сравниваемой части эталона.
j = 0 # Указатель отрезка эталона перемещается только вперёд.
rv = reference.values # Локальная ссылка уменьшает накладные расходы основного прохода.
for index, (stamp, value) in enumerate(zip(mt, measured.values)): # Перебираем каждый исходный отсчёт измерения.
if index % 16384 == 0: # Проверка раз в 16384 точки ограничивает стоимость поддержки отмены.
checkpoint(cancel) # Прерываем устаревший или отменённый расчёт.
if stamp < left or stamp > right: # Границы интервала включаются, внешние точки исключаются.
continue # Посторонние участки записи не влияют на статистику.
candidates += 1 # Покрытие относится ко всем выбранным точкам измерения.
if stamp < rt[0] or stamp > rt[-1]: # Вне временной сетки эталона экстраполяция запрещена.
outside += 1 # Отдельно учитываем отсутствие покрытия по времени.
continue # Не подставляем крайнее значение за пределами записи.
while j + 1 < len(rt) and rt[j + 1] <= stamp: # Находим левую опору без повторного поиска с начала.
j += 1 # Однонаправленное продвижение сохраняет линейную сложность.
target = rv[j] # Точное совпадение с узлом не зависит от соседних пропусков.
if stamp != rt[j]: # Между узлами используем две соседние опоры.
fraction = (stamp - rt[j]) / (rt[j + 1] - rt[j]) # Доля положения внутри временного отрезка безразмерна.
target = target + fraction * (rv[j + 1] - target) # NaN любой опоры сохраняет разрыв интерполяции.
times.append(stamp) # Сохраняем общую временную сетку, включая пропуски.
expected.append(target) # Интерполированные амплитуды доступны для дальнейшего анализа.
if not math.isfinite(value) or not math.isfinite(target): # Некорректные значения любой кривой исключаются из показателей.
invalid += 1 # Пропуски уменьшают процент покрытия интервала.
differences.append(math.nan) # График остатка сохраняет разрыв на этом месте.
continue # Не загрязняем численные накопители значениями NaN.
difference = value - target # Знак ошибки всегда означает измерение минус эталон.
differences.append(difference) # Каждая корректная точка получает собственный остаток.
count += 1 # Число сравнений служит знаменателем средних показателей.
delta = difference - mean # Алгоритм Уэлфорда использует предыдущее среднее.
mean += delta / count # Обновляем среднее смещение без накопления большой суммы.
m2 += delta * (difference - mean) # Центральный момент устойчиво оценивает разброс остатка.
magnitude = abs(difference) # Модуль нужен для MAE, максимума и допуска.
mean_abs += (magnitude - mean_abs) / count # Потоково обновляем среднюю абсолютную ошибку.
mean_square += (difference * difference - mean_square) / count # Средний квадрат затем преобразуется в RMSE.
ref_min, ref_max = min(ref_min, target), max(ref_max, target) # Нормализация использует фактический размах эталона.
if magnitude > peak: # При равных максимумах оставляем время первого.
peak, peak_time = magnitude, stamp # Величина и время максимума относятся к одной точке.
if magnitude > absolute_tolerance + relative_tolerance_pct * .01 * abs(target): # Множитель 0,01 переводит процент в долю модуля эталона.
beyond += 1 # Равенство границе допустимо; учитываем строгое превышение.
if first_outside is None: # Время первого выхода не заменяется последующими.
first_outside = stamp # Сохраняем миллисекунды от начала измерения.
checkpoint(cancel) # Не публикуем результат, отменённый в последней порции.
if not count: # Без корректного перекрытия показатели не определены.
raise ValueError('Нет общих корректных отсчётов в выбранном интервале') # Вызывающая сторона сбрасывает старые показатели.
rmse = math.sqrt(max(0., mean_square)) # Защита от отрицательного округления перед извлечением корня.
metrics = dict(count=count, selected_points=candidates, outside_reference=outside, # Различаем использованные, выбранные и непокрытые точки.
invalid_points=invalid, coverage_pct=100. * count / candidates, # Покрытие выражается в процентах от выбранных точек.
bias=mean, mae=mean_abs, rmse=rmse, error_std=math.sqrt(max(0., m2 / count)), # СКО характеризует всю сравниваемую запись.
max_abs=peak, max_abs_time_ms=peak_time, # Максимум хранится в единицах канала, время — в миллисекундах.
nrmse_span_pct=100. * rmse / (ref_max - ref_min) if ref_max > ref_min else None, # Постоянный эталон не имеет ненулевого размаха для нормализации.
outside_tolerance=beyond, outside_tolerance_pct=100. * beyond / count, # Превышения считаются только среди корректных сравнений.
first_outside_time_ms=first_outside, # None явно означает отсутствие выхода за допуск.
absolute_tolerance=absolute_tolerance, relative_tolerance_pct=relative_tolerance_pct, # Сохраняем параметры коридора для отчёта.
align_start=align_start, shift_ms=shift_ms, interval_ms=interval, reference_min=ref_min, reference_max=ref_max) # Настройки и пределы сравниваемого эталона обеспечивают воспроизводимость анализа.
result = Comparison(Trace(measured.name, mt, measured.values), # Повторно используем полный массив амплитуд измерения.
Trace(reference.name, rt, reference.values), # Полный эталон доступен для наложения на график.
Trace('Измерение − эталон', times, differences), expected, metrics) # Остаток и показатели относятся ко всем корректным исходным точкам.
context = dict(channels, measured_origin_ms=origin, reference_origin_ms=ref_origin, shift_ms=shift_ms) if channels else None # Связанные каналы сохраняют взаимное положение относительно выбранного измерения и единого сдвига эталона.
result.analysis = analyze_comparison(result, cancel, circuit=circuit, channels=context) # Идентификация моделей использует тот же снимок данных и явно заданные электрические ограничения.
return result # Публикуем только целое сравнение вместе с завершёнными гипотезами.
+6 -6
View File
@@ -17,7 +17,7 @@ class Waveform:
vref: float
period_ms: float
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2, *, backend="native"): # Именованный выбор решателя сохраняет прежние вызовы генератора.
"""One cyclic period, last endpoint excluded (no duplicated seam sample)."""
if type(sample_rate) is not int or not 1 <= sample_rate <= 1000000:
raise ValueError("Частота отсчётов должна быть 1…1000000 Гц")
@@ -36,23 +36,23 @@ def generate(points, sample_rate=1000, vref=3.3, method="pchip", degree=2):
raise ValueError("Период × частота должны давать от 2 до 4096 отсчётов")
if abs(exact_count - count) > 1e-7:
raise ValueError("Период должен содержать целое число отсчётов при выбранной частоте")
result = reconstruct(points, method, count, degree, endpoint=False)
result = reconstruct(points, method, count, degree, endpoint=False, backend=backend) # Формируем циклическую таблицу выбранным пользователем вариантом МНК.
# Check the endpoint too: polynomial fitting can move its value.
checked = reconstruct(points, method, count + 1, degree)
checked = reconstruct(points, method, count + 1, degree, backend=backend) # Конечную точку проверяем тем же решателем, исключая расхождение проверки диапазона ЦАП и результата.
dac12([p[1] for p in checked.points], vref)
codes = dac12([p[1] for p in result.points], vref)
return Waveform(tuple(result.points), codes, sample_rate, vref, period)
def recipe(points, sample_rate, vref, method, degree):
def recipe(points, sample_rate, vref, method, degree, *, backend="native"): # Новый необязательный параметр сохраняет совместимость ранее записанных рецептов версии 1.
return {"version": 1, "points_ms_volts": [list(pair) for pair in points], "sample_rate": sample_rate,
"vref": vref, "method": method, "degree": degree}
"vref": vref, "method": method, "degree": degree, "backend": backend} # Сохраняем выбранный решатель для воспроизводимого восстановления сигнала.
def load_recipe(text):
data = json.loads(text)
if not isinstance(data, dict) or data.get("version") != 1:
raise ValueError("Неизвестная версия задания генератора")
points = tuple((float(x), float(y)) for x, y in data["points_ms_volts"])
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"])
generate(points, data["sample_rate"], data["vref"], data["method"], data["degree"], backend=data.get("backend", "native")) # Старые рецепты без варианта используют прежний алгоритм, новые проверяют именно сохранённый решатель.
return points, data
def c_header(wave):
+16
View File
@@ -68,3 +68,19 @@ def test_confirm_and_progress_status_correlate_session():
assert reply['status'] == 0 and reply['command'] == 8 and reply['next_block'] == 123
assert service.decode(frame, discovery=True) is None
assert CanBootServices(CanBootTarget(device_type=3, device=7, session_id=6)).decode(frame) is None
@pytest.mark.parametrize("code,expected", [(0, 0), (0, 8), (0, 16), (8, 0), (8, 8), (8, 16)]) # Проверяет повторные и запоздалые OK/SEQUENCE_ERROR после подтверждённого первого окна.
def test_duplicate_data_status_does_not_resend_or_rewind_window(code, expected): # Один поздний ответ не должен порождать дополнительные 16 кадров в очереди MCU.
target = CanBootTarget(device_type=1, device=8, product_type=0x2833) # Использует адрес задатчика, на котором наблюдалось зависание передачи.
client = CanBootTransfer(SimpleNamespace(data=bytes(512), version=1, crc32=0), target) # Несколько окон позволяют проверить ответ до завершения передачи.
def status_frame(command, status_code=0, next_block=0): # Формирует тестовый STATUS в той же сессии, что запрос клиента.
return build_frame(ProtoCanId.build(1, 1, 1, 8, 12, 0x100 | command), struct.pack("<BBHI", status_code, 0, next_block, 0), to_can=False) # Session 1 и слот A отделяют проверку повторов от адресной фильтрации.
client.start() # Начинает обычную последовательность ENTER_BOOT и метаданных.
for command in (2, 3, 4, 5): # Переводит production-клиент к первому окну через штатные подтверждения этапов.
client.handle_status(status_frame(command)) # Каждый управляющий ответ разрешает только следующий этап протокола.
outgoing, _ = client.handle_status(status_frame(0, next_block=16)) # Подтверждает первое окно и ставит в отправку блоки 16–31.
assert len(outgoing) == 16 and client.next_block == 16 # Контрольное состояние должно содержать новое неподтверждённое окно.
for _ in range(16): # Остаток прежнего окна способен вернуть до 16 одинаковых ошибок последовательности.
assert client.handle_status(status_frame(0, code, expected)) == ([], "") # Дубликаты не пересылают текущее окно и не отодвигают транспортный тайм-аут.
assert client.next_block == 16 and client.stage == "data" # Запоздалый SEQUENCE_ERROR не откатывает уже подтверждённые блоки.
+19
View File
@@ -0,0 +1,19 @@
"""Общие DEIF-векторы проверяют ctypes теми же данными, что C и Android/JVM.""" # Тесты не требуют Qt или структуры Desktop-приложения.
from pathlib import Path # Fixture входит в общий репозиторий templates.
import pytest # Параметризация даёт независимый результат для каждой эталонной строки.
from set_devices.deif_native import get_deif_native # Python не использует альтернативный декодер при отсутствии C.
from set_devices.deif_display import DeifDisplay # Пассивная модель является общим API библиотеки.
FIXTURE = Path(__file__).resolve().parents[2] / 'c/set-protocol/tests/fixtures/deif-xdi-v1.tsv' # Точный общий источник тестов для трёх портов.
CASES = [line.split() for line in FIXTURE.read_text(encoding='utf-8').splitlines() if line and not line.startswith('#')] # Здесь разбирается текст fixture, а не протокол CAN.
@pytest.mark.parametrize('row', CASES) # Каждый ошибочный или валидный wire-кадр проверяется отдельно.
def test_ctypes_matches_c_and_jni_vectors(row): # Числовой результат должен совпасть с независимым описанием ожидаемого кадра.
identifier, payload, flags, result, raw, index, sub = row # Столбцы fixture не зависят от внутреннего layout C.
flags = int(flags) # EXT/RTR являются входными метаданными теста.
code, meta = get_deif_native().inspect_frame(int(identifier, 16), bytes.fromhex(payload), bool(flags & 1), bool(flags & 2)) # Wire-разбор выполняется единственным C-кодеком.
assert (code, meta[4], meta[2], meta[3]) == (int(result), int(raw), int(index, 16), int(sub, 16)) # RAW и адрес объекта совпадают с C- и Kotlin-проверкой.
def test_passive_angle_is_received_without_gui_or_simulation(): # Ошибка со снимка воспроизводится без запуска серверных сервисов.
model = DeifDisplay(source='CAN / мост', device_node=13) # Физический адрес SLAVE не становится источником SAM.
assert model.accept('CAN / мост', 0x201, bytes.fromhex('01 01 30 07 84 03 00 00'), now=10) # Точный кадр пользователя несёт RAW 900.
assert model.angle_selection(now=10) == (90.0, True) # Сетевое задание видно как 90°, с явной пометкой.
assert model.value('angle', now=10) is None # Принятое задание не превращается в фиктивный фактический объект 0x02.
assert model.angle_selection(now=15.001) == (None, False) # При полном молчании наблюдатель уважает прежний срок свежести пять секунд.
+4 -1
View File
@@ -126,7 +126,10 @@ def test_legacy_can_full_transaction_and_retransmission():
assert all(ProtoCanId.parse(f.can_id).msg_type == 11 for f in frames)
frames, _ = t.handle_status(status(t, 0, code=8, expected=8))
assert ProtoCanId.parse(frames[0].can_id).body == 8
assert frames[-1].data == bytes([128,129]) + b"\xff" * 6
assert [ProtoCanId.parse(frame.can_id).body for frame in frames] == list(range(8, 16)) # Восстановление повторяет хвост до абсолютной границы ACK 16 и не посылает преждевременно блок следующего окна.
frames, _ = t.handle_status(status(t, 0, expected=16)) # Настоящее ядро подтверждает блок 16 до передачи последнего неполного блока 16.
assert len(frames) == 1 and ProtoCanId.parse(frames[0].can_id).body == 16 # После ACK предыдущего окна остаётся ровно один блок образа из 130 байт.
assert frames[0].data == bytes([128,129]) + b"\xff" * 6 # Дополнение последнего блока до восьми байт сохраняет прежний wire-формат.
frames, _ = t.handle_status(status(t, 0, expected=17))
assert t.stage == "verify"
for cmd in (6, 7, 9):
@@ -0,0 +1,141 @@
"""Контракт сравнения проверяется с чужим canvas без импорта приложения.""" # Фиксируем возможность повторного использования Qt-порта.
import json # Сохранённый отчёт проверяется как машиночитаемые данные.
from pathlib import Path # Временный CSV и отчёт используют явные пути.
import tempfile # Проверки не затрагивают пользовательские файлы.
import time # Монотонный срок ограничивает ожидание worker.
from types import SimpleNamespace # Источник и кривая не зависят от моделей приложения.
import unittest # Набор можно запустить вне тестового окружения потребителя.
from unittest.mock import patch # Подменяем выбор файла, сохраняя реальную запись JSON.
try: # Используем доступную в окружении версию Qt.
from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Qt6 предоставляет базовые элементы тестового canvas.
except ImportError: # Qt5 проверяется непосредственно, без слоя совместимости приложения.
from PySide2.QtWidgets import QApplication, QCheckBox, QDialog, QWidget # Публичный порт обязан поддерживать оба binding.
from protocan.plot import Bounds, Viewport # Общие контейнеры координат не требуют конкретного рендерера.
from set_devices.qt_ports.reference_comparison import ReferenceComparisonDialog, ReferenceComparisonPanel # Импортируем общий порт напрямую.
from set_devices.qt_ports.reference_overlay import ReferenceOverlayPanel # Прямое наложение предоставляет только параметры и данные, не создавая canvas.
class Canvas(QWidget): # Минимальный сторонний график реализует документированный контракт.
def __init__(self): # В тесте отсутствует зависимость от графика приложения.
super().__init__() # QSplitter принимает обычный QWidget.
self._series = [] # Панель заполняет кривые через внедрённую фабрику.
self.axis_lock_x, self.axis_lock_y = QCheckBox(self), QCheckBox(self) # Флажки поддерживают снятие фиксации осей.
self.axis_limits = None # Ручные границы сбрасываются при новом результате.
self.reset_interaction() # Начинаем с нейтрального масштаба.
self._sync_bounds() # Задаём известные границы тестовой записи.
def reset_interaction(self): # Общая панель сбрасывает навигацию перед показом кривых.
self.viewport = Viewport() # Нормированная область начинается с полного масштаба.
def _sync_bounds(self): # Тестовым трём отсчётам достаточно постоянной общей области.
self.visible_bounds = Bounds(0., 2., 0., 10.) # Интервал 0–2 мс и амплитуда 0–10 охватывают контрольные данные.
def make_series(key, name, **data): # Формат контейнера кривой определяет потребитель.
return SimpleNamespace(key=key, name=name, **data) # Принимаем полные массивы и цвет без дополнительных преобразований.
class ReferencePortTests(unittest.TestCase): # Проверяем интеграционный контракт независимо от GUI-потребителя.
@classmethod # Один экземпляр QApplication обслуживает весь процесс проверок.
def setUpClass(cls): # Доставка сигналов worker требует реального приложения Qt.
cls.app = QApplication.instance() or QApplication([]) # Повторно используем существующий цикл событий при совместном запуске тестов.
def setUp(self): # Данные каждого сценария изолированы от остальных.
self.temp = tempfile.TemporaryDirectory() # Каталог удаляется после завершения всех операций.
self.root = Path(self.temp.name) # Потребитель явно задаёт начальную папку.
self.path = self.root / 'reference.csv' # Файл хранит известный линейный эталон.
self.path.write_text('time_ms,U (V)\n0,1\n1,3\n2,5\n', encoding='utf-8') # Три амплитуды позволяют проверить полный остаток и число точек.
self.sources = [(SimpleNamespace(name='U (V)', display_value=lambda value: value * 2), [(0., 1.), (1., 2.), (2., 3.)])] # После внешнего множителя измерение отличается от эталона на 1 В.
self.panels = [] # Созданные виджеты закрываются до удаления файлов.
def tearDown(self): # Очистка обязательна даже при ошибке утверждения.
for panel in self.panels: # Завершаем все экземпляры панели и диалога.
panel.close() # Закрытие останавливает worker или запрашивает его отмену.
self.wait_idle(panel) # Ожидаем завершение до удаления QObject.
panel.deleteLater() # Дочерние элементы освобождаются очередью Qt.
self.app.processEvents() # Доставляем отложенные сигналы завершённых заданий.
self.temp.cleanup() # Фоновые операции больше не используют файлы.
def create(self, cls=ReferenceComparisonPanel, sources=None): # Один контракт подключает встроенную панель и отдельное окно.
panel = cls(self.sources if sources is None else sources, source_path='capture.csv', plot_factory=Canvas, series_factory=make_series, path_provider=lambda name: str(self.root / name)) # Все зависимости передаются извне без импорта прикладного GUI.
self.panels.append(panel) # Регистрируем объект для гарантированного завершения.
return panel # Сценарий использует обычные методы общего порта.
def wait_idle(self, panel): # Короткие задания не должны зависеть от скорости машины.
deadline = time.monotonic() + 5 # Пять секунд ограничивают время ожидания трёхточечного файла.
while (panel._worker is not None or getattr(panel, '_pending', False) or (hasattr(panel, '_timer') and panel._timer.isActive())) and time.monotonic() < deadline: # Для слоя эталона ждём также автоматически запланированный расчёт.
self.app.processEvents() # Доставляем result и finished без зависимости от QtTest.
time.sleep(.001) # Одна миллисекунда освобождает процессор для worker.
self.assertIsNone(panel._worker) # Дальнейшие проверки выполняются только после остановки задания.
self.assertFalse(getattr(panel, '_pending', False)) # Новый снимок параметров также должен быть обработан.
def test_custom_canvas_transform_and_paths(self): # Проверяем пользовательский цикл на независимом canvas.
panel = self.create() # Фабрики графика и кривой принадлежат тестовому потребителю.
panel.load_reference(self.path) # Чтение CSV выполняется настоящим фоновым потоком.
self.wait_idle(panel) # Сравнение запускается после принятия эталона.
panel._calculate() # Применяем внешний множитель к полному измерению.
self.wait_idle(panel) # Ждём формирования таблицы и кривых.
self.assertIsInstance(panel.plot, Canvas) # Библиотека не навязывает собственный canvas.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Ошибка 1 В подтверждает корректность преобразования источника.
self.assertEqual([2., 4., 6.], list(panel.plot._series[0].values)) # Фабрика получает все исходные амплитуды после преобразования.
panel.mode.setCurrentIndex(1) # Переходим от наложения к отклонению без нового расчёта.
self.assertEqual([1., 1., 1.], list(panel.plot._series[0].values)) # Остаток сохраняет ожидаемый знак на каждой точке.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getOpenFileName', return_value=('', '')) as choose: # Отмена выбора позволяет проверить только начальную папку.
panel._open_reference() # Вызываем штатный обработчик открытия эталона.
self.assertEqual(str(self.root), choose.call_args[0][2]) # Путь предоставляется потребителем библиотеки.
output = self.root / 'report.json' # Экспорт направляется в изолированный каталог теста.
with patch('set_devices.qt_ports.reference_comparison.QFileDialog.getSaveFileName', return_value=(str(output), '')) as choose: # Подменяем только выбор имени файла.
panel._save_report() # Реальная запись проверяет UTF-8 и сериализацию показателей.
self.assertEqual(str(self.root / 'reference_comparison.json'), choose.call_args[0][2]) # Предложенное имя отчёта также проходит через provider.
report = json.loads(output.read_text(encoding='utf-8')) # Читаем результат с диска, не подменяя алгоритм экспорта.
self.assertEqual(('capture.csv', 3), (report['measured_file'], report['metrics']['count'])) # Отчёт сохраняет происхождение и полное число сравнённых точек.
def test_dialog_and_empty_source_contract(self): # Отдельное окно сохраняет совместимость без возможности расчёта пустого источника.
dialog = self.create(ReferenceComparisonDialog, sources=[]) # Источник может временно отсутствовать у другого потребителя.
self.assertIsInstance(dialog, QDialog) # Сохраняется настоящий API QDialog, а не имитация через QWidget.
dialog.load_reference(self.path) # Одного эталона недостаточно для численного сравнения.
self.wait_idle(dialog) # После чтения проверяем окончательное состояние элементов.
self.assertFalse(dialog.calculate_button.isEnabled()) # Не допускаем обращения к индексу -1 пустого списка.
dialog.reject() # Прежний способ закрытия отдельного окна поддерживается напрямую.
def test_overlay_loads_automatically_without_canvas(self): # Пользовательский потребитель получает кривую сразу после чтения CSV.
panel = ReferenceOverlayPanel(self.sources, source_path='capture.csv', path_provider=lambda name: str(self.root / name)) # Фабрики canvas и новой таблицы не нужны.
self.panels.append(panel) # Гарантируем остановку worker до удаления временных файлов.
events = [] # Сигнал сообщает существующему графику о готовом дополнительном слое.
panel.changed.connect(lambda: events.append(True)) # Не создаём даже тестовый canvas ради получения кривой.
panel.load_reference(self.path) # После чтения статистика и временное совмещение запускаются автоматически.
self.wait_idle(panel) # Ожидаем обе стадии фонового задания.
specs, data = panel.overlay() # Потребитель добавляет эти данные к своим исходным кривым.
self.assertFalse(hasattr(panel, 'plot')) # Слой принципиально не может подменить исходный canvas.
self.assertEqual(1, len(events)) # Один файл публикует одно готовое наложение.
self.assertEqual('Эталон: U (V)', specs[0][1]) # Легенда различает оригинал и эталон с одинаковым именем канала.
self.assertEqual([(0., 1.), (1., 3.), (2., 5.)], list(data[panel.RESULT_KEY])) # Сохраняем полную сетку и исходные амплитуды эталона.
self.assertEqual(1., panel.comparison.metrics['rmse']) # Внешнее преобразование измерения учитывается в статистике.
self.assertTrue(panel.report_button.isEnabled()) # Автоматический расчёт делает доступным актуальный отчёт.
self.assertEqual(14, panel.results.table.rowCount()) # Полные расчёты доступны переносимому потребителю вместе с данными наложения.
self.assertEqual('1', panel.results.table.item(6, 1).text()) # Восстановленная таблица содержит фактический RMSE, а не только подписи.
self.assertTrue(panel.results.export_button.isEnabled()) # Экспорт доступен непосредственно рядом с подробной таблицей.
output = self.root / 'detailed-report.json' # Подробная область сохраняет актуальный расчёт в изолированный файл.
with patch('set_devices.qt_ports.reference_overlay.QFileDialog.getSaveFileName', return_value=(str(output), '')): # Подменяем только выбор места, сохраняя обработчик экспорта.
panel.results.export_button.click() # Проверяем кнопку восстановленной подробной таблицы.
self.assertEqual(1., json.loads(output.read_text(encoding='utf-8'))['metrics']['rmse']) # JSON содержит фактическое отклонение этого результата.
panel.results.delta_curve.setChecked(True) # Добавление остатка не требует собственной фабрики графика.
specs, data = panel.overlay() # Потребитель получает эталон и отдельный слой отклонения вместе.
self.assertEqual(2, len(specs)) # Обе дополнительные кривые можно добавить к сохранённому оригиналу.
self.assertEqual([1., 1., 1.], list(data[panel.DIFFERENCE_KEY].values)) # Численные остатки совпадают с преобразованными амплитудами измерения.
panel.set_sources([(self.sources[0][0], [(10., 1.), (11., 2.), (12., 3.)])]) # Новый оригинальный кадр не требует повторного выбора файла эталона.
self.wait_idle(panel) # Совмещение времени обновляется после смены источника.
self.assertEqual([10., 11., 12.], panel.trace.times) # Эталон переходит на исходную временную шкалу нового измерения.
self.assertEqual([10., 11., 12.], panel.difference.times) # Остаток следует той же исходной шкале времени без скрытого обнуления.
panel.shift.setValue(10000.) # Сдвиг 10000 мкс выводит эталон за пределы трёхточечного измерения.
self.wait_idle(panel) # Параметр применяется без дополнительной кнопки расчёта.
self.assertIsNone(panel.comparison) # Отсутствие перекрытия исключает только статистику.
self.assertEqual([20., 21., 22.], panel.trace.times) # Кривая остаётся доступной исходному графику даже без общей части.
self.assertEqual(0, panel.results.table.rowCount()) # Старые расчёты не остаются видимыми после потери перекрытия.
self.assertFalse(panel.results.delta_curve.isEnabled() or panel.results.export_button.isEnabled()) # Без корректного результата отключаются остаток и экспорт, но не исходный график.
if __name__ == '__main__': # Набор можно выполнить стандартным Python без pytest.
unittest.main() # Общий порт проверяется отдельно от репозитория потребителя.