Расширить DEIF, загрузку CAN и общую обработку сигналов
Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
This commit is contained in:
@@ -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», сохраняет
|
||||
|
||||
@@ -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 завершена"
|
||||
|
||||
@@ -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) # Независимо проверяет только что сформированный результат.
|
||||
@@ -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",
|
||||
|
||||
@@ -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 # Недопустимый кадр не изменяет отображённые значения.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 и тестов.
|
||||
@@ -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-код.
|
||||
@@ -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.
|
||||
+88
-150
File diff suppressed because one or more lines are too long
@@ -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))
|
||||
@@ -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 не совпадает")
|
||||
|
||||
@@ -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
@@ -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 # Вызывающая навигация может отличить выполненный переход от отказа занятого адаптера.
|
||||
@@ -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):
|
||||
|
||||
@@ -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> ' # Цвет легенды совпадает с измеренной кривой.
|
||||
'<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
@@ -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
|
||||
|
||||
@@ -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 '')) # Краткий вывод связан с тем же покрытием, что полный математический отчёт.
|
||||
@@ -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
@@ -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 # Одновременные нарушения проводимости, утечки и контакта не скрывают друг друга.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 # Публикуем только целое сравнение вместе с завершёнными гипотезами.
|
||||
@@ -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):
|
||||
|
||||
@@ -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 не откатывает уже подтверждённые блоки.
|
||||
|
||||
@@ -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) # При полном молчании наблюдатель уважает прежний срок свежести пять секунд.
|
||||
@@ -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() # Общий порт проверяется отдельно от репозитория потребителя.
|
||||
Reference in New Issue
Block a user